@mooncompany/uplink-chat 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of @mooncompany/uplink-chat might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- package/utils/with-retry.js +105 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
// ============================================
|
|
2
|
+
// NOTIFICATIONS MODULE
|
|
3
|
+
// Push Notifications when bot responds
|
|
4
|
+
// ============================================
|
|
5
|
+
|
|
6
|
+
import { UplinkCore } from './core.js';
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'uplink-notifications';
|
|
9
|
+
|
|
10
|
+
// State
|
|
11
|
+
let enabled = false;
|
|
12
|
+
let permission = 'default';
|
|
13
|
+
let pushSubscription = null;
|
|
14
|
+
let vapidPublicKey = null;
|
|
15
|
+
|
|
16
|
+
// Helper function to convert VAPID key
|
|
17
|
+
function urlBase64ToUint8Array(base64String) {
|
|
18
|
+
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
|
19
|
+
const base64 = (base64String + padding)
|
|
20
|
+
.replace(/\-/g, '+')
|
|
21
|
+
.replace(/_/g, '/');
|
|
22
|
+
|
|
23
|
+
const rawData = window.atob(base64);
|
|
24
|
+
const outputArray = new Uint8Array(rawData.length);
|
|
25
|
+
|
|
26
|
+
for (let i = 0; i < rawData.length; ++i) {
|
|
27
|
+
outputArray[i] = rawData.charCodeAt(i);
|
|
28
|
+
}
|
|
29
|
+
return outputArray;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function init() {
|
|
33
|
+
// Check browser support
|
|
34
|
+
if (!('Notification' in window)) {
|
|
35
|
+
console.log('Notifications: Not supported in this browser');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Load saved preference
|
|
40
|
+
const saved = localStorage.getItem(STORAGE_KEY);
|
|
41
|
+
if (saved !== null) {
|
|
42
|
+
enabled = saved === 'true';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Check current permission
|
|
46
|
+
permission = Notification.permission;
|
|
47
|
+
|
|
48
|
+
// Add toggle to settings
|
|
49
|
+
addNotificationToggle();
|
|
50
|
+
|
|
51
|
+
// Listen for assistant messages to trigger notifications
|
|
52
|
+
listenForMessages();
|
|
53
|
+
|
|
54
|
+
// If enabled but not subscribed, subscribe now
|
|
55
|
+
if (enabled && permission === 'granted') {
|
|
56
|
+
console.log('Notifications: Re-subscribing on init...');
|
|
57
|
+
const success = await subscribeToPush();
|
|
58
|
+
if (!success) {
|
|
59
|
+
console.warn('Notifications: Failed to re-subscribe, disabling');
|
|
60
|
+
enabled = false;
|
|
61
|
+
savePreference();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
console.log('Notifications: Initialized');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function addNotificationToggle() {
|
|
69
|
+
// Look for the dedicated slot first, then fall back to settingsPanel
|
|
70
|
+
const slot = document.getElementById('notificationSettingsSlot');
|
|
71
|
+
const settingsPanel = document.getElementById('settingsPanel');
|
|
72
|
+
const container = slot || settingsPanel;
|
|
73
|
+
|
|
74
|
+
if (!container) {
|
|
75
|
+
setTimeout(addNotificationToggle, 100);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check if already added
|
|
80
|
+
if (document.getElementById('notificationRow')) return;
|
|
81
|
+
|
|
82
|
+
const row = document.createElement('div');
|
|
83
|
+
row.className = 'panel-row setting-row';
|
|
84
|
+
row.id = 'notificationRow';
|
|
85
|
+
row.innerHTML = `
|
|
86
|
+
<div>
|
|
87
|
+
<div class="setting-label">Push Notifications</div>
|
|
88
|
+
<div class="setting-desc">Get notified when ${getAgentName()} responds</div>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="toggle ${enabled ? 'on' : ''}" id="notificationToggle" tabindex="0" role="switch" aria-checked="${enabled}" aria-label="Toggle push notifications"></div>
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
if (slot) {
|
|
94
|
+
slot.appendChild(row);
|
|
95
|
+
} else {
|
|
96
|
+
// Legacy fallback: insert after voice row
|
|
97
|
+
const voiceRow = settingsPanel.querySelector('.setting-row:nth-child(3)');
|
|
98
|
+
if (voiceRow) {
|
|
99
|
+
voiceRow.after(row);
|
|
100
|
+
} else {
|
|
101
|
+
settingsPanel.appendChild(row);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Toggle handler
|
|
106
|
+
const toggle = document.getElementById('notificationToggle');
|
|
107
|
+
toggle.addEventListener('click', async () => {
|
|
108
|
+
if (!enabled) {
|
|
109
|
+
// Trying to enable - need to request permission and set up push
|
|
110
|
+
const granted = await requestPermission();
|
|
111
|
+
if (granted) {
|
|
112
|
+
enabled = true;
|
|
113
|
+
toggle.classList.add('on');
|
|
114
|
+
toggle.setAttribute('aria-checked', 'true');
|
|
115
|
+
savePreference();
|
|
116
|
+
showTestNotification();
|
|
117
|
+
}
|
|
118
|
+
} else {
|
|
119
|
+
// Disabling - unsubscribe from push
|
|
120
|
+
await unsubscribeFromPush();
|
|
121
|
+
enabled = false;
|
|
122
|
+
toggle.classList.remove('on');
|
|
123
|
+
toggle.setAttribute('aria-checked', 'false');
|
|
124
|
+
savePreference();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Keyboard handler for accessibility
|
|
129
|
+
toggle.addEventListener('keydown', (e) => {
|
|
130
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
toggle.click();
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Update toggle state based on permission
|
|
137
|
+
if (permission === 'denied') {
|
|
138
|
+
toggle.classList.add('disabled');
|
|
139
|
+
toggle.title = 'Notifications blocked. Enable in browser settings.';
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Get VAPID public key from server
|
|
144
|
+
async function getVapidPublicKey() {
|
|
145
|
+
if (vapidPublicKey) return vapidPublicKey;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const response = await fetch('/api/push/vapid-public');
|
|
149
|
+
const data = await response.json();
|
|
150
|
+
vapidPublicKey = data.publicKey;
|
|
151
|
+
return vapidPublicKey;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
console.error('Notifications: Failed to get VAPID public key', e);
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Subscribe to push notifications
|
|
159
|
+
async function subscribeToPush() {
|
|
160
|
+
console.log('Notifications: Starting push subscription...');
|
|
161
|
+
|
|
162
|
+
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
|
163
|
+
console.warn('Notifications: Push notifications not supported');
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
// Get service worker registration
|
|
169
|
+
console.log('Notifications: Waiting for service worker...');
|
|
170
|
+
const registration = await navigator.serviceWorker.ready;
|
|
171
|
+
console.log('Notifications: Service worker ready', registration);
|
|
172
|
+
|
|
173
|
+
// Check if already subscribed
|
|
174
|
+
let subscription = await registration.pushManager.getSubscription();
|
|
175
|
+
console.log('Notifications: Existing subscription?', !!subscription);
|
|
176
|
+
|
|
177
|
+
if (!subscription) {
|
|
178
|
+
// Get VAPID public key
|
|
179
|
+
console.log('Notifications: Fetching VAPID key...');
|
|
180
|
+
const publicKey = await getVapidPublicKey();
|
|
181
|
+
if (!publicKey) {
|
|
182
|
+
console.error('Notifications: No VAPID public key available');
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
console.log('Notifications: Got VAPID key, subscribing to push manager...');
|
|
186
|
+
|
|
187
|
+
// Subscribe to push manager
|
|
188
|
+
subscription = await registration.pushManager.subscribe({
|
|
189
|
+
userVisibleOnly: true,
|
|
190
|
+
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
|
191
|
+
});
|
|
192
|
+
console.log('Notifications: Push manager subscription created');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Send subscription to server
|
|
196
|
+
console.log('Notifications: Sending subscription to server...');
|
|
197
|
+
const response = await fetch('/api/push/subscribe', {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'Content-Type': 'application/json' },
|
|
200
|
+
body: JSON.stringify({
|
|
201
|
+
subscription,
|
|
202
|
+
userId: 'default' // TODO: Get actual user ID when multi-user support is added
|
|
203
|
+
})
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
if (response.ok) {
|
|
207
|
+
pushSubscription = subscription;
|
|
208
|
+
console.log('Notifications: Push subscription successful!');
|
|
209
|
+
return true;
|
|
210
|
+
} else {
|
|
211
|
+
const err = await response.text();
|
|
212
|
+
console.error('Notifications: Failed to store push subscription', response.status, err);
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
} catch (e) {
|
|
216
|
+
console.error('Notifications: Push subscription failed', e);
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Unsubscribe from push notifications
|
|
222
|
+
async function unsubscribeFromPush() {
|
|
223
|
+
try {
|
|
224
|
+
if (pushSubscription) {
|
|
225
|
+
await pushSubscription.unsubscribe();
|
|
226
|
+
pushSubscription = null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Remove from server
|
|
230
|
+
await fetch('/api/push/unsubscribe', {
|
|
231
|
+
method: 'DELETE',
|
|
232
|
+
headers: { 'Content-Type': 'application/json' },
|
|
233
|
+
body: JSON.stringify({ userId: 'default' })
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
console.log('Notifications: Push unsubscription successful');
|
|
237
|
+
} catch (e) {
|
|
238
|
+
console.error('Notifications: Push unsubscription failed', e);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function requestPermission() {
|
|
243
|
+
if (permission === 'granted') {
|
|
244
|
+
// Already have permission, just set up push
|
|
245
|
+
await subscribeToPush();
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (permission === 'denied') {
|
|
250
|
+
alert('Notifications are blocked. Please enable them in your browser settings.');
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const result = await Notification.requestPermission();
|
|
256
|
+
permission = result;
|
|
257
|
+
|
|
258
|
+
if (result === 'granted') {
|
|
259
|
+
// Permission granted, set up push subscription
|
|
260
|
+
await subscribeToPush();
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return false;
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.error('Notifications: Permission request failed', e);
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function showTestNotification() {
|
|
272
|
+
if (!enabled || permission !== 'granted') return;
|
|
273
|
+
|
|
274
|
+
const notification = new Notification(`${getAgentName()} says hi! 🛰️`, {
|
|
275
|
+
body: 'Push Notifications are now enabled.',
|
|
276
|
+
icon: '/favicon.svg',
|
|
277
|
+
tag: 'uplink-test'
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
notification.onclick = () => {
|
|
281
|
+
window.focus();
|
|
282
|
+
notification.close();
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
// Auto-close after 5 seconds
|
|
286
|
+
setTimeout(() => notification.close(), 5000);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function showNotification(title, body) {
|
|
290
|
+
if (!enabled || permission !== 'granted') return;
|
|
291
|
+
|
|
292
|
+
// Don't show if window is focused
|
|
293
|
+
if (document.hasFocus()) return;
|
|
294
|
+
|
|
295
|
+
const notification = new Notification(title, {
|
|
296
|
+
body: truncate(body, 100),
|
|
297
|
+
icon: '/favicon.svg',
|
|
298
|
+
tag: 'uplink-message',
|
|
299
|
+
renotify: true
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
notification.onclick = () => {
|
|
303
|
+
window.focus();
|
|
304
|
+
notification.close();
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
// Auto-close after 10 seconds
|
|
308
|
+
setTimeout(() => notification.close(), 10000);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Module-level unsubscribe function for message hook
|
|
312
|
+
let messageHookUnsubscribe = null;
|
|
313
|
+
|
|
314
|
+
function listenForMessages() {
|
|
315
|
+
// Use the new hook system instead of patching window.addMessage
|
|
316
|
+
if (window.UplinkChat?.onMessage) {
|
|
317
|
+
messageHookUnsubscribe = window.UplinkChat.onMessage((msg) => {
|
|
318
|
+
// Show notification for assistant messages
|
|
319
|
+
if (msg.type === 'assistant' && msg.text) {
|
|
320
|
+
showNotification(getAgentName(), msg.text);
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
console.log('Notifications: Using onMessage hook');
|
|
324
|
+
} else {
|
|
325
|
+
// Fallback: retry after a delay if chat module not ready
|
|
326
|
+
setTimeout(listenForMessages, 100);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function getAgentName() {
|
|
331
|
+
return UplinkCore.agentName || 'Assistant';
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function truncate(str, len) {
|
|
335
|
+
if (!str || str.length <= len) return str;
|
|
336
|
+
return str.slice(0, len - 3) + '...';
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function savePreference() {
|
|
340
|
+
localStorage.setItem(STORAGE_KEY, String(enabled));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Export API
|
|
344
|
+
export const UplinkNotifications = {
|
|
345
|
+
show: showNotification,
|
|
346
|
+
isEnabled: () => enabled,
|
|
347
|
+
enable: async () => {
|
|
348
|
+
const granted = await requestPermission();
|
|
349
|
+
if (granted) {
|
|
350
|
+
enabled = true;
|
|
351
|
+
savePreference();
|
|
352
|
+
}
|
|
353
|
+
return granted;
|
|
354
|
+
},
|
|
355
|
+
disable: () => {
|
|
356
|
+
enabled = false;
|
|
357
|
+
savePreference();
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
export { showNotification };
|
|
362
|
+
|
|
363
|
+
// Backward compat: assign to window
|
|
364
|
+
window.UplinkNotifications = UplinkNotifications;
|
|
365
|
+
|
|
366
|
+
// Register and init
|
|
367
|
+
UplinkCore.registerModule('notifications', init);
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// ============================================
|
|
2
|
+
// OFFLINE QUEUE MODULE
|
|
3
|
+
// Queues messages when offline, processes when reconnected
|
|
4
|
+
// ============================================
|
|
5
|
+
|
|
6
|
+
import { UplinkLogger } from './logger.js';
|
|
7
|
+
|
|
8
|
+
// Offline message queue - persisted to localStorage
|
|
9
|
+
const OFFLINE_QUEUE_KEY = 'uplink-offline-queue';
|
|
10
|
+
const MAX_OFFLINE_QUEUE_SIZE = 50;
|
|
11
|
+
const MAX_OFFLINE_MESSAGE_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
12
|
+
|
|
13
|
+
let offlineQueue = [];
|
|
14
|
+
|
|
15
|
+
// ============================================
|
|
16
|
+
// PERSISTENCE
|
|
17
|
+
// ============================================
|
|
18
|
+
|
|
19
|
+
export function load() {
|
|
20
|
+
try {
|
|
21
|
+
const saved = localStorage.getItem(OFFLINE_QUEUE_KEY);
|
|
22
|
+
if (saved) {
|
|
23
|
+
offlineQueue = JSON.parse(saved);
|
|
24
|
+
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
const initialLength = offlineQueue.length;
|
|
27
|
+
offlineQueue = offlineQueue.filter(msg => {
|
|
28
|
+
const age = now - (msg.timestamp || 0);
|
|
29
|
+
return age < MAX_OFFLINE_MESSAGE_AGE_MS;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (offlineQueue.length < initialLength) {
|
|
33
|
+
UplinkLogger.debug(`OfflineQueue: Removed ${initialLength - offlineQueue.length} expired messages`);
|
|
34
|
+
save();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
UplinkLogger.debug('OfflineQueue: Loaded', offlineQueue.length, 'messages');
|
|
38
|
+
}
|
|
39
|
+
} catch (parseError) {
|
|
40
|
+
UplinkLogger.error('OfflineQueue: Failed to load', parseError);
|
|
41
|
+
offlineQueue = [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function save() {
|
|
46
|
+
try {
|
|
47
|
+
localStorage.setItem(OFFLINE_QUEUE_KEY, JSON.stringify(offlineQueue));
|
|
48
|
+
} catch (storageError) {
|
|
49
|
+
UplinkLogger.error('OfflineQueue: Failed to save', storageError);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============================================
|
|
54
|
+
// QUEUE OPERATIONS
|
|
55
|
+
// ============================================
|
|
56
|
+
|
|
57
|
+
export function queueMessage(text, imageUrl) {
|
|
58
|
+
if (offlineQueue.length >= MAX_OFFLINE_QUEUE_SIZE) {
|
|
59
|
+
const removed = offlineQueue.shift();
|
|
60
|
+
UplinkLogger.warn(`OfflineQueue: Queue full (${MAX_OFFLINE_QUEUE_SIZE}), removed oldest message ${removed.id}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const queuedMsg = {
|
|
64
|
+
id: `offline-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
65
|
+
text,
|
|
66
|
+
imageUrl: imageUrl || null,
|
|
67
|
+
timestamp: Date.now()
|
|
68
|
+
};
|
|
69
|
+
offlineQueue.push(queuedMsg);
|
|
70
|
+
save();
|
|
71
|
+
|
|
72
|
+
UplinkLogger.debug('OfflineQueue: Queued message', queuedMsg.id);
|
|
73
|
+
return queuedMsg;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function processQueue() {
|
|
77
|
+
if (offlineQueue.length === 0) return;
|
|
78
|
+
if (!navigator.onLine) return;
|
|
79
|
+
|
|
80
|
+
const core = window.UplinkCore;
|
|
81
|
+
if (core && core.chatState !== 'idle') {
|
|
82
|
+
setTimeout(() => processQueue().catch(err => {
|
|
83
|
+
UplinkLogger.error('OfflineQueue: Processing failed', err);
|
|
84
|
+
}), 1000);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
UplinkLogger.debug('OfflineQueue: Processing', offlineQueue.length, 'messages');
|
|
89
|
+
|
|
90
|
+
while (offlineQueue.length > 0) {
|
|
91
|
+
const msg = offlineQueue.shift();
|
|
92
|
+
save();
|
|
93
|
+
|
|
94
|
+
const queuedMsgEl = document.querySelector(`[data-offline-id="${msg.id}"]`);
|
|
95
|
+
if (queuedMsgEl) {
|
|
96
|
+
const indicator = queuedMsgEl.querySelector('.queued-indicator');
|
|
97
|
+
if (indicator) indicator.remove();
|
|
98
|
+
queuedMsgEl.classList.remove('queued');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const chat = window.UplinkChat;
|
|
102
|
+
if (msg.imageUrl && chat?.sendImageMessage) {
|
|
103
|
+
await chat.sendImageMessage(msg.imageUrl, msg.text);
|
|
104
|
+
} else if (chat?.sendTextMessage) {
|
|
105
|
+
await chat.sendTextMessage(msg.text);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
UplinkLogger.debug('OfflineQueue: Processed');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function clear() {
|
|
113
|
+
offlineQueue = [];
|
|
114
|
+
save();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ============================================
|
|
118
|
+
// QUEUED MESSAGE DISPLAY
|
|
119
|
+
// ============================================
|
|
120
|
+
|
|
121
|
+
export function addMessageWithQueuedIndicator(container, text, type, imageUrl, offlineId, formatMessage, isNearBottom) {
|
|
122
|
+
if (!container) return;
|
|
123
|
+
|
|
124
|
+
const div = document.createElement('div');
|
|
125
|
+
div.className = `message ${type} queued`;
|
|
126
|
+
div.dataset.time = Date.now();
|
|
127
|
+
div.dataset.originalText = text;
|
|
128
|
+
if (offlineId) {
|
|
129
|
+
div.dataset.offlineId = offlineId;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (imageUrl) {
|
|
133
|
+
const img = document.createElement('img');
|
|
134
|
+
img.src = imageUrl;
|
|
135
|
+
img.alt = 'Image queued for sending';
|
|
136
|
+
div.appendChild(img);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (text) {
|
|
140
|
+
const textSpan = document.createElement('span');
|
|
141
|
+
textSpan.className = 'message-text';
|
|
142
|
+
textSpan.innerHTML = formatMessage ? formatMessage(text) : text;
|
|
143
|
+
div.appendChild(textSpan);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const indicator = document.createElement('span');
|
|
147
|
+
indicator.className = 'queued-indicator';
|
|
148
|
+
indicator.innerHTML = '⏳ Queued - will send when online';
|
|
149
|
+
indicator.style.cssText = `
|
|
150
|
+
display: block;
|
|
151
|
+
font-size: 0.75rem;
|
|
152
|
+
color: #f59e0b;
|
|
153
|
+
margin-top: 4px;
|
|
154
|
+
font-style: italic;
|
|
155
|
+
`;
|
|
156
|
+
div.appendChild(indicator);
|
|
157
|
+
|
|
158
|
+
container.appendChild(div);
|
|
159
|
+
if (isNearBottom) container.scrollTop = container.scrollHeight;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ============================================
|
|
163
|
+
// PUBLIC API
|
|
164
|
+
// ============================================
|
|
165
|
+
|
|
166
|
+
export const UplinkOfflineQueue = {
|
|
167
|
+
load,
|
|
168
|
+
queueMessage,
|
|
169
|
+
processQueue,
|
|
170
|
+
clear,
|
|
171
|
+
addMessageWithQueuedIndicator,
|
|
172
|
+
getLength: () => offlineQueue.length
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// Backward compat: assign to window
|
|
176
|
+
window.UplinkOfflineQueue = UplinkOfflineQueue;
|
|
177
|
+
|
|
178
|
+
UplinkLogger.debug('OfflineQueue: Module loaded');
|