Repository navigation
Expand file tree
/
Copy pathchat.js
More file actions
1354 lines (1335 loc) · 85.1 KB
/
Copy pathchat.js
File metadata and controls
1354 lines (1335 loc) · 85.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import { downloadChunkedRelay, uploadChunkedRelay, uploadRelay } from './storage-upload.js';
import { CHUNKED_RELAY_FORMAT, RELAY_PLAIN_CHUNK, createChunkedRelayEncryptor, decryptSharedRelayChunk } from './relay-crypto.js';
import { sendBlobSlices } from './p2p-file.js';
import { createMessageBatcher } from './message-batcher.js';
import { loadVideoBlob } from './video-media.js';
import { parseCommand, runChatCommand, applySavedFont } from './chat-commands.js';
import { isLocationCommand, runLocationCommand, locationLinks } from './location-command.js';
import { initCallWindow } from './call-window.js';
import { sb, SUPABASE_URL, SUPABASE_KEY, SNAP_BUCKET, $, el, esc, rand, toast, state, idb, isOnline, setFriendActivity, activityText, initial, ago,
avatarHTML, safeMediaUrl, chunkString, mimeKind, icon } from './core.js';
import { peer, fetchSnap } from './rtc.js';
import { db } from './db.js';
import { encryptText, decryptText, decryptWith, decryptSharedRelay, encryptSharedRelay, unwrapSharedRelayKey, wrapSharedRelayKey } from './crypto.js';
import { browserNotificationsEnabled } from './push.js';
import { mountCallApps, unmountCallApps, toggleCallApps, receiveCallApp } from './callapps.js';
// ===================== unified conversations (snaps + chat, Snapchat-style) =====================
// TEXT is async + end-to-end encrypted via mf_messages (works even when the friend is
// offline — they pick it up on next open, then the row is deleted). MEDIA / voice notes
// / video calls are live P2P (both online). Snaps normally become inline chat media;
// a positive timer keeps the full-screen view-once treatment.
// Each device keeps its own thread history in IndexedDB (thread:<uid>); nothing readable
// lives on the server.
const MSG_MAX = 2000; // max characters per chat message
const conns = new Map(); // uid -> live P2P data conn (for media/voice/typing)
let inboxByUser = {}; // uid -> [unopened snap rows]
const unreadMsg = new Set(); // uids with messages received while their thread was closed
let openUid = null; // conversation currently on screen
let openUsername = 'friend';
window.addEventListener('mf-activity-updated',()=>{if(!openUid||!threadBox)return;const n=threadBox.querySelector('.friendactivity');if(n)n.textContent=activityText(openUid,'active now');});
let threadBox = null, convBox = null;
let setReplyDraft = () => {};
// Reactions are delivered as small encrypted control messages. In the unusual case
// that a reaction reaches this device before the message it refers to, keep it long
// enough to apply when that message is ingested.
const pendingReactions = new Map();
// Legacy photo snaps use plain "live" / "relay". Video snaps retain their MIME type
// in the existing delivery value, avoiding a database migration.
const snapMime = (s) => {
const tag = s.delivery?.split(':')[1];
try { return tag ? decodeURIComponent(tag) : 'image/jpeg'; }
catch (e) { return 'image/jpeg'; }
};
const snapKind = (s) => snapMime(s).startsWith('video/') ? 'video' : 'photo';
const receiptKey = (id) => 'snap-receipt:' + id;
const statusRank = { sent: 0, delivered: 1, expired: 2, opened: 3 };
const pendingSnapStatuses = new Map();
const THREAD_CLEAR_KEY = 'mf_thread_clear_marks';
const THREAD_HIDE_KEY = 'mf_thread_hide_marks';
const clearMarks = () => { try { return JSON.parse(localStorage.getItem(THREAD_CLEAR_KEY) || '{}'); } catch (e) { return {}; } };
const hideMarks = () => { try { return JSON.parse(localStorage.getItem(THREAD_HIDE_KEY) || '{}'); } catch (e) { return {}; } };
const clearAt = (uid) => { const marks = clearMarks(); return Math.max(Number(marks['*']) || 0, Number(marks[uid]) || 0); };
const clearAllAt = () => Number(clearMarks()['*']) || 0;
const markCleared = (uid) => { const marks = clearMarks(); marks[uid] = Date.now(); localStorage.setItem(THREAD_CLEAR_KEY, JSON.stringify(marks)); };
const hideAt = (uid) => Number(hideMarks()[uid]) || 0;
const markHidden = (uid) => { const marks = hideMarks(); marks[uid] = Date.now(); localStorage.setItem(THREAD_HIDE_KEY, JSON.stringify(marks)); };
const isAfterClear = (uid, at) => Number(at) > clearAt(uid);
const onChange = () => window.dispatchEvent(new Event('chat-unread'));
export const chatUnread = () => {
const s = new Set(unreadMsg);
for (const uid in inboxByUser) if (inboxByUser[uid]?.some(snap => isAfterClear(uid, new Date(snap.created_at).getTime()))) s.add(uid);
return s.size;
};
// ---- local per-friend thread history ----
const histGet = (uid) => idb.get('thread:' + uid).then(h => h || []);
// A Story reply, an opened Snap, and an incoming chat can arrive close together.
// Serialize their read-modify-write cycle so one stale history snapshot cannot erase
// another entry when the page is later refreshed.
const histWrites = new Map();
const histUpdate = (uid, change) => {
const previous = histWrites.get(uid) || Promise.resolve();
const task = previous.catch(() => {}).then(async () => {
const history = await histGet(uid);
const result = await change(history);
await idb.set('thread:' + uid, history);
return result;
});
histWrites.set(uid, task);
void task.then(
() => { if (histWrites.get(uid) === task) histWrites.delete(uid); },
() => { if (histWrites.get(uid) === task) histWrites.delete(uid); },
);
return task;
};
const entryId = () => crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
const histPush = (uid, entry) => histUpdate(uid, (history) => {
entry.localId ||= entryId();
history.push(entry); if (history.length > 300) history.splice(0, history.length - 300);
});
const lastLine = (h) => {
if (!h || !h.length) return '';
const m = h[h.length - 1];
return m.kind === 'text' ? (m.me ? 'You: ' : '') + m.text
: m.kind === 'story-reply' ? (m.me ? 'You: ' : '') + 'Story reply'
: m.kind === 'snap' ? '📷 You sent a Snap'
: m.kind === 'gif' ? (m.me ? 'You: ' : '') + (m.type === 'sticker' ? 'Sticker' : 'GIF')
: m.kind === 'media' ? (m.me ? 'You: ' : '') + '📎 ' + (m.name || m.mediaKind || 'attachment') : '';
};
const deviceKeysOf = async (uid) => {
const { data } = await db.devicesForUser(uid);
const devices = (data || []).map(device => {
try { return { id: device.id, pubkey: JSON.parse(device.pubkey) }; } catch (e) { return null; }
}).filter(Boolean);
// Keep a legacy delivery fallback for someone who has not upgraded yet.
if (!devices.length) {
const { data: profile } = await db.profileById(uid);
try { if (profile?.pubkey) devices.push({ id: null, pubkey: JSON.parse(profile.pubkey) }); } catch (e) {}
}
return devices;
};
// ---- pull any messages that arrived while we were offline ----
export const syncMessages = async () => {
const { data } = await db.myUndelivered();
await receiveMessages(data || []);
};
const storyReplyFromPayload = async (text, me = false, at = Date.now(), localPreview = null) => {
try {
const p = JSON.parse(text);
if (p?.t !== 'story-reply' || typeof p.storyId !== 'string' || typeof p.text !== 'string') return null;
let preview = localPreview, storyW = Number(p.w) || 0, storyH = Number(p.h) || 0;
if (!preview || !storyW || !storyH) {
const { data } = await db.storyById(p.storyId);
preview ||= data?.preview || null; storyW ||= Number(data?.w) || 0; storyH ||= Number(data?.h) || 0;
}
return { me, kind: 'story-reply', text: p.text, storyId: p.storyId, preview, storyW, storyH, at };
} catch (e) { return null; }
};
const clipFromPayload = (text, me = false, at = Date.now()) => {
try { const p = JSON.parse(text); return p?.t === 'clip-share' && p.provider === 'youtube' && typeof p.name === 'string' && /^[\w-]{11}$/.test(p.videoId || '') ? { me, kind: 'clip', name: p.name, videoId: p.videoId, caption: typeof p.caption === 'string' ? p.caption.slice(0, 240) : '', at } : null; } catch (e) { return null; }
};
const gifFromPayload = (text, me = false, at = Date.now()) => {
try { const p = JSON.parse(text), url = new URL(p?.url || ''); if (p?.t !== 'gif-share' || p.provider !== 'giphy' || !['gif', 'sticker'].includes(p.type) || !/^(media\d*|i)\.giphy\.com$/i.test(url.hostname)) return null; return { me, kind: 'gif', type: p.type, url: url.href, title: typeof p.title === 'string' ? p.title.slice(0, 160) : p.type, at }; } catch (e) { return null; }
};
const replyFromPayload = (text, me = false, at = Date.now()) => {
try { const p = JSON.parse(text); return p?.t === 'chat-reply' && typeof p.text === 'string' && typeof p.reply === 'string' ? { me, kind: 'text', text: p.text.slice(0, MSG_MAX), replyTo: p.reply.slice(0, 240), at } : null; } catch (e) { return null; }
};
const reactionFromPayload = (text) => {
try {
const p = JSON.parse(text);
return p?.t === 'chat-reaction' && typeof p.targetId === 'string' && p.targetId.length <= 160 && REACTION_VALUES.has(p.reaction)
? { targetId: p.targetId, reaction: p.reaction }
: null;
} catch (e) { return null; }
};
const REACTION_VALUES = new Set(['\u{1F44D}', '\u2665', '\u2764\uFE0F', '\u{1F602}', '\u{1F62E}', '\u{1F622}', '\u{1F525}']);
const repairStoredReactions = (history) => {
let changed = false;
for (let i = history.length - 1; i >= 0; i--) {
const entry = history[i];
const reaction = entry?.kind === 'text' && !entry.me ? reactionFromPayload(entry.text) : null;
if (!reaction) continue;
const target = history.find(item => item.msgId === reaction.targetId);
if (target) target.reaction = reaction.reaction;
else pendingReactions.set(reaction.targetId, reaction.reaction);
history.splice(i, 1);
changed = true;
}
return changed;
};
const decodeTitle = (value = '') => { const node = document.createElement('textarea'); node.innerHTML = value; return node.value; };
const ingestMessageBatch = async (rows) => {
// Decrypt a bounded chunk concurrently; one bad envelope cannot block its peers.
const decoded = await Promise.allSettled(rows.map(async row => {
const text = await decryptText(state.priv, row.eph_pub, row.iv, row.body);
const at = new Date(row.created_at).getTime();
const reaction = reactionFromPayload(text);
const entry = reaction ? null : await storyReplyFromPayload(text, false, at) || replyFromPayload(text, false, at) || gifFromPayload(text, false, at) || clipFromPayload(text, false, at) || { me: false, kind: 'text', text, at };
if (entry) { entry.msgId = row.message_id || row.id; entry.deliveryId = row.id; }
return { row, entry, reaction, at };
}));
const byUser = new Map();
for (const result of decoded) if (result.status === 'fulfilled') {
const item = result.value;
if (!byUser.has(item.row.sender_id)) byUser.set(item.row.sender_id, []);
byUser.get(item.row.sender_id).push(item);
}
const completed = new Set(), newlyUnread = new Set();
await Promise.all([...byUser].map(async ([uid, items]) => {
items.sort((a,b) => a.at - b.at || a.row.id.localeCompare(b.row.id));
let added = false;
try {
await histUpdate(uid, history => {
for (const item of items) {
if (!isAfterClear(uid, item.at)) continue;
if (item.reaction) {
const target = history.find(entry => entry.msgId === item.reaction.targetId);
if (target) target.reaction = item.reaction.reaction;
else { pendingReactions.set(item.reaction.targetId, item.reaction.reaction); if (pendingReactions.size > 500) pendingReactions.delete(pendingReactions.keys().next().value); }
} else if (!history.some(entry => entry.deliveryId === item.row.id || entry.msgId === item.entry.msgId)) {
const reaction = pendingReactions.get(item.entry.msgId);
if (reaction) { item.entry.reaction = reaction; pendingReactions.delete(item.entry.msgId); }
history.push(item.entry); added = true;
}
}
history.sort((a,b) => a.at - b.at);
if (history.length > 300) history.splice(0, history.length - 300);
});
for (const item of items) completed.add(item.row.id);
if (added && openUid !== uid) { unreadMsg.add(uid); newlyUnread.add(uid); }
} catch (error) { console.warn('[mayfly] could not save message batch', error); }
}));
// Acknowledge only envelopes durably saved locally, in one request per chunk.
let acknowledged = true;
if (completed.size) {
try { const {error} = await db.delMessages([...completed]); if (error) { acknowledged = false; console.warn('[mayfly] message batch acknowledgement failed', error); } }
catch (error) { acknowledged = false; console.warn('[mayfly] message batch acknowledgement failed', error); }
}
if (openUid && byUser.has(openUid)) await renderThreadBody(openUid);
if (convBox?.isConnected) await renderConvs(convBox, openUid);
if (newlyUnread.size && browserNotificationsEnabled()) {
try { new Notification('mayfly 🐛', {body: 'New messages'}); } catch {}
}
onChange();
return acknowledged ? completed : new Set();
};
const queueMessages = createMessageBatcher(ingestMessageBatch);
const receiveMessages = rows => queueMessages(rows.filter(row => row.recipient_id === state.me.id && (!row.recipient_device_id || row.recipient_device_id === state.deviceId)));
// Realtime bursts share the same queue as catch-up, with one render per chunk.
export const onMessageInsert = row => {
receiveMessages([row]).catch(error => console.warn('[mayfly] could not ingest live messages', error));
};
// ---- a snap arrived for me / I sent one ----
export const onSnapInsert = async (row) => {
if (row.recipient_id !== state.me.id) return;
if (row.recipient_device_id && row.recipient_device_id !== state.deviceId) return;
(inboxByUser[row.sender_id] = inboxByUser[row.sender_id] || []).unshift(row);
if (!row.delivered_at) db.markSnapDelivered(row.id).then(() => {}, () => {});
if (openUid === row.sender_id && threadBox) renderThreadBody(row.sender_id);
else if (browserNotificationsEnabled()) new Notification('mayfly 🐛', { body: `New ${snapKind(row) === 'video' ? 'video' : 'photo'} Snap!` });
if (convBox) renderConvs(convBox, openUid);
onChange();
};
export const noteSentSnap = async (uid, snapId = null, snapKind = 'photo') => {
const status = snapId ? (pendingSnapStatuses.get(snapId) || 'sent') : 'sent';
const entry = { me: true, kind: 'snap', snapId, snapKind, status, at: Date.now(), expiresAt: Date.now() + 24 * 60 * 60 * 1000 };
await histPush(uid, entry);
if (snapId) { pendingSnapStatuses.delete(snapId); await idb.set(receiptKey(snapId), { uid, status }); }
if (convBox) renderConvs(convBox, openUid);
};
const updateSentSnapStatus = async (id, status) => {
if (!id) return;
const receipt = await idb.get(receiptKey(id));
if (!receipt) { pendingSnapStatuses.set(id, status); return; }
if (statusRank[status] < statusRank[receipt.status || 'sent']) return;
receipt.status = status;
await idb.set(receiptKey(id), receipt);
await histUpdate(receipt.uid, (history) => {
const entry = history.find(e => e.kind === 'snap' && e.snapId === id);
if (entry && statusRank[status] >= statusRank[entry.status || 'sent']) entry.status = status;
}).catch(() => {});
if (openUid === receipt.uid) renderThreadBody(receipt.uid);
if (convBox) renderConvs(convBox, openUid);
};
export const markSnapDelivered = (id) => { updateSentSnapStatus(id, 'delivered'); };
export const markSnapOpened = (id) => { updateSentSnapStatus(id, 'opened'); };
export const markSnapRemoved = (id) => { setTimeout(() => updateSentSnapStatus(id, 'expired'), 250); };
const snapReceipt = (e) => `<div class="msgstatus me ${e.snapKind || 'photo'} ${e.status || 'sent'}"${e.snapId ? ` data-snap="${e.snapId}"` : ''}><span class="si"></span><span class="sl">${({ sent: 'Sent', delivered: 'Delivered', opened: 'Opened', expired: 'Expired' })[e.status] || 'Sent'}</span></div>`;
// Text delivery receipt (blue): a message row is deleted the moment the recipient's
// device ingests it, so its realtime DELETE tells the sender it was delivered.
const deliveredMsgIds = new Set();
let deliveryRenderTimer = null;
export const markMessageDelivered = (id) => {
if (!id) return; deliveredMsgIds.add(id);
if (openUid && !deliveryRenderTimer) deliveryRenderTimer = setTimeout(() => { deliveryRenderTimer = null; if (openUid) renderThreadBody(openUid); }, 30);
};
const textReceipt = (delivered) => `<div class="msgstatus me text ${delivered ? 'delivered' : ''}"><span class="si"></span><span class="sl">${delivered ? 'Delivered' : 'Sent'}</span></div>`;
const refreshInbox = async () => {
const { data } = await db.inbox();
inboxByUser = {};
(data || []).forEach(s => (inboxByUser[s.sender_id] = inboxByUser[s.sender_id] || []).push(s));
Promise.all((data || []).filter(s => !s.delivered_at).map(s => db.markSnapDelivered(s.id))).catch(() => {});
};
const clearReceiptsFor = async (uid = null) => {
const keys = await idb.keys();
await Promise.all(keys.filter(key => typeof key === 'string' && key.startsWith('snap-receipt:')).map(async key => {
const receipt = await idb.get(key);
if (!uid || receipt?.uid === uid) await idb.del(key);
}));
};
export const clearConversation = async (uid) => {
markCleared(uid); unreadMsg.delete(uid);
await histUpdate(uid, history => {
const retained = history.filter(entry => isAfterClear(uid, entry.at));
history.splice(0, history.length, ...retained);
});
await clearReceiptsFor(uid).catch(() => {});
if (openUid === uid) await renderThreadBody(uid);
if (convBox) renderConvs(convBox, openUid);
onChange();
};
export const clearAllLocalConversations = async () => {
const marks = clearMarks(); marks['*'] = Date.now(); localStorage.setItem(THREAD_CLEAR_KEY, JSON.stringify(marks));
unreadMsg.clear();
const keys = await idb.keys();
const uids = new Set([...histWrites.keys(), ...keys.filter(key => typeof key === 'string' && key.startsWith('thread:')).map(key => key.slice(7))]);
await Promise.all([...uids].map(uid => histUpdate(uid, history => {
const retained = history.filter(entry => isAfterClear(uid, entry.at));
history.splice(0, history.length, ...retained);
})));
await clearReceiptsFor();
if (openUid) await renderThreadBody(openUid);
if (convBox) renderConvs(convBox, openUid);
onChange();
};
export const hideConversation = (uid) => {
markHidden(uid); unreadMsg.delete(uid);
if (convBox) renderConvs(convBox, openUid);
onChange();
};
// ===================== conversation list =====================
export const renderConvs = async (box, activeUid) => {
convBox = box;
const [{ data: fr }, { data: activeStories }, { data: viewed }] = await Promise.all([db.friends(), db.activeStories(), db.myViewedStories()]);
const friends = (fr || []).map(f => f.requester_id === state.me.id ? f.addressee : f.requester).filter(Boolean);
if (box !== convBox) return;
// friends with an UNWATCHED live Story → ring their avatar; tapping plays the unseen ones
const seen = new Set((viewed || []).map(v => v.story_id));
const storyByUid = new Map();
(activeStories || []).forEach(st => { if (st.user_id !== state.me.id && !seen.has(st.id)) (storyByUid.get(st.user_id) || storyByUid.set(st.user_id, []).get(st.user_id)).push(st); });
// build each conversation's summary
const rows = (await Promise.all(friends.map(async (u) => {
const h = await histGet(u.id);
const pending = (inboxByUser[u.id] || []).filter(snap => isAfterClear(u.id, new Date(snap.created_at).getTime()));
const snaps = pending.length;
const kind = snaps ? snapKind(pending[0]) : null;
const lastAt = h.length ? h[h.length - 1].at : 0;
if (u.id !== activeUid && clearAllAt() && lastAt <= clearAllAt() && !snaps) return null;
if (hideAt(u.id) && lastAt <= hideAt(u.id) && !pending.some(snap => new Date(snap.created_at).getTime() > hideAt(u.id))) return null;
const unread = snaps > 0 || unreadMsg.has(u.id);
const status = snaps ? `New ${kind === 'video' ? 'Video' : 'Photo'} Snap${snaps > 1 ? ` ×${snaps}` : ''}` : (lastLine(h) || 'Tap to chat');
return { u, lastAt: Math.max(lastAt, snaps ? Date.now() : 0), unread, status, snaps, kind };
}))).filter(Boolean);
rows.sort((a, b) => (b.unread - a.unread) || (b.lastAt - a.lastAt));
box.innerHTML = '';
if (!rows.length) { box.innerHTML = `<div class="empty">No friends yet. <a href="#/friends">Add some →</a></div>`; return; }
rows.forEach(({ u, unread, status, snaps, kind }) => {
const theirStory = storyByUid.get(u.id);
const avatar = theirStory
? `<span class="storyavatar" role="button" tabindex="0" aria-label="View ${esc(u.username)}'s story">${avatarHTML(u.username, u.avatar, 'hasstory')}</span>`
: avatarHTML(u.username, u.avatar);
const row = el(`<button class="conv ${u.id === activeUid ? 'active' : ''} ${unread ? 'unread' : ''} ${kind ? 'snap-' + kind : ''}" data-go="#/c/${u.id}">
<span class="convhide" role="button" tabindex="0" aria-label="Hide conversation with ${esc(u.username)}" title="Hide conversation">×</span>
${avatar}
<div class="who"><b>${esc(u.username)}</b>
<div class="sub ${unread ? 'hot' : ''}">${isOnline(u.id) ? '<i class="dot"></i>' : ''}${esc(status)}</div></div>
<span class="camicon" data-snap="${u.id}" aria-label="Send a snap">${icon('camera', 20)}</span></button>`);
$('.camicon', row).onclick = (e) => { e.preventDefault(); e.stopPropagation(); location.hash = '#/snap/' + u.id; };
const hide = $('.convhide', row);
const doHide = (e) => { e.preventDefault(); e.stopPropagation(); hideConversation(u.id); if (openUid === u.id) location.hash = '#/chats'; };
hide.onclick = doHide;
hide.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') doHide(e); };
if (theirStory) {
const sa = $('.storyavatar', row);
const playStory = (e) => { e.preventDefault(); e.stopPropagation(); window.dispatchEvent(new CustomEvent('mf-play-story', { detail: { items: theirStory } })); };
sa.onclick = playStory;
sa.onkeydown = (e) => { if (e.key === 'Enter' || e.key === ' ') playStory(e); };
}
box.appendChild(row);
});
};
// ===================== conversation thread =====================
export const openConversation = async (box, uid) => {
openUid = uid; threadBox = box;
unreadMsg.delete(uid); onChange();
const [{ data: prof }, { data: activity }] = await Promise.all([db.profileById(uid),db.friendActivity()]);setFriendActivity(activity||[]);
const username = prof?.username || 'friend';
openUsername = username;
box.innerHTML = `<div class="thread">
<div class="thead">
<button class="icon back" data-go="#/chats" aria-label="Back">‹</button>
${avatarHTML(username, prof?.avatar)}
<div class="who"><b>${esc(username)}</b><div class="sub"><i class="cdot" style="opacity:${isOnline(uid) ? '1' : '.3'}"></i> <span class="friendactivity">${activityText(uid,'active now')}</span></div></div>
<button class="icon callbtn" aria-label="Call">${icon('phone')}</button>
<button class="icon chatmore" aria-label="Chat options">${icon('more')}</button>
<div class="headmenu" hidden><button type="button" class="clearthread">Clear chat on this device</button></div>
</div>
<div class="tbody" id="tbody"><div class="spin">…</div></div>
<div class="ctyping" id="ctyping"></div>
<div class="voicepreview" hidden></div>
<div class="replydraft" id="replydraft" hidden><span></span><button type="button" aria-label="Cancel reply">×</button></div>
<div class="commandstatus" role="status" aria-live="polite"></div>
<form class="tin">
<button type="button" class="icon snapbtn" aria-label="Send a snap">${icon('camera')}</button>
<input class="tinput" placeholder="Message or /help" autocomplete="off" enterkeyhint="send" maxlength="2000" aria-label="Message">
<button type="button" class="icon mic" aria-label="Record a voice note">${icon('mic')}</button>
<button type="button" class="icon gifbtn" aria-label="GIFs and Stickers"><span class="gifmark">GIF</span></button>
<button type="button" class="icon attach" aria-label="Attach a file">${icon('paperclip')}</button>
<input type="file" class="fileinput" hidden>
</form>
</div>`;
$('.callbtn', box).onclick = (e) => callMenu(e.currentTarget, (video) => callUser(uid, username, video));
const menu = $('.headmenu', box), more = $('.chatmore', box);
more.onclick = () => { menu.hidden = !menu.hidden; more.setAttribute('aria-expanded', String(!menu.hidden)); };
$('.clearthread', box).onclick = async () => {
if (!confirm(`Clear this chat with ${username} on this device?`)) return;
menu.hidden = true; await clearConversation(uid); toast('Chat cleared on this device.');
};
$('.snapbtn', box).onclick = () => { location.hash = '#/snap/' + uid; };
$('.gifbtn', box).onclick = () => openGifPicker(uid, username);
const fileInput = $('.fileinput', box);
$('.attach', box).onclick = () => fileInput.click();
fileInput.onchange = () => { const f = fileInput.files[0]; if (f) sendFile(uid, f, mimeKind(f.type)); fileInput.value = ''; };
wireMic(box, uid);
const form = $('.tin', box), input = $('.tinput', box);
let replyDraft = null;
const replyBar = $('#replydraft');
setReplyDraft = (entry) => {
replyDraft = { text: String(entry.text || entry.name || entry.title || (entry.type === 'sticker' ? 'Sticker' : entry.type === 'gif' ? 'GIF' : 'Message')).slice(0, 240) };
$('span', replyBar).textContent = `Replying to: ${replyDraft.text}`; replyBar.hidden = false; input.focus();
};
$('button', replyBar).onclick = () => { replyDraft = null; replyBar.hidden = true; };
let locating = false;
const sendCommandEntry = async (text, entry) => {
entry ||= { me: true, kind: 'text', text, at: Date.now(), msgId: entryId(), status: 'sent', localId: entryId() };
if (!await sendText(uid, username, text, entry, { keepLocalHistory: false })) return false;
await histPush(uid, entry); if (openUid === uid) appendEntry(entry);
return true;
};
form.onsubmit = async (e) => {
e.preventDefault(); const t = input.value.trim(); if (!t || locating) return;
const command = parseCommand(t);
if (command) {
locating = true; input.disabled = true;
const active = () => form.isConnected && openUid === uid;
try {
$('.commandstatus', box).textContent = '';
const ok = await runChatCommand(command, { active,
status: text => { if (active()) $('.commandstatus', box).textContent = text; },
searchGif: query => sb.functions.invoke('giphy', { body: { query, type: 'gifs' } }),
sendText: text => sendCommandEntry(text),
sendGif: gif => sendCommandEntry(JSON.stringify({ t: 'gif-share', provider: 'giphy', type: 'gif', url: gif.url, title: gif.title }), { me: true, kind: 'gif', type: 'gif', url: gif.url, title: gif.title, at: Date.now(), msgId: entryId(), localId: entryId(), status: 'sent' }),
});
if (ok && active()) { input.value = ''; replyDraft = null; replyBar.hidden = true; }
} finally { locating = false; input.disabled = false; }
return;
}
if (isLocationCommand(t)) {
locating = true; input.disabled = true;
const active = () => form.isConnected && openUid === uid;
try {
const sent = await runLocationCommand({ active,
status: text => { if (active()) $('.commandstatus', box).textContent = text; },
send: async text => {
const entry = { me: true, kind: 'text', text, at: Date.now(), msgId: entryId(), status: 'sent', localId: entryId() };
if (!await sendText(uid, username, text, entry, { keepLocalHistory: false })) return false;
await histPush(uid, entry); if (openUid === uid) appendEntry(entry);
return true;
},
});
if (sent && active()) { input.value = ''; replyDraft = null; replyBar.hidden = true; }
} finally { locating = false; input.disabled = false; }
return;
}
input.value = '';
const msgId = entryId(), replyTo = replyDraft?.text || '';
const payload = replyTo ? JSON.stringify({ t: 'chat-reply', text: t, reply: replyTo }) : t;
replyDraft = null; replyBar.hidden = true;
sendText(uid, username, payload, { me: true, kind: 'text', text: t, replyTo, at: Date.now(), msgId, status: 'sent', localId: entryId() });
};
input.oninput = () => { const c = conns.get(uid); if (c?.open) { try { c.send({ t: 'typing' }); } catch (e) {} clearTimeout(input._tt); input._tt = setTimeout(() => { try { c.send({ t: 'stop' }); } catch (e) {} }, 1200); } };
await renderThreadBody(uid);
ensureConn(uid, username); // best-effort live link for typing / media
// grab any messages this friend sent while we were away
const { data: pend } = await db.myUndelivered();
await Promise.all([receiveMessages(pend || []), syncTransfers()]);
};
// Merge local history + unopened snap cards into one chronological timeline.
const closeMessageMenus = (except = null) => document.querySelectorAll('.messagemenu:not([hidden]), .reactiontray:not([hidden])').forEach(menu => { if (menu !== except) menu.hidden = true; });
const renderThreadBody = async (uid, preserveScroll = false) => {
const body = $('#tbody'); if (!body || openUid !== uid) return;
const scrollTop = body.scrollTop;
let h = await histGet(uid);
// Versions before the emoji picker treated a valid reaction payload as a
// normal message. Repair those local entries without requiring deletion.
if (repairStoredReactions(h)) {
await histUpdate(uid, repairStoredReactions);
h = await histGet(uid);
}
// Expire locally tracked receipts after their 24-hour delivery window.
let dirty = false;
const reconcile = (e) => {
if (e.kind === 'snap' && e.expiresAt && e.expiresAt <= Date.now() && e.status !== 'opened' && e.status !== 'expired') { e.status = 'expired'; return true; }
if (e.kind === 'text' && e.me && e.msgId && deliveredMsgIds.has(e.msgId) && e.status !== 'delivered') { e.status = 'delivered'; return true; }
return false;
};
h.forEach(e => { if (!e.localId) { e.localId = entryId(); dirty = true; } if (reconcile(e)) dirty = true; });
if (dirty) histUpdate(uid, (history) => { history.forEach(reconcile); }).catch(() => {});
const snaps = (inboxByUser[uid] || []).filter(s => isAfterClear(uid, new Date(s.created_at).getTime())).map(s => ({ snap: s, at: new Date(s.created_at).getTime() }));
const items = [...h.map(e => ({ entry: e, at: e.at })), ...snaps].sort((a, b) => a.at - b.at);
body.innerHTML = '';
if (!items.length) body.innerHTML = `<div class="threadhint">Say hi 👋 — messages are end-to-end encrypted.</div>`;
for (const it of items) {
if (it.snap) body.appendChild(snapCard(it.snap));
else {
const e = it.entry;
if (e.kind === 'text') {
const sharedGif = gifFromPayload(e.text, e.me, e.at), sharedClip = clipFromPayload(e.text, e.me, e.at);
body.appendChild(sharedGif ? messageCard(uid, e, gifBubble(sharedGif)) : (sharedClip ? messageCard(uid, e, clipBubble(sharedClip)) : textBubble(uid, e)));
}
else if (e.kind === 'story-reply') body.appendChild(messageCard(uid, e, storyReplyBubble(e)));
else if (e.kind === 'clip') body.appendChild(messageCard(uid, e, clipBubble(e)));
else if (e.kind === 'gif') body.appendChild(messageCard(uid, e, gifBubble(e)));
else if (e.kind === 'snap') body.appendChild(el(snapReceipt(e)));
else if (e.kind === 'media') body.appendChild(messageCard(uid, e, mediaBubble(e, e.me ? 'me' : 'them')));
}
}
// one Delivered/Sent receipt under the most recent message, only if it's one you sent
const last = items[items.length - 1];
if (last && !last.snap && last.entry?.me && last.entry.kind === 'text' && last.entry.msgId) {
body.appendChild(el(textReceipt(deliveredMsgIds.has(last.entry.msgId) || last.entry.status === 'delivered')));
}
body.onclick = (event) => { if (!event.target.closest('.messagemenu, .reactiontray, .messagemore')) closeMessageMenus(); };
body.scrollTop = preserveScroll ? Math.min(scrollTop, body.scrollHeight) : body.scrollHeight;
};
const snapCard = (s) => {
const kind = snapKind(s), label = kind === 'video' ? 'Video Snap' : 'Photo Snap';
const action = Number(s.timer) > 0 ? 'Tap to view' : 'Tap to open';
const card = el(`<button class="snapcard ${kind} them"><span class="sq">${kind === 'video' ? '▶' : '●'}</span><span class="snaplabel">${action} ${label}</span><span class="sqt">${ago(s.created_at)}</span><span class="snapload" hidden><i></i><b>Connecting…</b></span></button>`);
card.onclick = () => openSnap(s, card);
return card;
};
const updateLocalEntry = async (uid, localId, update) => {
await histUpdate(uid, (history) => { const entry = history.find(x => x.localId === localId); if (entry) update(entry, history); });
if (openUid === uid) renderThreadBody(uid, true);
};
const messageCard = (uid, e, content) => {
if (e.saved) content.classList.add('saved');
if (e.reaction) content.appendChild(el(`<span class="localreaction">${esc(e.reaction)}</span>`));
const card = el(`<div class="messagewrap ${e.me ? 'me' : 'them'}" data-local-id="${esc(e.localId)}"><div class="messagecontent"></div><button class="messagemore" aria-label="Message options" title="Message options">${icon('more', 18)}</button><div class="messagemenu" hidden><button data-action="open-reactions" aria-label="React" title="React">${icon('smilePlus', 20)}</button><button data-action="save" aria-label="${e.saved ? 'Unsave message' : 'Save message'}" title="${e.saved ? 'Unsave' : 'Save'}">${icon('bookmark', 19)}</button><button data-action="reply" aria-label="Reply" title="Reply">${icon('reply', 19)}</button><button data-action="delete" aria-label="Delete from this device" title="Delete from this device">${icon('trash', 19)}</button></div><div class="reactiontray" aria-label="Choose a reaction" hidden><button data-action="reaction" data-reaction="😂" aria-label="React with laughing face">😂</button><button data-action="reaction" data-reaction="❤️" aria-label="React with heart">❤️</button><button data-action="reaction" data-reaction="👍" aria-label="React with thumbs up">👍</button><button data-action="reaction" data-reaction="😮" aria-label="React with surprised face">😮</button><button data-action="reaction" data-reaction="😢" aria-label="React with crying face">😢</button><button data-action="reaction" data-reaction="🔥" aria-label="React with fire">🔥</button></div></div>`);
card.querySelector('.messagecontent').appendChild(content);
const menu = card.querySelector('.messagemenu');
const tray = card.querySelector('.reactiontray');
card.querySelector('.messagemore').onclick = (event) => { event.stopPropagation(); const opening = menu.hidden; closeMessageMenus(); menu.hidden = !opening; tray.hidden = true; };
menu.onclick = async (event) => {
const action = event.target.closest?.('[data-action]')?.dataset.action; if (!action) return;
event.stopPropagation();
if (action === 'open-reactions') { menu.hidden = true; tray.hidden = false; return; }
menu.hidden = true; tray.hidden = true;
if (action === 'reaction') {
const reaction = event.target.closest('[data-reaction]').dataset.reaction;
await updateLocalEntry(uid, e.localId, entry => { entry.reaction = reaction; });
if (e.msgId) await sendReaction(uid, e.msgId, reaction);
return;
}
if (action === 'reply') { setReplyDraft(e); return; }
if (action === 'save') await updateLocalEntry(uid, e.localId, entry => { entry.saved = !entry.saved; });
if (action === 'delete') await updateLocalEntry(uid, e.localId, (entry, history) => { history.splice(history.indexOf(entry), 1); });
};
tray.onclick = menu.onclick;
return card;
};
const chatTime = (at) => new Date(Number(at) || Date.now()).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
const textBubble = (uid, e) => {
const reply = e.replyTo ? `<div class="replyquote">${esc(e.replyTo)}</div>` : '';
const who = e.me ? 'Me' : openUsername;
const card = messageCard(uid, e, el(`<article class="b chattext ${e.me ? 'me' : 'them'}"><div class="chatmeta"><span class="chatsender">${esc(who)}</span><time class="chattime">${chatTime(e.at)}</time></div><div class="chatbody">${reply}${locationLinks(e.text, esc)}</div></article>`));
card.classList.add('textcard');
return card;
};
const appendBubble = (text, cls) => { const body = $('#tbody'); if (!body) return; const hint = $('.threadhint', body); if (hint) hint.remove(); body.appendChild(el(`<div class="b ${cls}">${esc(text)}</div>`)); body.scrollTop = body.scrollHeight; };
const storyReplyBubble = (e) => {
const w = Math.max(1, Math.min(4096, Number(e.storyW) || 4)), h = Math.max(1, Math.min(4096, Number(e.storyH) || 3));
const preview = e.preview ? `<div class="storyreplypreview" style="aspect-ratio:${w} / ${h}"><img src="${safeMediaUrl(e.preview)}" alt="Story preview"></div>` : '';
return el(`<div class="b ${e.me ? 'me' : 'them'} storyreplymsg"><div class="storyreplylabel">↩ Reply to Story</div>${preview}<div class="storyreplytext">${esc(e.text || 'Story reply')}</div></div>`);
};
const clipBubble = (e) => { const name = decodeTitle(e.name); return el(`<div class="b ${e.me ? 'me' : 'them'} clipbubble"><div class="storyreplylabel">YouTube Clip</div><iframe class="clipembed" title="${esc(name)}" src="https://www.youtube-nocookie.com/embed/${e.videoId}?autoplay=0&rel=0&playsinline=1" allow="autoplay; fullscreen; picture-in-picture"></iframe><div class="storyreplytext">${esc(name)}</div>${e.caption ? `<div class="clipcaption">${esc(e.caption)}</div>` : ''}</div>`); };
const gifBubble = (e) => el(`<div class="b ${e.me ? 'me' : 'them'} gifbubble ${e.type}"><img src="${esc(e.url)}" alt="${esc(e.title || e.type)}" loading="lazy" referrerpolicy="no-referrer"><span>${e.type === 'sticker' ? 'Sticker · GIPHY' : 'GIF · GIPHY'}</span></div>`);
const appendEntry = () => { if (openUid) renderThreadBody(openUid); };
// ---- send an async encrypted text ----
export const sendText = async (uid, username, text, localEntry = null, options = {}) => {
const keepLocalHistory = options.keepLocalHistory !== false;
const countTowardStreak = options.countTowardStreak !== false;
text = text.slice(0, MSG_MAX); // hard size cap (backstop to the input maxlength)
const msgId = localEntry?.msgId || entryId(); // so the row's realtime DELETE = "delivered" receipt
const entry = localEntry || { me: true, kind: 'text', text, at: Date.now(), msgId, status: 'sent', localId: entryId() };
// Special entries (GIFs, Clips, Story replies) also need this ID so either person
// can react to the same item after it arrives.
entry.msgId ||= msgId;
if (keepLocalHistory) {
await histPush(uid, entry);
if (openUid === uid) appendEntry(entry);
if (convBox) renderConvs(convBox, uid);
}
const devices = await deviceKeysOf(uid);
if (!devices.length) {
// Retry the legacy key directly rather than treating an unavailable device
// registry as a new-account setup failure. This keeps every existing friend
// reachable during the multi-device rollout.
try {
const { data: profile } = await db.profileById(uid);
const legacyKey = profile?.pubkey ? JSON.parse(profile.pubkey) : null;
if (legacyKey) {
const enc = await encryptText(legacyKey, text);
const { error } = await db.sendMessage({ id: msgId, sender_id: state.me.id, recipient_id: uid, iv: enc.iv, eph_pub: enc.eph_pub, body: enc.body });
if (!error) { if (countTowardStreak) db.bumpStreak(uid).then(() => {}, () => {}); return true; }
console.error('[mayfly] legacy message retry', error);
}
} catch (e) { console.error('[mayfly] legacy message retry', e); }
if (openUid === uid) appendBubble('(can’t encrypt — they haven’t opened Mayfly yet)', 'sys'); else toast('They have not finished setting up Mayfly.');
return false;
}
let error = null;
try {
const rows = await Promise.all(devices.map(async (device) => {
const enc = await encryptText(device.pubkey, text);
return { id: entryId(), message_id: msgId, sender_id: state.me.id, recipient_id: uid, recipient_device_id: device.id, iv: enc.iv, eph_pub: enc.eph_pub, body: enc.body };
}));
({ error } = await db.sendMessages(rows));
} catch (e) {
console.error('[mayfly] multi-device message send', e);
error = e;
}
// Never let a device-fan-out problem strand normal chat. Older Mayfly clients
// understand this account-key envelope, and it gives the sender a working
// delivery path while a recipient's device registry is being refreshed.
if (error) {
try {
const { data: profile } = await db.profileById(uid);
const legacyKey = profile?.pubkey ? JSON.parse(profile.pubkey) : null;
if (legacyKey) {
const enc = await encryptText(legacyKey, text);
({ error } = await db.sendMessage({ id: msgId, sender_id: state.me.id, recipient_id: uid, iv: enc.iv, eph_pub: enc.eph_pub, body: enc.body }));
}
} catch (e) { console.error('[mayfly] legacy message fallback', e); }
}
if (error) { if (openUid === uid) appendBubble('(failed to send)', 'sys'); else toast('Could not send that reply.'); return false; }
if (countTowardStreak) db.bumpStreak(uid).then(() => {}, () => {});
return true;
};
const sendReaction = (uid, targetId, reaction) => sendText(
uid,
'your friend',
JSON.stringify({ t: 'chat-reaction', targetId, reaction }),
null,
{ keepLocalHistory: false, countTowardStreak: false },
);
export const sendStoryReply = async (uid, username, text, story) => {
const storyW = Number(story.w) || 0, storyH = Number(story.h) || 0;
const payload = JSON.stringify({ t: 'story-reply', storyId: story.id, text, w: storyW, h: storyH });
return sendText(uid, username, payload, { me: true, kind: 'story-reply', text, storyId: story.id, preview: story.preview, storyW, storyH, at: Date.now() });
};
export const sendClipShare = (uid, username, clip) => sendText(uid, username, JSON.stringify({ t: 'clip-share', provider: 'youtube', name: clip.name, videoId: clip.videoId, caption: String(clip.caption || '').slice(0, 240) }), { me: true, kind: 'clip', name: clip.name, videoId: clip.videoId, caption: String(clip.caption || '').slice(0, 240), at: Date.now() });
const sendGifShare = (uid, username, gif) => sendText(uid, username, JSON.stringify({ t: 'gif-share', provider: 'giphy', type: gif.type, url: gif.url, title: gif.title }), { me: true, kind: 'gif', type: gif.type, url: gif.url, title: gif.title, at: Date.now() });
const openGifPicker = (uid, username) => {
document.querySelector('.gifpicker')?.remove();
let type = 'gif', timer = null;
const sheet = el(`<div class="gifpicker"><div class="gifpickercard"><div class="gifpickerhead"><b>GIFs & Stickers</b><button class="gifclose" aria-label="Close">×</button></div><div class="giftypes"><button class="on" data-type="gif">GIFs</button><button data-type="sticker">Stickers</button></div><input class="recipsearch" id="gifsearch" type="search" placeholder="Search GIFs" autocomplete="off"><div class="gifresults"><div class="spin">Loading…</div></div><div class="gifcredit">Powered by GIPHY</div></div></div>`);
document.body.appendChild(sheet);
const close = () => sheet.remove(), input = sheet.querySelector('#gifsearch'), results = sheet.querySelector('.gifresults');
sheet.querySelector('.gifclose').onclick = close; sheet.onclick = event => { if (event.target === sheet) close(); };
const load = async () => {
results.innerHTML = '<div class="spin">Loading…</div>';
const { data, error } = await sb.functions.invoke('giphy', { body: { query: input.value.trim(), type: type === 'sticker' ? 'stickers' : 'gifs' } });
if (error || data?.error || !data?.items?.length) { results.innerHTML = '<div class="empty">No GIFs found. Try another search.</div>'; return; }
results.innerHTML = '';
data.items.forEach(gif => { const button = el(`<button class="gifresult" title="${esc(gif.title)}"><img src="${esc(gif.preview)}" alt="${esc(gif.title)}" loading="lazy"></button>`); button.onclick = async () => { button.disabled = true; await sendGifShare(uid, username, gif); close(); }; results.appendChild(button); });
};
sheet.querySelector('.giftypes').onclick = event => { const next = event.target.dataset.type; if (!next) return; type = next; sheet.querySelectorAll('.giftypes button').forEach(button => button.classList.toggle('on', button.dataset.type === type)); input.placeholder = type === 'sticker' ? 'Search stickers' : 'Search GIFs'; load(); };
input.oninput = () => { clearTimeout(timer); timer = setTimeout(load, 250); };
load();
};
// ===================== snap opening =====================
const relayDownload = async (path, onProgress) => {
// A signed URL lets Fetch expose the response stream, unlike storage.download(),
// so large encrypted videos can report bytes as they arrive.
const { data, error } = await sb.storage.from(SNAP_BUCKET).createSignedUrl(path, 120);
if (error || !data?.signedUrl) throw error || new Error('Could not create download URL');
const response = await fetch(data.signedUrl, { cache: 'no-store' });
if (!response.ok) throw new Error(`Download failed (${response.status})`);
const total = Math.max(0, Number(response.headers.get('content-length')) || 0);
if (!response.body?.getReader) {
const blob = await response.blob();
onProgress?.(blob.size, total || blob.size);
return blob;
}
const reader = response.body.getReader(), chunks = [];
let received = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value); received += value.byteLength;
onProgress?.(received, total);
}
return new Blob(chunks, { type: 'application/octet-stream' });
};
const relaySignedUrl = async path => {
const { data, error } = await sb.storage.from(SNAP_BUCKET).createSignedUrl(path, 3600);
if (error || !data?.signedUrl) throw error || new Error('Could not create download URL');
return data.signedUrl;
};
const relayTransferJobs = new Map();
const ingestRelayTransfer = async (row) => {
try {
// A failed completion request may leave an already saved envelope queued.
const existing = (await histGet(row.sender_id)).some(entry => entry.transferId === row.id);
if (!existing) {
const { data: relay, error } = await db.relayPayload(row.relay_id);
if (error || !relay) throw error || new Error('Relay payload is unavailable');
let media;
if (relay.encryption_format === 'aes-gcm-chunks-v1') {
const key = await unwrapSharedRelayKey(state.priv, row.eph_pub, row.iv, row.wrapped_key);
media = await downloadChunkedRelay({
url: await relaySignedUrl(row.relay_id),
plainSize: Number(relay.plaintext_size),
chunkSize: Number(relay.chunk_size),
mime: row.mime || relay.mime || 'application/octet-stream',
decryptChunk: (index, encrypted) => decryptSharedRelayChunk(key, relay.content_iv, index, encrypted),
});
} else {
const blob = await relayDownload(row.relay_id);
const plain = await decryptSharedRelay(state.priv, row.eph_pub, row.iv, row.wrapped_key, relay.content_iv, await blob.arrayBuffer());
media = new Blob([plain], { type: row.mime || relay.mime || 'application/octet-stream' });
}
const m = { kind: 'media', me: false, name: row.name, mime: media.type, mediaKind: row.media_kind, data: media, at: new Date(row.created_at).getTime(), relay: true, transferId: row.id };
await histUpdate(row.sender_id, history => {
if (isAfterClear(row.sender_id, m.at) && !history.some(entry => entry.transferId === row.id)) {
history.push(m); history.sort((a,b) => a.at - b.at);
if (history.length > 300) history.splice(0, history.length - 300);
}
});
}
// Display the saved clip even if the delivery acknowledgement fails.
if (openUid === row.sender_id) await renderThreadBody(row.sender_id);
else if (!existing && isAfterClear(row.sender_id, new Date(row.created_at).getTime())) unreadMsg.add(row.sender_id);
if (convBox) await renderConvs(convBox, openUid);
onChange();
const { data: completed, error: completeError } = await db.completeTransfer(row.id);
if (completeError || !completed) throw completeError || new Error('transfer completion was not accepted');
return true;
} catch (error) { console.error('[mayfly] relay transfer receive failed', error); return false; }
};
export const receiveRelayTransfer = row => {
if (!row?.id || row.recipient_id !== state.me?.id || row.recipient_device_id && row.recipient_device_id !== state.deviceId) return Promise.resolve(false);
if (relayTransferJobs.has(row.id)) return relayTransferJobs.get(row.id);
const job = ingestRelayTransfer(row).finally(() => relayTransferJobs.delete(row.id));
relayTransferJobs.set(row.id, job); return job;
};
let transferSyncJob = null;
export const syncTransfers = () => {
if (transferSyncJob) return transferSyncJob;
transferSyncJob = (async () => {
const { data, error } = await db.incomingTransfers();
if (error) throw error;
for (let offset = 0; offset < (data || []).length; offset += 5) await Promise.all(data.slice(offset, offset + 5).map(receiveRelayTransfer));
})().finally(() => { transferSyncJob = null; });
return transferSyncJob;
};
const formatSnapProgress = ({ phase = 'Loading', received = 0, total = 0 }) => {
if (total > 0) return `${Math.min(100, Math.round(received / total * 100))}%`;
if (received > 0) return `${(received / 1024 / 1024).toFixed(received >= 1024 * 1024 ? 1 : 2)} MB`;
return `${phase}…`;
};
const openSnap = async (s, card) => {
const report = (progress) => {
if (!card) return;
const load = $('.snapload', card), fill = $('.snapload i', card), text = $('.snapload b', card);
if (load) load.hidden = false;
if (text) text.textContent = formatSnapProgress(progress);
if (fill) fill.style.setProperty('--snap-progress', progress.total > 0 ? Math.min(1, progress.received / progress.total) : 0);
};
const resetProgress = () => {
card?.classList.remove('opening');
const load = card && $('.snapload', card); if (load) load.hidden = true;
if (card) card.disabled = false;
};
if (card) { card.disabled = true; card.classList.add('opening'); report({ phase: 'Loading' }); }
let full = null;
try {
if (s.delivery?.startsWith('live')) full = await fetchSnap(s.id, s.sender_id, s.sender_device_id, report);
else if (s.relay_id) {
const { data: relay } = await db.relayPayload(s.relay_id);
if (relay) {
const blob = await relayDownload(s.relay_id, (received, total) => report({ phase: 'Downloading', received, total }));
report({ phase: 'Decrypting' });
const pt = await decryptSharedRelay(state.priv, s.eph_pub, s.iv, s.wrapped_key, relay.content_iv, await blob.arrayBuffer());
full = URL.createObjectURL(new Blob([pt], { type: relay.mime || snapMime(s) }));
}
} else {
const blob = await relayDownload(s.id, (received, total) => report({ phase: 'Downloading', received, total }));
report({ phase: 'Decrypting' });
const pt = await decryptWith(state.priv, s.eph_pub, s.iv, await blob.arrayBuffer());
full = URL.createObjectURL(new Blob([pt], { type: snapMime(s) }));
}
} catch (e) { console.error('[mayfly] open snap', e); }
// Failing to fetch is not the same as being consumed, so the Snap stays put
// and the card goes back to being tappable. Burning here destroyed Snaps that
// were only briefly unreachable -- a live one whose sender had closed the tab
// is readable the moment they reopen it, and a relay one can fail on nothing
// worse than a dropped request. Either way the recipient got no second try,
// and the Snap was gone for good well before its 24h expiry. Expiry is swept
// separately; this path should leave the row alone.
if (!full) {
toast(s.delivery?.startsWith('live')
? 'Sender is offline — try again when they are back.'
: 'Could not load this Snap — try again.');
resetProgress();
return;
}
const claimForViewing = async () => {
if (!s.logical_id) return true;
const { data: claimed, error } = await db.claimSnap(s.id);
if (error || !claimed) { toast(error ? 'Could not open this Snap. Please try again.' : 'This Snap was opened on another device.'); return false; }
return true;
};
const video = snapMime(s).startsWith('video/');
// The default (timer 0) saves the opened media into this device's chat history,
// then consumes the encrypted/live delivery. It will render inline like any file.
if (!(Number(s.timer) > 0)) {
try {
const blob = await fetch(full).then(r => r.blob());
if (video) { const decoded = await loadVideoBlob(blob); decoded.release(); }
const m = {
kind: 'media', me: false, name: video ? 'Video Snap' : 'Photo Snap',
mime: snapMime(s), mediaKind: video ? 'video' : 'image',
data: blob, caption: s.caption || '', snap: true, snapId: s.id,
at: new Date(s.created_at).getTime(),
};
if (!await claimForViewing()) { if (full.startsWith('blob:')) URL.revokeObjectURL(full); resetProgress(); return; }
await histPush(s.sender_id, m);
if (full.startsWith('blob:')) URL.revokeObjectURL(full);
await burnSnap(s, card);
if (openUid === s.sender_id) {
await renderThreadBody(s.sender_id);
autoPlaySnapVideo($(`video.chatmedia[data-snap="${s.id}"]`, $('#tbody')));
}
} catch (e) {
console.error('[mayfly] save inline snap', e);
if (full.startsWith('blob:')) URL.revokeObjectURL(full);
toast(e.message || 'Could not save this Snap into the chat.');
resetProgress();
}
return;
}
const u = s.sender || {};
const media = video ? `<video src="${safeMediaUrl(full)}" autoplay muted controls playsinline></video>` : `<img src="${safeMediaUrl(full)}" alt="snap">`;
const ov = el(`<div class="player">${media}${s.caption ? `<div class="pcap">${esc(s.caption)}</div>` : ''}<div class="pname">${esc(u.username || '')}</div><div class="pbar"><i></i></div></div>`);
document.body.appendChild(ov);
let done = false, viewing = false, t = null, loadTimer = null;
const release = () => { if (full.startsWith('blob:')) URL.revokeObjectURL(full); };
const fail = () => {
if (done) return;
done = true;
clearTimeout(t); clearTimeout(loadTimer);
ov.remove(); release();
toast('This Snap could not start — try again.');
resetProgress();
};
const finish = async () => {
if (done || !viewing) return;
done = true;
clearTimeout(t); clearTimeout(loadTimer);
ov.remove(); release();
await burnSnap(s, card);
};
let claiming = false;
const startViewing = async () => {
if (done || viewing || claiming) return;
claiming = true;
if (!await claimForViewing()) { fail(); return; }
if (done) return;
viewing = true;
clearTimeout(loadTimer);
requestAnimationFrame(() => { const bar = $('.pbar i', ov); if (bar) { bar.style.transitionDuration = s.timer + 's'; bar.classList.add('run'); } });
t = setTimeout(finish, s.timer * 1000);
if (!video) return;
const v = $('video', ov);
v.play().then(() => {
v.muted = false;
return v.play();
}).catch(() => {
// Preserve automatic visual playback if the browser blocks autoplay sound.
v.muted = true;
v.play().catch(() => {});
});
};
// Do not burn a timed Snap while its media is still loading. This matters most
// for video: a slow decode or a transient P2P hiccup should leave it available
// to retry rather than consume it unseen.
loadTimer = setTimeout(fail, 30000);
if (video) {
const v = $('video', ov);
v.preload = 'auto';
v.onended = finish;
v.onplaying = startViewing;
v.onerror = fail;
v.onclick = (e) => e.stopPropagation();
v.play().catch(() => { /* Native controls let the user start playback. */ });
} else {
const im = $('img', ov);
im.onload = startViewing;
im.onerror = fail;
if (im.complete && im.naturalWidth) startViewing();
}
ov.onclick = () => { if (viewing) finish(); };
};
// Only ever called once a Snap has actually been seen, so it always marks it
// opened. The old wasOpened=false caller burned Snaps that failed to load.
const burnSnap = async (s, card) => {
await db.markSnapOpened(s.id);
// The last recipient/device to claim a shared relay may remove its encrypted
// payload. Policies keep fan-out media intact while anyone still has it unopened.
if (s.relay_id) {
const removed = await sb.storage.from(SNAP_BUCKET).remove([s.relay_id]);
if (!removed.error) await db.delRelayPayloads([s.relay_id]);
}
await db.delSnap(s.id);
// Legacy single-recipient relay objects predate shared relay payload rows.
if (s.delivery?.startsWith('relay') && !s.relay_id) sb.storage.from(SNAP_BUCKET).remove([s.id]);
inboxByUser[s.sender_id] = (inboxByUser[s.sender_id] || []).filter(x => x.id !== s.id);
card?.remove();
if (convBox) renderConvs(convBox, openUid);
onChange();
};
// ===================== live P2P: media, voice, typing =====================
const TRANSFER_RELAY_LIMIT = 10;
const TRANSFER_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const autoPlaySnapVideo = (video) => {
if (!video) return;
const play = () => video.play().catch(() => {
// Browsers may require a direct user gesture for sound. Keep the Snap moving
// even then, while preserving sound whenever the policy permits it.
video.muted = true;
video.play().catch(() => {});
});
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) play();
else video.addEventListener('canplay', play, { once: true });
};
// A chat bubble is inserted before an image/video knows its final dimensions.
// Scroll again after metadata arrives, otherwise tall mobile media expands below
// the visible end of the thread and makes the recipient scroll manually.
const pinThreadAfterMediaLoads = (media) => {
if (!media) return;
const pin = () => requestAnimationFrame(() => requestAnimationFrame(() => {
const body = media.closest('.tbody');
if (body) body.scrollTop = body.scrollHeight;
}));
media.addEventListener('load', pin, { once: true });
media.addEventListener('loadedmetadata', pin, { once: true });
media.addEventListener('canplay', pin, { once: true });
if (media instanceof HTMLImageElement && media.complete) pin();
};
const mediaObjectUrls = new WeakMap();
const mediaSourceUrl = data => {
if (!(data instanceof Blob)) return safeMediaUrl(data);
if (!mediaObjectUrls.has(data)) mediaObjectUrls.set(data, URL.createObjectURL(data));
return mediaObjectUrls.get(data);
};
const openMediaViewer = (m, inlinePlayer = null) => {
const video = m.mediaKind === 'video';
const reusePlayer = video && inlinePlayer;
const tag = reusePlayer ? '' : video ? `<video src="${mediaSourceUrl(m.data)}" controls autoplay playsinline></video>` : `<img src="${mediaSourceUrl(m.data)}" alt="${esc(m.name || 'image')}">`;
const ov = el(`<div class="media-viewer" role="dialog" aria-modal="true"><button class="media-close" aria-label="Close media">✕</button>${tag}</div>`);
const marker = reusePlayer ? document.createComment('inline video') : null;
if (reusePlayer) { inlinePlayer.before(marker); ov.appendChild(inlinePlayer); }
const close = () => {
ov.remove(); window.removeEventListener('keydown', onKey);
if (marker?.parentNode) marker.replaceWith(inlinePlayer);
};
const onKey = (e) => { if (e.key === 'Escape') close(); };
$('.media-close', ov).onclick = close;
ov.onclick = (e) => { if (e.target === ov) close(); };
document.body.appendChild(ov);
window.addEventListener('keydown', onKey);
if (video) autoPlaySnapVideo(reusePlayer ? inlinePlayer : $('video', ov));
};
const mediaBubble = (m, cls) => {
const url = mediaSourceUrl(m.data);
const inner = m.mediaKind === 'image' ? `<img class="chatmedia" src="${url}" alt="">`
: m.mediaKind === 'video' ? `<video class="chatmedia" data-snap="${esc(m.snapId || '')}" src="${url}" controls playsinline></video>`
: m.mediaKind === 'audio' ? `<audio src="${url}" controls></audio>`
: `<a class="chatfile" href="${url}" download="${esc(m.name || 'file')}">📎 ${esc(m.name || 'file')}</a>`;
const bubble = el(`<div class="b ${cls} media">${inner}${m.caption ? `<div class="snapcaption">${esc(m.caption)}</div>` : ''}</div>`);
const media = $('.chatmedia', bubble);
if (media) {
media.classList.add('expandable');
media.title = 'Open larger';
media.onclick = () => { if (!media.closest('.media-viewer')) openMediaViewer(m, media); };
pinThreadAfterMediaLoads(media);
}
return bubble;
};
const relayFile = async (uid, file, kind, meta) => {
const [{ count }, { data: devices }] = await Promise.all([db.pendingTransfersTo(uid), db.devicesForUser(uid)]);
if ((count || 0) >= TRANSFER_RELAY_LIMIT) { toast('Voice/file queue is full. Try again after pending items are received.'); return false; }