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 | /**
* #/c/<id> — full-screen conversation view (M3): header (name; fingerprint →
* contact detail for pairs, member count → member sheet for groups; subtle
* "online acum" presence dot when a same-room device is live on the mesh),
* message bubbles (text / image / voice; mine right, theirs left, author
* labels in groups), day separators, and a keyboard-safe composer with photo
* attach + voice recording.
*
* Media: image bubbles render the encrypted THUMB blob inline (aspect box
* reserved from the op's w/h so the scroll never jumps) and open a
* full-screen viewer on tap, which upgrades progressively from thumb to the
* display blob. Voice bubbles are play/pause + progress + duration — no
* waveform theater. Blob bytes come from room.handle.log.getImage (device
* blockstore, else bitswap from live members) and become object URLs cached
* per mount, revoked on unmount. Received image blobs are decode-bounded
* BEFORE any URL reaches an <img> (decodeWithinBounds — the op's w/h only
* reserve the layout box; the actual decode is what must be sane).
*
* Keyboard safety (iOS): the app frame is a 100dvh flex column, but the iOS
* keyboard shrinks only the VISUAL viewport — so while this view is mounted
* the frame's height is pinned to visualViewport.height (restored on
* unmount) and the window is kept scrolled to 0. The composer therefore
* stays glued above the keyboard inside the frame.
*
* Everything renders through h()/text nodes — message bodies are never
* injected as HTML, and media srcs are only ever blob: URLs of decrypted
* bytes (XSS-safe by construction).
*/
import { MSG_MAX, type ChatMessage, type ChatRoom } from "../../core/chatlog";
import type { ChatService } from "../../core/conversations";
import { decodeWithinBounds, encodeImage, MediaDecodeError, VoiceRecorder, VOICE_MAX_SEC, type EncodedImage } from "../../core/media";
import { Session } from "../../core/session";
import { matchByConvId, matchPeerOf } from "../../public/match-service";
import { dateLocales, t } from "../i18n";
import { h, sheet, svgIcon, toast } from "../ui-kit";
import { fpSpan, openContactSheet } from "./contacts-tab";
import { presetTab } from "./lobby";
import { openMatchAddContactSheet, openMatchCardReviewSheet } from "./matches";
const shortPub = (pub: string): string => `${pub.slice(0, 8)}…`;
const PRESENCE_POLL_MS = 3000;
const BLOB_FETCH_TIMEOUT_MS = 60_000;
// decode-bomb guard: decodeWithinBounds (core/media) — the op's w/h only size
// the LAYOUT box; the actual decode is what must be sane.
async function waitFor<T>(get: () => T | null, timeoutMs: number): Promise<T | null> {
const until = Date.now() + timeoutMs;
for (;;) {
const v = get();
if (v) return v;
if (Date.now() > until) return null;
await new Promise((r) => setTimeout(r, 250));
}
}
/** Run `cleanup` the moment `el` leaves the document. */
function onDisconnect(el: HTMLElement, cleanup: () => void): void {
const obs = new MutationObserver(() => {
if (!el.isConnected) {
obs.disconnect();
cleanup();
}
});
obs.observe(document.body, { childList: true, subtree: true });
}
function dayLabel(ts: number): string {
const d = new Date(ts);
const now = new Date();
if (d.toDateString() === now.toDateString()) return t("chatToday");
const yesterday = new Date(now.getTime() - 86_400_000);
if (d.toDateString() === yesterday.toDateString()) return t("chatYesterday");
return d.toLocaleDateString(dateLocales(), {
day: "numeric",
month: "long",
year: d.getFullYear() === now.getFullYear() ? undefined : "numeric",
});
}
const timeOf = (ts: number): string =>
new Date(ts).toLocaleTimeString(dateLocales(), { hour: "2-digit", minute: "2-digit" });
const fmtDur = (sec: number): string => `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, "0")}`;
/** Honest local-only delete: hides the message on THIS device. */
function openHideSheet(room: ChatRoom, msg: ChatMessage): void {
const hide = h("button", { type: "button", class: "btn danger", "data-testid": "msg-hide" }, t("chatHide"));
const cancel = h("button", { type: "button", class: "btn ghost" }, t("cancel"));
const body = h(
"div",
{},
h("h2", { class: "sheet-title" }, t("chatHideTitle")),
h("p", { class: "muted" }, t("chatHideBody")),
h("div", { class: "btn-row" }, cancel, hide),
);
const s = sheet(body);
cancel.addEventListener("click", () => s.close());
hide.addEventListener("click", () => {
room.hideLocally(msg.id);
s.close();
});
}
function openGroupInfoSheet(chat: ChatService, convId: string): void {
const state = Session.selfroom?.state;
const reg = state?.chats.get(convId);
if (!state || !reg) return;
const rows = reg.members.map((pub) => {
const name = pub === chat.myPub ? t("chatYou") : (state.contacts.get(pub)?.name ?? shortPub(pub));
return h("div", { class: "member-row", "data-testid": "groupinfo-member", "data-pub": pub }, fpSpan(pub, "fp-small"), h("span", {}, name));
});
sheet(
h(
"div",
{ "data-testid": "groupinfo" },
h("h2", { class: "sheet-title" }, reg.name),
h("p", { class: "muted" }, t("chatMembers", { n: reg.members.length })),
h("div", { class: "member-list" }, ...rows),
// v1 honesty: static membership; eviction = re-create without the member
h("p", { class: "muted small-note spaced-top" }, t("chatGroupEvictNote")),
),
);
}
/** Preview-before-send: shows the RE-ENCODED image (what will actually go out). */
function confirmImageSend(enc: EncodedImage): Promise<boolean> {
const url = URL.createObjectURL(new Blob([enc.display as BlobPart], { type: enc.mime }));
const img = h("img", { class: "img-preview", alt: "", src: url, "data-testid": "img-preview" });
const send = h("button", { type: "button", class: "btn", "data-testid": "img-send" }, t("chatSend"));
const cancel = h("button", { type: "button", class: "btn ghost" }, t("cancel"));
const s = sheet(
h(
"div",
{ "data-testid": "img-preview-sheet" },
h("h2", { class: "sheet-title" }, t("chatImgPreviewTitle")),
img,
h("div", { class: "btn-row" }, cancel, send),
),
);
return new Promise((resolve) => {
let confirmed = false;
send.addEventListener("click", () => {
confirmed = true;
s.close();
});
cancel.addEventListener("click", () => s.close());
void s.closed.then(() => {
URL.revokeObjectURL(url);
resolve(confirmed); // backdrop dismiss ⇒ false (sheet contract)
});
});
}
export function renderChatView(root: HTMLElement, id: string): void {
root.replaceChildren(
h(
"div",
{ class: "chat-frame", "data-testid": "chat-view" },
h("p", { class: "muted empty-note", "data-testid": "chat-loading" }, t("chatOpening")),
),
);
void run(root, id);
}
async function run(root: HTMLElement, id: string): Promise<void> {
const goBack = (): void => {
presetTab("chats");
location.hash = "#/";
};
const fail = (msg: string): void => {
root.replaceChildren(
h(
"div",
{ class: "placeholder", style: "padding-top: calc(var(--safe-top) + 64px)" },
h("div", { class: "glyph" }, "🕳️"),
h("p", { "data-testid": "chat-missing" }, msg),
h("a", { class: "btn ghost", href: "#/", onclick: () => presetTab("chats") }, t("backHome")),
),
);
};
// the selfroom/chat stack boots in the background — wait honestly
const chat = await waitFor(() => Session.chat, 90_000);
if (!chat || !root.isConnected) {
if (root.isConnected) fail(t("syncNoMailbox"));
return;
}
// a chatreg synced from another device may land moments after boot
const ref = await waitFor(() => chat.resolve(id), 10_000);
if (!root.isConnected) return;
if (!ref) {
fail(t("chatUnknownConv"));
return;
}
const room = await chat.open(id);
if (!root.isConnected) return;
if (!room) {
fail(t("chatUnknownConv"));
return;
}
const state = Session.selfroom!.state;
const isGroup = ref.kind === "group";
const isMatch = ref.kind === "match";
const matchPeer = isMatch ? matchPeerOf(id) : null;
const title = isGroup
? (state.chats.get(id)?.name ?? "?")
: isMatch
? matchByConvId(id)?.name || shortPub(matchPeer ?? "")
: (state.contacts.get(id)?.name ?? shortPub(id));
// ---- decrypted blob loading (object-URL cache for this mount) -------------
const blobUrls = new Map<string, string>(); // cid → object URL
const blobLoads = new Map<string, Promise<string | null>>();
const blobRejected = new Set<string>(); // decode bombs / undecodable — never retried
const loadBlob = (cid: string, mime: string): Promise<string | null> => {
const cached = blobUrls.get(cid);
if (cached) return Promise.resolve(cached);
if (blobRejected.has(cid)) return Promise.resolve(null);
let p = blobLoads.get(cid);
if (!p) {
p = (async () => {
const log = room.handle?.log;
if (!log) return null;
const bytes = await log.getImage(cid, AbortSignal.timeout(BLOB_FETCH_TIMEOUT_MS));
if (!bytes) {
blobLoads.delete(cid); // a later render retries (blocks may arrive)
return null;
}
const blob = new Blob([bytes as BlobPart], { type: mime });
// decode-bomb bound (BOTH thumb and full/viewer come through here):
// rejected cids stay rejected — no retry loop re-decoding an attack blob
if (mime.startsWith("image/") && !(await decodeWithinBounds(blob))) {
blobRejected.add(cid);
return null;
}
const url = URL.createObjectURL(blob);
blobUrls.set(cid, url);
return url;
})();
blobLoads.set(cid, p);
}
return p;
};
// ---- full-screen image viewer ---------------------------------------------
const openViewer = (m: ChatMessage): void => {
if (!m.img) return;
const img = h("img", { class: "viewer-img", alt: "", "data-testid": "viewer-img" });
const close = h(
"button",
{ type: "button", class: "viewer-close", "aria-label": t("closeSheet"), "data-testid": "viewer-close" },
"✕",
);
const overlay = h("div", { class: "img-viewer", "data-testid": "img-viewer" }, img, close);
const dismiss = (): void => overlay.remove();
close.addEventListener("click", dismiss);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) dismiss();
});
document.body.append(overlay);
// progressive: thumb (usually cached) first, swap in the display blob
void loadBlob(m.img.thumb, m.img.mime).then((u) => {
if (u && !img.src) img.src = u;
});
void loadBlob(m.img.full, m.img.mime).then((u) => {
if (u) img.src = u;
else if (!img.src) toast(t("chatImgUnavailable"));
});
};
// ---- voice playback (one shared element; per-bubble binding at render) ----
const players = new Map<string, HTMLAudioElement>(); // msg id → audio
const audioFor = (m: ChatMessage): HTMLAudioElement => {
let a = players.get(m.id);
if (!a) {
a = new Audio();
a.preload = "none";
players.set(m.id, a);
}
return a;
};
// ---- message rendering ----------------------------------------------------
const scroll = h("div", { class: "chat-scroll", "data-testid": "chat-scroll" });
const nameOf = (pub: string): string => state.contacts.get(pub)?.name ?? shortPub(pub);
const textBubble = (m: ChatMessage): HTMLElement =>
h(
"div",
{ class: "bubble", "data-testid": "chat-msg", "data-id": m.id, "data-from": m.from },
h("span", { class: "bubble-body" }, m.body),
h("span", { class: "bubble-time" }, timeOf(m.ts)),
);
// P4 call marker ({t:"call"} op appended by the caller): a system-style line
const callBubble = (m: ChatMessage): HTMLElement => {
const c = m.call!;
const label =
c.kind === "missed" ? t("callMissedLine") : c.dur > 0 ? t("callEndedLineDur", { dur: fmtDur(c.dur) }) : t("callEndedLine");
return h(
"div",
{
class: "bubble call-bubble",
"data-testid": "chat-msg",
"data-id": m.id,
"data-from": m.from,
"data-kind": "call",
"data-call": c.kind,
},
h("span", { class: "call-bubble-glyph", "aria-hidden": "true" }, "📞"),
h("span", { class: "bubble-body" }, label),
h("span", { class: "bubble-time" }, timeOf(m.ts)),
);
};
const imgBubble = (m: ChatMessage): HTMLElement => {
const { thumb, w, h: ih, mime } = m.img!;
const img = h("img", {
class: "chat-img",
alt: "",
width: w,
height: ih,
style: `aspect-ratio: ${w} / ${ih}`,
"data-testid": "msg-img",
"data-cid": m.img!.full,
}) as HTMLImageElement;
void loadBlob(thumb, mime).then((u) => {
if (u) img.src = u;
// refused decode bomb → honest blank box + the unavailable state (the
// aspect box stays reserved; data-rejected makes the refusal testable)
else if (blobRejected.has(thumb)) {
img.dataset.rejected = "1";
img.alt = t("chatImgUnavailable");
}
});
img.addEventListener("click", (e) => {
e.stopPropagation();
openViewer(m);
});
return h(
"div",
{ class: "bubble media-bubble", "data-testid": "chat-msg", "data-id": m.id, "data-from": m.from },
img,
h("span", { class: "bubble-time media-time" }, timeOf(m.ts)),
);
};
const voiceBubble = (m: ChatMessage): HTMLElement => {
const v = m.voice!;
const audio = audioFor(m);
// eager load: small blob, and iOS needs play() inside the tap gesture —
// by tap time the object URL is (almost always) ready
void loadBlob(v.blob, v.mime);
const btn = h(
"button",
{ type: "button", class: "voice-play", "aria-label": t("chatMic"), "data-testid": "voice-play" },
audio.paused ? "▶" : "⏸",
) as HTMLButtonElement;
const bar = h("div", { class: "voice-bar" }, h("div", { class: "voice-bar-fill" })) as HTMLDivElement;
const fill = bar.firstChild as HTMLDivElement;
const label = h("span", { class: "voice-dur", "data-testid": "voice-dur" }, fmtDur(v.dur));
const sync = (): void => {
btn.textContent = audio.paused ? "▶" : "⏸";
const dur = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : v.dur;
fill.style.width = `${Math.min(100, (audio.currentTime / dur) * 100)}%`;
label.textContent = audio.paused && audio.currentTime === 0 ? fmtDur(v.dur) : fmtDur(audio.currentTime);
};
// property handlers: re-binding on re-render replaces the stale closures
audio.ontimeupdate = sync;
audio.onplay = sync;
audio.onpause = sync;
audio.onended = () => {
audio.currentTime = 0;
sync();
};
btn.addEventListener("click", (e) => {
e.stopPropagation();
if (!audio.paused) {
audio.pause();
return;
}
const cached = blobUrls.get(v.blob);
if (cached) {
if (audio.src !== cached) audio.src = cached;
void audio.play().catch(() => toast(t("chatVoiceUnavailable")));
} else {
// not fetched yet — load, then play (may need a second tap on iOS)
void loadBlob(v.blob, v.mime).then((u) => {
if (!u) {
toast(t("chatVoiceUnavailable"));
return;
}
audio.src = u;
void audio.play().catch(() => {});
});
}
});
sync();
return h(
"div",
{ class: "bubble voice-bubble", "data-testid": "chat-msg", "data-id": m.id, "data-from": m.from, "data-kind": "voice" },
btn,
bar,
label,
h("span", { class: "bubble-time" }, timeOf(m.ts)),
);
};
const renderMessages = (): void => {
const msgs = room.visible;
const items: HTMLElement[] = [];
let lastDay = "";
for (const m of msgs) {
const day = new Date(m.ts).toDateString();
if (day !== lastDay) {
lastDay = day;
items.push(h("div", { class: "chat-day", "data-testid": "chat-day" }, dayLabel(m.ts)));
}
const bubble =
m.kind === "img" ? imgBubble(m) : m.kind === "voice" ? voiceBubble(m) : m.kind === "call" ? callBubble(m) : textBubble(m);
bubble.addEventListener("click", () => openHideSheet(room, m));
items.push(
h(
"div",
{ class: `msg-row ${m.mine ? "mine" : "theirs"}` },
h(
"div",
{ class: "msg-col" },
isGroup && !m.mine ? h("div", { class: "msg-author muted" }, nameOf(m.from)) : null,
bubble,
),
),
);
}
if (items.length === 0) items.push(h("p", { class: "muted empty-note", "data-testid": "chat-empty" }, t("chatNoMessages")));
const nearBottom = scroll.scrollTop + scroll.clientHeight >= scroll.scrollHeight - 80;
scroll.replaceChildren(...items);
if (nearBottom || scroll.scrollTop === 0) scroll.scrollTop = scroll.scrollHeight;
};
// ---- header ---------------------------------------------------------------
const back = h(
"button",
{ type: "button", class: "chat-back", "aria-label": t("backHome"), "data-testid": "chat-back", onclick: goBack },
"‹",
);
const presence = h(
"span",
{ class: "presence", "data-testid": "chat-presence", hidden: true },
h("span", { class: "presence-dot", "aria-hidden": "true" }),
t("chatOnlineNow"),
);
const headMain = h(
"button",
{
type: "button",
class: "chat-head-main",
"data-testid": "chat-header",
onclick: () => {
if (isGroup) openGroupInfoSheet(chat, id);
else if (isMatch) {
// matched peer: contact sheet once they ARE a contact, else the
// in-chat card-exchange flow (P3)
const c = matchPeer ? Session.selfroom?.state.contacts.get(matchPeer) : null;
if (c?.status === "active" && matchPeer) openContactSheet(matchPeer, () => {});
else openMatchAddContactSheet(id, room);
} else openContactSheet(id, () => {});
},
},
h("span", { class: "chat-title" }, title),
h(
"span",
{ class: "chat-subrow" },
isGroup
? h("span", { class: "muted chat-sub", "data-testid": "chat-members" }, t("chatMembers", { n: state.chats.get(id)?.members.length ?? 0 }))
: fpSpan(isMatch ? (matchPeer ?? "") : id, "chat-sub chat-fp", "chat-fp"),
isMatch ? h("span", { class: "chip chip-strong", "data-testid": "chat-match-badge" }, `❤️ ${t("mtMatchBadge")}`) : null,
presence,
),
);
// P4 call buttons (pair + match only; group calls are out of scope v1).
// Enabled ONLY when the peer is live on the call topic — calls need
// co-presence, and the disabled tooltip says so honestly.
const calls = Session.calls;
const canCall = !isGroup && calls !== null && calls.supports(id);
const callAudioBtn = canCall
? (h(
"button",
{ type: "button", class: "chat-call-btn", disabled: true, "data-testid": "call-audio" },
svgIcon("M5 4h4l2 5-2.5 1.5a11 11 0 0 0 5 5L15 13l5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2z", "icon"),
) as HTMLButtonElement)
: null;
const callVideoBtn = canCall
? (h(
"button",
{ type: "button", class: "chat-call-btn", disabled: true, "data-testid": "call-video" },
svgIcon("M3 7h12v10H3zM15 10l6-3v10l-6-3", "icon"),
) as HTMLButtonElement)
: null;
// STRICTLY inside click handlers — iOS getUserMedia gesture rule
callAudioBtn?.addEventListener("click", () => void calls?.start(id, false));
callVideoBtn?.addEventListener("click", () => void calls?.start(id, true));
const syncCallBtns = (): void => {
if (!calls || !callAudioBtn || !callVideoBtn) return;
const callable = calls.callableNow(id) && calls.phase === "idle";
for (const [btn, label] of [
[callAudioBtn, t("callAudio")],
[callVideoBtn, t("callVideo")],
] as const) {
btn.disabled = !callable;
btn.title = callable ? label : t("callNeedOnline");
btn.setAttribute("aria-label", label);
}
};
syncCallBtns();
const header = h("header", { class: "chat-header" }, back, headMain, callAudioBtn, callVideoBtn);
// subtle presence: a same-room device is gossiping on this room's topic
const presenceTimer = setInterval(() => {
presence.hidden = (room.handle?.peersOnline() ?? 0) === 0;
syncCallBtns();
}, PRESENCE_POLL_MS);
// ---- composer -------------------------------------------------------------
const input = h("textarea", {
class: "chat-input",
rows: 1,
maxlength: MSG_MAX,
placeholder: t("chatMsgPh"),
enterkeyhint: "send",
autocomplete: "off",
"data-testid": "chat-input",
}) as HTMLTextAreaElement;
const sendBtn = h(
"button",
{ type: "button", class: "btn chat-send", "aria-label": t("chatSend"), "data-testid": "chat-send" },
t("chatSend"),
) as HTMLButtonElement;
const doSend = (): void => {
const text = input.value.trim();
if (!text) return;
input.value = "";
void room.send(text).catch(() => {});
input.focus();
};
sendBtn.addEventListener("click", doSend);
// Enter sends on desktop (fine pointer); on touch the button does — the
// mobile Enter key inserts a newline as users expect
input.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey && window.matchMedia("(pointer: fine)").matches) {
e.preventDefault();
doSend();
}
});
// photo attach: file input → re-encode (EXIF gone) → preview → send.
// multiple files = multiple messages, sequential (v1 simple).
const fileIn = h("input", {
type: "file",
accept: "image/*",
multiple: true,
class: "visually-hidden",
"data-testid": "chat-file",
}) as HTMLInputElement;
const attachBtn = h(
"button",
{ type: "button", class: "composer-icon", "aria-label": t("chatAttach"), "data-testid": "chat-attach" },
svgIcon("M4 7h3l2-2h6l2 2h3v12H4zM12 17a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", "icon"),
) as HTMLButtonElement;
attachBtn.addEventListener("click", () => fileIn.click());
fileIn.addEventListener("change", () => {
const files = [...(fileIn.files ?? [])];
fileIn.value = "";
if (files.length === 0) return;
void (async () => {
for (const file of files) {
let enc: EncodedImage;
try {
enc = await encodeImage(file);
} catch (err) {
toast(err instanceof MediaDecodeError ? t("chatImgFailed") : t("chatImgTooBig"));
continue;
}
if (!(await confirmImageSend(enc))) continue;
await room.sendImage(enc).catch(() => {});
}
})();
});
// voice: tap to start, tap to stop & send; cancel discards; 60 s hard cap
let recorder: VoiceRecorder | null = null;
const recLabel = h("span", { class: "rec-elapsed", "data-testid": "rec-elapsed" }, `0:00 / ${fmtDur(VOICE_MAX_SEC)}`);
const recCancel = h(
"button",
{ type: "button", class: "btn ghost small", "data-testid": "rec-cancel" },
t("cancel"),
) as HTMLButtonElement;
const recStop = h(
"button",
{ type: "button", class: "btn small", "data-testid": "rec-stop" },
t("chatSend"),
) as HTMLButtonElement;
const recBar = h(
"div",
{ class: "rec-bar", "data-testid": "rec-bar", hidden: true },
h("span", { class: "rec-dot", "aria-hidden": "true" }),
h("span", { class: "muted" }, t("chatRecording")),
recLabel,
recCancel,
recStop,
);
const micBtn = h(
"button",
{ type: "button", class: "composer-icon", "aria-label": t("chatMic"), "data-testid": "chat-mic" },
svgIcon("M12 3a3 3 0 0 1 3 3v6a3 3 0 1 1-6 0V6a3 3 0 0 1 3-3zM6 11a6 6 0 0 0 12 0M12 17v4M9 21h6", "icon"),
) as HTMLButtonElement;
const setRecording = (on: boolean): void => {
recBar.hidden = !on;
input.style.display = on ? "none" : "";
sendBtn.style.display = on ? "none" : "";
attachBtn.style.display = on ? "none" : "";
micBtn.style.display = on ? "none" : "";
};
const startRecording = async (): Promise<void> => {
if (recorder) return;
if (!window.MediaRecorder) {
toast(t("chatVoiceUnsupported"));
return;
}
const rec = new VoiceRecorder();
rec.onTick = (sec) => {
recLabel.textContent = `${fmtDur(Math.min(sec, VOICE_MAX_SEC))} / ${fmtDur(VOICE_MAX_SEC)}`;
};
rec.onDone = (clip) => {
recorder = null;
setRecording(false);
if (clip) void room.sendVoice(clip).catch(() => {});
};
try {
await rec.start();
} catch {
toast(t("chatMicDenied"));
return;
}
recorder = rec;
recLabel.textContent = `0:00 / ${fmtDur(VOICE_MAX_SEC)}`;
setRecording(true);
};
micBtn.addEventListener("click", () => void startRecording());
recStop.addEventListener("click", () => recorder?.stop());
recCancel.addEventListener("click", () => recorder?.cancel());
const composer = h("div", { class: "chat-composer" }, attachBtn, micBtn, input, sendBtn, recBar, fileIn);
// ---- match contact-exchange bar (P3) --------------------------------------
const matchBar = isMatch ? h("div", { class: "match-bar", "data-testid": "mc-bar" }) : null;
const renderMatchBar = (): void => {
if (!matchBar || !matchPeer) return;
matchBar.replaceChildren();
const contact = Session.selfroom?.state.contacts.get(matchPeer);
if (contact?.status === "active") {
matchBar.append(h("span", { class: "muted small-note", "data-testid": "mc-contact-ok" }, t("mcAlreadyContact")));
return;
}
const offered = chat.matchCardOf(id);
if (offered) {
matchBar.append(
h("span", { class: "small-note", "data-testid": "mc-card-offer" }, t("mcCardOffer", { name: offered.name })),
h(
"button",
{
type: "button",
class: "btn small",
"data-testid": "mc-card-review",
onclick: () => openMatchCardReviewSheet(id, offered, room, renderMatchBar),
},
t("mcCardReview"),
),
);
return;
}
matchBar.append(
h(
"button",
{
type: "button",
class: "btn ghost small",
"data-testid": "mc-add-open",
onclick: () => openMatchAddContactSheet(id, room),
},
t("mcAddContact"),
),
);
};
renderMatchBar();
// a contact op landing (mine after the fingerprint sheet, or synced from
// another device) flips the bar to "sunteți contacte"
const unsubState = isMatch ? Session.selfroom?.onStateChanged(() => renderMatchBar()) : undefined;
const frame = h("div", { class: "chat-frame", "data-testid": "chat-view" }, header, scroll, matchBar, composer);
root.replaceChildren(frame);
renderMessages();
scroll.scrollTop = scroll.scrollHeight;
chat.markViewed(id);
// live updates: fold changes re-render + count as read while on screen
const unsub = chat.onRoomChanged(id, () => {
if (!frame.isConnected) return;
renderMessages();
renderMatchBar(); // a peer card offer may have folded in
if (document.visibilityState === "visible") chat.markViewed(id);
});
// keyboard-safe frame height (see module doc)
const vv = window.visualViewport;
const onVv = (): void => {
if (!vv || !frame.isConnected) return;
root.style.height = `${Math.round(vv.height)}px`;
window.scrollTo(0, 0);
scroll.scrollTop = scroll.scrollHeight;
};
vv?.addEventListener("resize", onVv);
onDisconnect(frame, () => {
unsub();
unsubState?.();
vv?.removeEventListener("resize", onVv);
root.style.height = "";
clearInterval(presenceTimer);
recorder?.cancel();
for (const a of players.values()) {
a.pause();
a.removeAttribute("src");
}
players.clear();
document.querySelector('[data-testid="img-viewer"]')?.remove();
for (const url of blobUrls.values()) URL.revokeObjectURL(url);
blobUrls.clear();
});
}
|