@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.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. 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');