@marktiderman/genesis-chat 0.0.0-stage → 0.1.1

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.
package/dist/web.js ADDED
@@ -0,0 +1,2405 @@
1
+ import { useChat, useInbox, useConversation, useMessageActions, usePins, useFileUrls } from './chunk-27UTR5FW.js';
2
+ import { previewText, summarizeReactions, activityLine, itemSender, startsDay, dayLabel, itemTime, startsGroup, parseMentions } from './chunk-SAY24E6V.js';
3
+ import { formatDuration, nextPlaybackRate, CHAT_LIMITS, fileExtension, waveformPeaks, recordingLimitMs, isAllowedExtension, attachmentKindFor, randomId, ChatError } from './chunk-JQXZUEEL.js';
4
+ import { createContext, useRef, Fragment, useState, useEffect, useCallback, useImperativeHandle, useMemo, useLayoutEffect, useContext } from 'react';
5
+ import { MessageSquare, Pin, BellOff, AtSign, Pause, Play, Paperclip, ChevronLeft, ChevronRight, FileText, Download, Lock, Ban, AlertCircle, Clock, Reply, Copy, Pencil, PinOff, Bookmark, Flag, Trash2, SmilePlus, MoreHorizontal, Mic, X, SendHorizontal, ArrowDown, ImagePlus, Link2, ArrowLeft } from 'lucide-react';
6
+ import { Skeleton, cn, EmptyState, Button, UserAvatar, Badge, Collapsible, CollapsibleTrigger, CollapsibleContent, Dialog, DialogContent, VisuallyHidden, DialogTitle, DialogDescription, Spinner, ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuSeparator, ContextMenuItem, Tooltip, Textarea, Popover, PopoverTrigger, PopoverContent, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuItem, toast, Alert, AlertDescription, Toggle, AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, DialogHeader, RadioGroup, RadioGroupItem, Label, DialogFooter, Split } from '@marktiderman/genesis-ui';
7
+ import { renderSlot } from '@marktiderman/genesis-core/slots';
8
+ import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
9
+
10
+ // src/web/format.ts
11
+ function clockTime(iso, locale) {
12
+ return new Date(iso).toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
13
+ }
14
+ function relativeTime(iso, now = /* @__PURE__ */ new Date(), locale) {
15
+ if (!iso) return "";
16
+ const d = new Date(iso);
17
+ const diff = now.getTime() - d.getTime();
18
+ if (diff < 6e4) return "now";
19
+ if (diff < 36e5) return `${Math.floor(diff / 6e4)}m`;
20
+ const startOf = (x) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
21
+ const days = Math.round((startOf(now) - startOf(d)) / 864e5);
22
+ if (days === 0) return clockTime(iso, locale);
23
+ if (days === 1) return "Yesterday";
24
+ if (days > 1 && days < 7) return d.toLocaleDateString(locale, { weekday: "short" });
25
+ return d.toLocaleDateString(locale, {
26
+ day: "numeric",
27
+ month: "short",
28
+ year: d.getFullYear() === now.getFullYear() ? void 0 : "numeric"
29
+ });
30
+ }
31
+ function formatBytes(bytes) {
32
+ if (bytes == null || !Number.isFinite(bytes)) return "";
33
+ if (bytes < 1024) return `${bytes} B`;
34
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
35
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
36
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
37
+ }
38
+ function messageLabel(message, sender, locale) {
39
+ const what = previewText(message.body, message.attachments[0]?.kind ?? null, Boolean(message.deletedAt)) || "Message";
40
+ const extra = [message.visibility === "observers" ? "internal note" : null, message.editedAt && !message.deletedAt ? "edited" : null].filter(Boolean);
41
+ return `${sender}, ${clockTime(message.createdAt, locale)}: ${what}${extra.length ? ` (${extra.join(", ")})` : ""}`;
42
+ }
43
+ function DefaultEmpty({ archived }) {
44
+ return /* @__PURE__ */ jsx(
45
+ EmptyState,
46
+ {
47
+ icon: MessageSquare,
48
+ title: archived ? "No archived conversations" : "No conversations yet",
49
+ description: archived ? void 0 : "When someone messages you, it shows up here."
50
+ }
51
+ );
52
+ }
53
+ function inboxTitle(item, resolveUser, fallback) {
54
+ if (item.title) return item.title;
55
+ if (item.otherUserId) return resolveUser(item.otherUserId).name;
56
+ return fallback?.(item) ?? "Conversation";
57
+ }
58
+ function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived = false, fallbackTitle, slots, className, ...rest }) {
59
+ const { userId: me } = useChat();
60
+ const { items, isLoading, error, refetch } = useInbox({ kind, archived });
61
+ const list = useRef(null);
62
+ const onKeyDown = (e) => {
63
+ const buttons = Array.from(list.current?.querySelectorAll("button[data-inbox-row]") ?? []);
64
+ const at = buttons.indexOf(document.activeElement);
65
+ const to = e.key === "ArrowDown" ? Math.min(buttons.length - 1, at + 1) : e.key === "ArrowUp" ? Math.max(0, at - 1) : e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : -2;
66
+ if (to === -2 || buttons.length === 0) return;
67
+ e.preventDefault();
68
+ buttons[to < 0 ? 0 : to]?.focus();
69
+ };
70
+ if (isLoading) {
71
+ return /* @__PURE__ */ jsx("div", { className: cn("flex flex-col", className), "aria-busy": true, "aria-label": "Loading conversations", "data-testid": "genesis-chat-inbox-loading", children: Array.from({ length: 6 }, (_, i) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3 px-3 py-3", children: [
72
+ /* @__PURE__ */ jsx(Skeleton, { className: "size-10 shrink-0 rounded-full" }),
73
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 space-y-2", children: [
74
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-3.5 w-2/5" }),
75
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-3 w-4/5" })
76
+ ] })
77
+ ] }, i)) });
78
+ }
79
+ if (error) {
80
+ return /* @__PURE__ */ jsx("div", { className, children: /* @__PURE__ */ jsx(
81
+ EmptyState,
82
+ {
83
+ icon: MessageSquare,
84
+ title: "Couldn't load your conversations",
85
+ description: "Check your connection and try again.",
86
+ action: /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => void refetch(), children: "Try again" })
87
+ }
88
+ ) });
89
+ }
90
+ if (items.length === 0) {
91
+ return /* @__PURE__ */ jsx("div", { className, children: renderSlot(DefaultEmpty, slots?.empty, { archived }) });
92
+ }
93
+ return /* @__PURE__ */ jsx("nav", { "aria-label": rest["aria-label"] ?? "Conversations", className: cn("min-h-0", className), "data-testid": "genesis-chat-inbox", children: /* @__PURE__ */ jsx("ul", { ref: list, className: "flex flex-col", onKeyDown, children: items.map((item) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(InboxRow, { item, me, resolveUser, fallbackTitle, selected: item.conversationId === selectedId, onSelect }) }, item.conversationId)) }) });
94
+ }
95
+ function InboxRow({ item, me, resolveUser, fallbackTitle, selected, onSelect }) {
96
+ const title = inboxTitle(item, resolveUser, fallbackTitle);
97
+ const other = item.otherUserId ? resolveUser(item.otherUserId) : null;
98
+ const last = item.lastMessage;
99
+ const unread = item.unread > 0;
100
+ let preview = last ? previewText(last.body, last.attachment, last.deleted) : "";
101
+ if (last && last.kind !== "system" && !last.deleted && last.senderId) {
102
+ if (last.senderId === me) preview = `You: ${preview}`;
103
+ else if (!item.otherUserId && item.memberCount > 2) preview = `${resolveUser(last.senderId).name}: ${preview}`;
104
+ }
105
+ const time = relativeTime(item.lastMessageAt ?? last?.createdAt);
106
+ const count = item.unread >= 99 ? "99+" : String(item.unread);
107
+ const label = [
108
+ title,
109
+ unread ? `${count} unread` : null,
110
+ item.mentioned ? "you were mentioned" : null,
111
+ item.muted ? "muted" : null,
112
+ item.pinned ? "pinned" : null,
113
+ preview || null,
114
+ time || null
115
+ ].filter(Boolean).join(", ");
116
+ return /* @__PURE__ */ jsxs(
117
+ "button",
118
+ {
119
+ type: "button",
120
+ "data-inbox-row": true,
121
+ "aria-current": selected ? "true" : void 0,
122
+ "aria-label": label,
123
+ onClick: () => onSelect(item.conversationId),
124
+ className: cn(
125
+ "flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
126
+ selected && "bg-accent"
127
+ ),
128
+ "data-testid": `genesis-chat-inbox-row-${item.conversationId}`,
129
+ children: [
130
+ /* @__PURE__ */ jsx(UserAvatar, { name: title, src: other?.avatarUrl, className: "shrink-0" }),
131
+ /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-1 flex-col gap-0.5", "aria-hidden": true, children: [
132
+ /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2", children: [
133
+ /* @__PURE__ */ jsx("span", { className: cn("min-w-0 flex-1 truncate text-sm", unread ? "font-semibold text-foreground" : "font-medium text-foreground"), children: title }),
134
+ /* @__PURE__ */ jsx("span", { className: cn("shrink-0 text-xs tabular-nums", unread && !item.muted ? "font-medium text-primary" : "text-muted-foreground"), children: time })
135
+ ] }),
136
+ /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-1.5", children: [
137
+ /* @__PURE__ */ jsx("span", { className: cn("min-w-0 flex-1 truncate text-sm", unread ? "text-foreground" : "text-muted-foreground"), children: preview || "\xA0" }),
138
+ item.pinned ? /* @__PURE__ */ jsx(Pin, { className: "size-3.5 shrink-0 text-muted-foreground" }) : null,
139
+ item.muted ? /* @__PURE__ */ jsx(BellOff, { className: "size-3.5 shrink-0 text-muted-foreground" }) : null,
140
+ item.mentioned ? /* @__PURE__ */ jsx("span", { className: "flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground", children: /* @__PURE__ */ jsx(AtSign, { className: "size-3" }) }) : null,
141
+ unread ? /* @__PURE__ */ jsx(Badge, { variant: item.muted ? "muted" : "default", className: "h-5 min-w-5 shrink-0 justify-center rounded-full px-1.5 text-[11px] tabular-nums", children: count }) : null
142
+ ] })
143
+ ] })
144
+ ]
145
+ }
146
+ );
147
+ }
148
+ function isSafeHref(href) {
149
+ try {
150
+ const url = new URL(href);
151
+ return url.protocol === "http:" || url.protocol === "https:";
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+ var MENTION = /^@\[([^\]\n]{1,80})\]\(user:([0-9a-f-]{36})\)/i;
157
+ var LINK = /^\[([^\]\n]{1,300})\]\(((?:[^()\s]|\([^()\s]*\)){1,2000})\)/;
158
+ var BARE_URL = /^https?:\/\/[^\s<>"]+/i;
159
+ var TRAILING_PUNCTUATION = /[.,;:!?)\]'"]+$/;
160
+ function parseMarkdown(text) {
161
+ const out = [];
162
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
163
+ lines.forEach((line, i) => {
164
+ if (i > 0) out.push({ type: "break" });
165
+ out.push(...parseInline(line, 0));
166
+ });
167
+ return mergeText(out);
168
+ }
169
+ function parseInline(src, depth) {
170
+ const out = [];
171
+ let text = "";
172
+ const flush = () => {
173
+ if (text) out.push({ type: "text", text });
174
+ text = "";
175
+ };
176
+ let i = 0;
177
+ while (i < src.length) {
178
+ const ch = src[i];
179
+ const rest = src.slice(i);
180
+ const prev = i > 0 ? src[i - 1] : "";
181
+ if (ch === "\\" && i + 1 < src.length && /[*_`[\]@\\]/.test(src[i + 1])) {
182
+ text += src[i + 1];
183
+ i += 2;
184
+ continue;
185
+ }
186
+ if (ch === "`") {
187
+ const end = src.indexOf("`", i + 1);
188
+ if (end > i + 1) {
189
+ flush();
190
+ out.push({ type: "code", text: src.slice(i + 1, end) });
191
+ i = end + 1;
192
+ continue;
193
+ }
194
+ }
195
+ if (ch === "@") {
196
+ const m = MENTION.exec(rest);
197
+ if (m) {
198
+ flush();
199
+ out.push({ type: "mention", name: m[1], userId: m[2].toLowerCase() });
200
+ i += m[0].length;
201
+ continue;
202
+ }
203
+ }
204
+ if (ch === "[") {
205
+ const m = LINK.exec(rest);
206
+ if (m) {
207
+ flush();
208
+ const label = depth < 3 ? parseInline(m[1], depth + 1) : [{ type: "text", text: m[1] }];
209
+ if (isSafeHref(m[2])) out.push({ type: "link", href: m[2], children: label });
210
+ else out.push(...label);
211
+ i += m[0].length;
212
+ continue;
213
+ }
214
+ }
215
+ if ((ch === "h" || ch === "H") && !/[\w/]/.test(prev)) {
216
+ const m = BARE_URL.exec(rest);
217
+ if (m) {
218
+ const url = m[0].replace(TRAILING_PUNCTUATION, "");
219
+ if (isSafeHref(url)) {
220
+ flush();
221
+ out.push({ type: "link", href: url, children: [{ type: "text", text: url }] });
222
+ i += url.length;
223
+ continue;
224
+ }
225
+ }
226
+ }
227
+ if ((ch === "*" || ch === "_") && depth < 3) {
228
+ const double = src[i + 1] === ch;
229
+ const marker = double ? ch + ch : ch;
230
+ const opens = ch === "*" || !/\w/.test(prev);
231
+ const after = src[i + marker.length];
232
+ if (opens && after && after !== " " && after !== ch) {
233
+ const end = findClose(src, i + marker.length, marker);
234
+ if (end > 0) {
235
+ flush();
236
+ const inner = parseInline(src.slice(i + marker.length, end), depth + 1);
237
+ out.push(double ? { type: "bold", children: inner } : { type: "italic", children: inner });
238
+ i = end + marker.length;
239
+ continue;
240
+ }
241
+ }
242
+ }
243
+ text += ch;
244
+ i += 1;
245
+ }
246
+ flush();
247
+ return out;
248
+ }
249
+ function findClose(src, from, marker) {
250
+ let j = from;
251
+ while (j < src.length) {
252
+ const k = src.indexOf(marker, j);
253
+ if (k < 0) return -1;
254
+ const before = src[k - 1];
255
+ const next = src[k + marker.length];
256
+ const doubled = marker.length === 1 && next === marker;
257
+ const edge = marker[0] !== "_" || !next || !/\w/.test(next);
258
+ if (k > from && before !== " " && !doubled && edge) return k;
259
+ j = k + (doubled ? 2 : 1);
260
+ }
261
+ return -1;
262
+ }
263
+ function mergeText(nodes) {
264
+ const out = [];
265
+ for (const n of nodes) {
266
+ const last = out[out.length - 1];
267
+ if (n.type === "text" && last?.type === "text") last.text += n.text;
268
+ else out.push(n);
269
+ }
270
+ return out;
271
+ }
272
+ function ChatMarkdown({ text, renderMention, me, tone = "default", className }) {
273
+ const nodes = parseMarkdown(text);
274
+ return /* @__PURE__ */ jsx("span", { className: cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className), children: renderNodes(nodes, { renderMention, me, tone }) });
275
+ }
276
+ function renderNodes(nodes, opts) {
277
+ return nodes.map((n, i) => {
278
+ switch (n.type) {
279
+ case "text":
280
+ return /* @__PURE__ */ jsx(Fragment, { children: n.text }, i);
281
+ case "break":
282
+ return /* @__PURE__ */ jsx("br", {}, i);
283
+ case "bold":
284
+ return /* @__PURE__ */ jsx("strong", { className: "font-semibold", children: renderNodes(n.children, opts) }, i);
285
+ case "italic":
286
+ return /* @__PURE__ */ jsx("em", { children: renderNodes(n.children, opts) }, i);
287
+ case "code":
288
+ return /* @__PURE__ */ jsx(
289
+ "code",
290
+ {
291
+ className: cn(
292
+ "rounded px-1 py-0.5 font-mono text-[0.85em]",
293
+ opts.tone === "primary" ? "bg-primary-foreground/15" : "bg-background/70"
294
+ ),
295
+ children: n.text
296
+ },
297
+ i
298
+ );
299
+ case "link":
300
+ return /* @__PURE__ */ jsx(
301
+ "a",
302
+ {
303
+ href: n.href,
304
+ target: "_blank",
305
+ rel: "noopener noreferrer",
306
+ className: cn("underline underline-offset-2", opts.tone === "primary" ? "text-primary-foreground" : "text-primary"),
307
+ children: renderNodes(n.children, opts)
308
+ },
309
+ i
310
+ );
311
+ case "mention": {
312
+ if (opts.renderMention) return /* @__PURE__ */ jsx(Fragment, { children: opts.renderMention(n) }, i);
313
+ const mine = opts.me && n.userId === opts.me.toLowerCase();
314
+ return /* @__PURE__ */ jsxs(
315
+ "span",
316
+ {
317
+ "data-mention": n.userId,
318
+ className: cn(
319
+ "rounded px-1 font-medium",
320
+ opts.tone === "primary" ? "bg-primary-foreground/20 text-primary-foreground" : mine ? "bg-primary/20 text-primary" : "bg-primary/10 text-primary"
321
+ ),
322
+ children: [
323
+ "@",
324
+ n.name
325
+ ]
326
+ },
327
+ i
328
+ );
329
+ }
330
+ }
331
+ });
332
+ }
333
+ var playing = null;
334
+ var BARS = 48;
335
+ function resample(peaks, n = BARS) {
336
+ if (!peaks || peaks.length === 0) return new Array(n).fill(12);
337
+ if (peaks.length === n) return peaks;
338
+ return Array.from({ length: n }, (_, i) => peaks[Math.min(peaks.length - 1, Math.floor(i / n * peaks.length))] ?? 0);
339
+ }
340
+ function VoiceNote({ src, durationMs, waveform, transcript, transcriptStatus, tone = "default", className }) {
341
+ const audio = useRef(null);
342
+ const [isPlaying, setPlaying] = useState(false);
343
+ const [current, setCurrent] = useState(0);
344
+ const [duration, setDuration] = useState((durationMs ?? 0) / 1e3);
345
+ const [rate, setRate] = useState(1);
346
+ const bars = resample(waveform);
347
+ const progress = duration > 0 ? Math.min(1, current / duration) : 0;
348
+ useEffect(() => {
349
+ const el = audio.current;
350
+ if (!el) return;
351
+ const onPlay = () => {
352
+ if (playing && playing !== el) playing.pause();
353
+ playing = el;
354
+ setPlaying(true);
355
+ };
356
+ const onPause = () => {
357
+ if (playing === el) playing = null;
358
+ setPlaying(false);
359
+ };
360
+ const onTime = () => setCurrent(el.currentTime);
361
+ const onMeta = () => {
362
+ if (Number.isFinite(el.duration) && el.duration > 0) setDuration(el.duration);
363
+ };
364
+ const onEnded = () => {
365
+ setPlaying(false);
366
+ setCurrent(0);
367
+ };
368
+ el.addEventListener("play", onPlay);
369
+ el.addEventListener("pause", onPause);
370
+ el.addEventListener("timeupdate", onTime);
371
+ el.addEventListener("loadedmetadata", onMeta);
372
+ el.addEventListener("durationchange", onMeta);
373
+ el.addEventListener("ended", onEnded);
374
+ return () => {
375
+ el.removeEventListener("play", onPlay);
376
+ el.removeEventListener("pause", onPause);
377
+ el.removeEventListener("timeupdate", onTime);
378
+ el.removeEventListener("loadedmetadata", onMeta);
379
+ el.removeEventListener("durationchange", onMeta);
380
+ el.removeEventListener("ended", onEnded);
381
+ if (playing === el) playing = null;
382
+ };
383
+ }, []);
384
+ useEffect(() => {
385
+ if (audio.current) audio.current.playbackRate = rate;
386
+ }, [rate]);
387
+ const toggle = () => {
388
+ const el = audio.current;
389
+ if (!el || !src) return;
390
+ if (el.paused) {
391
+ el.playbackRate = rate;
392
+ void Promise.resolve(el.play?.()).catch(() => setPlaying(false));
393
+ } else el.pause();
394
+ };
395
+ const seekTo = (fraction) => {
396
+ const el = audio.current;
397
+ const f = Math.max(0, Math.min(1, fraction));
398
+ if (el && duration > 0) {
399
+ el.currentTime = f * duration;
400
+ setCurrent(f * duration);
401
+ }
402
+ };
403
+ const onBarsClick = (e) => {
404
+ const box = e.currentTarget.getBoundingClientRect();
405
+ if (box.width > 0) seekTo((e.clientX - box.left) / box.width);
406
+ };
407
+ const onBarsKey = (e) => {
408
+ if (duration <= 0) return;
409
+ const step = 5 / duration;
410
+ const keys = { ArrowRight: progress + step, ArrowUp: progress + step, ArrowLeft: progress - step, ArrowDown: progress - step, Home: 0, End: 1 };
411
+ if (e.key in keys) {
412
+ e.preventDefault();
413
+ seekTo(keys[e.key]);
414
+ }
415
+ };
416
+ const shown = isPlaying || current > 0 ? current * 1e3 : duration * 1e3;
417
+ const primary = tone === "primary";
418
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex min-w-[220px] max-w-full flex-col gap-1", className), "data-testid": "genesis-chat-voice", children: [
419
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
420
+ /* @__PURE__ */ jsx(
421
+ Button,
422
+ {
423
+ type: "button",
424
+ size: "icon",
425
+ variant: primary ? "secondary" : "default",
426
+ className: "h-9 w-9 shrink-0 rounded-full",
427
+ onClick: toggle,
428
+ disabled: !src,
429
+ "aria-label": isPlaying ? "Pause voice message" : "Play voice message",
430
+ "data-testid": "genesis-chat-voice-play",
431
+ children: isPlaying ? /* @__PURE__ */ jsx(Pause, {}) : /* @__PURE__ */ jsx(Play, {})
432
+ }
433
+ ),
434
+ /* @__PURE__ */ jsx(
435
+ "div",
436
+ {
437
+ role: "slider",
438
+ tabIndex: 0,
439
+ "aria-label": "Position",
440
+ "aria-valuemin": 0,
441
+ "aria-valuemax": Math.round(duration),
442
+ "aria-valuenow": Math.round(current),
443
+ "aria-valuetext": `${formatDuration(current * 1e3)} of ${formatDuration(duration * 1e3)}`,
444
+ className: "flex h-8 flex-1 cursor-pointer items-center gap-[2px] rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
445
+ onClick: onBarsClick,
446
+ onKeyDown: onBarsKey,
447
+ children: bars.map((peak, i) => /* @__PURE__ */ jsx(
448
+ "span",
449
+ {
450
+ "aria-hidden": true,
451
+ className: cn(
452
+ "w-[3px] flex-1 rounded-full transition-colors",
453
+ (i + 0.5) / bars.length <= progress ? primary ? "bg-primary-foreground" : "bg-primary" : primary ? "bg-primary-foreground/40" : "bg-muted-foreground/40"
454
+ ),
455
+ style: { height: `${Math.max(12, peak)}%` }
456
+ },
457
+ i
458
+ ))
459
+ }
460
+ ),
461
+ /* @__PURE__ */ jsxs(
462
+ Button,
463
+ {
464
+ type: "button",
465
+ size: "sm",
466
+ variant: "ghost",
467
+ className: cn("h-7 shrink-0 rounded-full px-2 text-xs tabular-nums", primary && "text-primary-foreground hover:bg-primary-foreground/15 hover:text-primary-foreground"),
468
+ onClick: () => setRate((r) => nextPlaybackRate(r)),
469
+ "aria-label": `Playback speed ${rate}\xD7`,
470
+ "data-testid": "genesis-chat-voice-rate",
471
+ children: [
472
+ rate,
473
+ "\xD7"
474
+ ]
475
+ }
476
+ )
477
+ ] }),
478
+ /* @__PURE__ */ jsx("div", { className: cn("pl-11 text-xs tabular-nums", primary ? "text-primary-foreground/80" : "text-muted-foreground"), children: formatDuration(shown) }),
479
+ transcriptStatus === "pending" ? /* @__PURE__ */ jsx("div", { className: cn("pl-11 text-xs italic", primary ? "text-primary-foreground/80" : "text-muted-foreground"), children: "Transcribing\u2026" }) : transcript ? /* @__PURE__ */ jsxs(Collapsible, { className: "pl-11", children: [
480
+ /* @__PURE__ */ jsx(CollapsibleTrigger, { className: cn("text-xs font-medium underline-offset-2 hover:underline", primary ? "text-primary-foreground" : "text-primary"), children: "Transcript" }),
481
+ /* @__PURE__ */ jsx(CollapsibleContent, { className: "pt-1 text-sm", children: transcript })
482
+ ] }) : null,
483
+ /* @__PURE__ */ jsx("audio", { ref: audio, src: src ?? void 0, preload: "metadata", className: "hidden" })
484
+ ] });
485
+ }
486
+ function toAttachmentView(a, urls) {
487
+ return {
488
+ key: a.id,
489
+ kind: a.kind,
490
+ url: (a.path ? urls[a.path] : null) ?? (a.provider === "external" ? a.url : null) ?? (a.kind === "link" ? a.url : null),
491
+ posterUrl: a.posterPath ? urls[a.posterPath] ?? null : null,
492
+ name: a.name,
493
+ mime: a.mime,
494
+ bytes: a.bytes,
495
+ width: a.width,
496
+ height: a.height,
497
+ durationMs: a.durationMs,
498
+ waveform: a.waveform,
499
+ transcript: a.transcript,
500
+ transcriptStatus: a.transcriptStatus,
501
+ pending: a.status === "pending",
502
+ attachment: a
503
+ };
504
+ }
505
+ function GenericAttachment({ attachment: attachment2, url }) {
506
+ const label = attachment2.name ?? attachment2.kind.charAt(0).toUpperCase() + attachment2.kind.slice(1);
507
+ const body = /* @__PURE__ */ jsxs(Fragment$1, { children: [
508
+ /* @__PURE__ */ jsx(Paperclip, { className: "size-4 shrink-0 text-muted-foreground", "aria-hidden": true }),
509
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
510
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm font-medium", children: label }),
511
+ /* @__PURE__ */ jsx("span", { className: "block text-xs capitalize text-muted-foreground", children: attachment2.kind })
512
+ ] })
513
+ ] });
514
+ return url && isSafeHref(url) ? /* @__PURE__ */ jsx("a", { href: url, target: "_blank", rel: "noopener noreferrer", className: "flex items-center gap-2 rounded-lg border bg-background p-2 text-foreground hover:bg-accent", children: body }) : /* @__PURE__ */ jsx("div", { className: "flex items-center gap-2 rounded-lg border bg-background p-2 text-foreground", children: body });
515
+ }
516
+ function Uploading({ className }) {
517
+ return /* @__PURE__ */ jsx("span", { className: cn("absolute inset-0 flex items-center justify-center bg-background/40", className), "aria-label": "Uploading", role: "status", children: /* @__PURE__ */ jsx(Spinner, { size: "sm", className: "text-foreground" }) });
518
+ }
519
+ function ImageGrid({ images, className }) {
520
+ const [open, setOpen] = useState(null);
521
+ const shown = images.slice(0, 4);
522
+ const more = images.length - shown.length;
523
+ const cols = shown.length === 1 ? "grid-cols-1" : "grid-cols-2";
524
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
525
+ /* @__PURE__ */ jsx("div", { className: cn("grid w-[min(18rem,100%)] gap-1 overflow-hidden rounded-xl", cols, className), "data-testid": "genesis-chat-images", children: shown.map((img, i) => {
526
+ const tall = shown.length === 3 && i === 0;
527
+ return /* @__PURE__ */ jsxs(
528
+ "button",
529
+ {
530
+ type: "button",
531
+ className: cn(
532
+ "relative block overflow-hidden bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
533
+ shown.length === 1 ? "max-h-80" : "aspect-square",
534
+ tall && "row-span-2 aspect-auto"
535
+ ),
536
+ onClick: () => setOpen(i),
537
+ "aria-label": `Open photo${img.name ? ` ${img.name}` : ""}`,
538
+ children: [
539
+ img.url ? /* @__PURE__ */ jsx(
540
+ "img",
541
+ {
542
+ src: img.url,
543
+ alt: img.name ?? "Photo",
544
+ loading: "lazy",
545
+ className: cn("h-full w-full object-cover", img.pending && "opacity-70"),
546
+ width: img.width ?? void 0,
547
+ height: img.height ?? void 0
548
+ }
549
+ ) : /* @__PURE__ */ jsx("span", { className: "block h-full min-h-24 w-full animate-pulse bg-muted" }),
550
+ img.pending ? /* @__PURE__ */ jsx(Uploading, {}) : null,
551
+ more > 0 && i === shown.length - 1 ? /* @__PURE__ */ jsxs("span", { className: "absolute inset-0 flex items-center justify-center bg-foreground/50 text-xl font-semibold text-background", children: [
552
+ "+",
553
+ more
554
+ ] }) : null
555
+ ]
556
+ },
557
+ img.key
558
+ );
559
+ }) }),
560
+ /* @__PURE__ */ jsx(Lightbox, { images, index: open, onIndexChange: setOpen })
561
+ ] });
562
+ }
563
+ function Lightbox({ images, index, onIndexChange }) {
564
+ const img = index === null ? null : images[index];
565
+ const go = useCallback(
566
+ (delta) => {
567
+ if (index === null || images.length < 2) return;
568
+ onIndexChange((index + delta + images.length) % images.length);
569
+ },
570
+ [images.length, index, onIndexChange]
571
+ );
572
+ const onKey = (e) => {
573
+ if (e.key === "ArrowRight") {
574
+ e.preventDefault();
575
+ go(1);
576
+ } else if (e.key === "ArrowLeft") {
577
+ e.preventDefault();
578
+ go(-1);
579
+ }
580
+ };
581
+ return /* @__PURE__ */ jsx(Dialog, { open: img != null, onOpenChange: (o) => !o && onIndexChange(null), children: /* @__PURE__ */ jsxs(DialogContent, { className: "max-w-[min(64rem,95vw)] border-0 bg-background p-2", onKeyDown: onKey, "data-testid": "genesis-chat-lightbox", children: [
582
+ /* @__PURE__ */ jsxs(VisuallyHidden, { children: [
583
+ /* @__PURE__ */ jsx(DialogTitle, { children: img?.name ?? "Photo" }),
584
+ /* @__PURE__ */ jsxs(DialogDescription, { children: [
585
+ "Photo ",
586
+ index === null ? 0 : index + 1,
587
+ " of ",
588
+ images.length,
589
+ ". Use the arrow keys for the others."
590
+ ] })
591
+ ] }),
592
+ img?.url ? /* @__PURE__ */ jsx("img", { src: img.url, alt: img.name ?? "Photo", className: "mx-auto max-h-[80vh] w-auto rounded-md object-contain" }) : null,
593
+ images.length > 1 ? /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
594
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => go(-1), "aria-label": "Previous photo", children: /* @__PURE__ */ jsx(ChevronLeft, {}) }),
595
+ /* @__PURE__ */ jsxs("span", { className: "text-sm tabular-nums text-muted-foreground", children: [
596
+ (index ?? 0) + 1,
597
+ " / ",
598
+ images.length
599
+ ] }),
600
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", onClick: () => go(1), "aria-label": "Next photo", children: /* @__PURE__ */ jsx(ChevronRight, {}) })
601
+ ] }) : null
602
+ ] }) });
603
+ }
604
+ function VideoAttachment({ view }) {
605
+ return /* @__PURE__ */ jsxs("div", { className: "relative w-[min(20rem,100%)] overflow-hidden rounded-xl bg-muted", "data-testid": "genesis-chat-video", children: [
606
+ view.url ? (
607
+ // eslint-disable-next-line jsx-a11y/media-has-caption -- transcripts are offered beside voice; video captions are the provider's
608
+ /* @__PURE__ */ jsx("video", { controls: true, playsInline: true, preload: "metadata", poster: view.posterUrl ?? void 0, src: view.url, className: "block max-h-80 w-full", "aria-label": view.name ?? "Video" })
609
+ ) : /* @__PURE__ */ jsx("span", { className: "block aspect-video w-full animate-pulse bg-muted" }),
610
+ view.pending ? /* @__PURE__ */ jsx(Uploading, {}) : null
611
+ ] });
612
+ }
613
+ function FileChip({ view, tone = "default" }) {
614
+ const primary = tone === "primary";
615
+ const ext = (view.name?.split(".").pop() ?? "").toUpperCase();
616
+ return /* @__PURE__ */ jsxs(
617
+ "div",
618
+ {
619
+ className: cn("flex w-[min(18rem,100%)] items-center gap-2 rounded-lg p-2", primary ? "bg-primary-foreground/15" : "border bg-background"),
620
+ "data-testid": "genesis-chat-file",
621
+ children: [
622
+ /* @__PURE__ */ jsx("span", { className: cn("relative flex size-9 shrink-0 items-center justify-center rounded-md", primary ? "bg-primary-foreground/20" : "bg-muted"), children: view.pending ? /* @__PURE__ */ jsx(Spinner, { size: "sm", className: primary ? "text-primary-foreground" : void 0 }) : /* @__PURE__ */ jsx(FileText, { className: "size-4", "aria-hidden": true }) }),
623
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
624
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm font-medium", children: view.name ?? "File" }),
625
+ /* @__PURE__ */ jsxs("span", { className: cn("block text-xs", primary ? "text-primary-foreground/80" : "text-muted-foreground"), children: [
626
+ [ext, formatBytes(view.bytes)].filter(Boolean).join(" \xB7 "),
627
+ view.pending ? " \xB7 Uploading\u2026" : ""
628
+ ] })
629
+ ] }),
630
+ view.url && !view.pending ? /* @__PURE__ */ jsx(
631
+ Button,
632
+ {
633
+ asChild: true,
634
+ size: "icon",
635
+ variant: "ghost",
636
+ className: cn("size-8 shrink-0", primary && "text-primary-foreground hover:bg-primary-foreground/15 hover:text-primary-foreground"),
637
+ children: /* @__PURE__ */ jsx("a", { href: view.url, download: view.name ?? true, target: "_blank", rel: "noopener noreferrer", "aria-label": `Download ${view.name ?? "file"}`, children: /* @__PURE__ */ jsx(Download, {}) })
638
+ }
639
+ ) : null
640
+ ]
641
+ }
642
+ );
643
+ }
644
+ function LinkAttachment({ view, tone }) {
645
+ if (!view.url || !isSafeHref(view.url)) return null;
646
+ let host = view.url;
647
+ try {
648
+ host = new URL(view.url).host;
649
+ } catch {
650
+ }
651
+ return /* @__PURE__ */ jsxs(
652
+ "a",
653
+ {
654
+ href: view.url,
655
+ target: "_blank",
656
+ rel: "noopener noreferrer",
657
+ className: cn("flex w-[min(18rem,100%)] items-center gap-2 rounded-lg p-2", tone === "primary" ? "bg-primary-foreground/15" : "border bg-background hover:bg-accent"),
658
+ children: [
659
+ /* @__PURE__ */ jsx(Link2, { className: "size-4 shrink-0", "aria-hidden": true }),
660
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0", children: [
661
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm font-medium", children: view.name ?? host }),
662
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-xs opacity-80", children: host })
663
+ ] })
664
+ ]
665
+ }
666
+ );
667
+ }
668
+ function MessageAttachments({ views, message, mine, tone, slot }) {
669
+ if (views.length === 0) return null;
670
+ const images = views.filter((v) => v.kind === "image");
671
+ const others = views.filter((v) => v.kind !== "image");
672
+ const Custom = slot === void 0 ? GenericAttachment : slot;
673
+ return /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
674
+ images.length ? /* @__PURE__ */ jsx(ImageGrid, { images }) : null,
675
+ others.map((v) => {
676
+ switch (v.kind) {
677
+ case "voice":
678
+ return /* @__PURE__ */ jsx(
679
+ VoiceNote,
680
+ {
681
+ src: v.url,
682
+ durationMs: v.durationMs,
683
+ waveform: v.waveform,
684
+ transcript: v.transcript,
685
+ transcriptStatus: v.transcriptStatus,
686
+ tone
687
+ },
688
+ v.key
689
+ );
690
+ case "video":
691
+ return /* @__PURE__ */ jsx(VideoAttachment, { view: v }, v.key);
692
+ case "file":
693
+ return /* @__PURE__ */ jsx(FileChip, { view: v, tone }, v.key);
694
+ case "link":
695
+ return /* @__PURE__ */ jsx(LinkAttachment, { view: v, tone }, v.key);
696
+ default:
697
+ if (!v.attachment || !message) return /* @__PURE__ */ jsx(FileChip, { view: v, tone }, v.key);
698
+ return Custom ? /* @__PURE__ */ jsx(Custom, { attachment: v.attachment, message, url: v.url, mine }, v.key) : null;
699
+ }
700
+ })
701
+ ] });
702
+ }
703
+ var ThreadContext = createContext(null);
704
+ function useThreadContext() {
705
+ const ctx = useContext(ThreadContext);
706
+ if (!ctx) throw new Error("Chat bubbles render inside <ChatThread>.");
707
+ return ctx;
708
+ }
709
+ var QUICK_REACTIONS = ["\u{1F44D}", "\u2764\uFE0F", "\u{1F602}", "\u{1F62E}", "\u{1F622}", "\u{1F64F}"];
710
+ function pendingAsMessage(p) {
711
+ return {
712
+ id: `pending:${p.clientId}`,
713
+ conversationId: p.input.conversationId,
714
+ seq: Number.MAX_SAFE_INTEGER,
715
+ clientId: p.clientId,
716
+ senderId: p.senderId,
717
+ senderRole: "member",
718
+ kind: "text",
719
+ body: p.input.body ?? "",
720
+ mentions: p.input.mentions ?? [],
721
+ replyToId: p.input.replyToId ?? null,
722
+ threadRootId: p.input.threadRootId ?? null,
723
+ threadCount: 0,
724
+ threadLastAt: null,
725
+ topic: p.input.topic ?? null,
726
+ visibility: p.input.visibility ?? "everyone",
727
+ silent: Boolean(p.input.silent),
728
+ meta: {},
729
+ createdAt: p.createdAt,
730
+ editedAt: null,
731
+ deletedAt: null,
732
+ attachments: [],
733
+ reactions: []
734
+ };
735
+ }
736
+ function pendingViews(p) {
737
+ const files = p.files ?? [];
738
+ return files.map((f, i) => ({
739
+ key: `${p.clientId}:${i}`,
740
+ kind: f.meta?.kind ?? attachmentKindFor(f.contentType),
741
+ url: p.previews?.[i] ?? f.previewUrl ?? null,
742
+ posterUrl: null,
743
+ name: f.meta?.name ?? f.name,
744
+ mime: f.contentType,
745
+ bytes: f.meta?.bytes ?? (typeof Blob !== "undefined" && f.data instanceof Blob ? f.data.size : null),
746
+ width: f.meta?.width ?? null,
747
+ height: f.meta?.height ?? null,
748
+ durationMs: f.meta?.durationMs ?? null,
749
+ waveform: f.meta?.waveform ?? null,
750
+ transcript: null,
751
+ transcriptStatus: null,
752
+ pending: p.status === "sending"
753
+ }));
754
+ }
755
+ function returnFocus(e, keepFocus) {
756
+ const next = keepFocus.current;
757
+ if (next) {
758
+ e.preventDefault();
759
+ keepFocus.current = null;
760
+ next();
761
+ }
762
+ }
763
+ function plainText(body) {
764
+ return body.replace(/@\[([^\]]+)\]\(user:[0-9a-f-]{36}\)/gi, "@$1");
765
+ }
766
+ function MessageRow({ item, startsGroup: startsGroup2, className }) {
767
+ const ctx = useThreadContext();
768
+ const keepFocus = useRef(null);
769
+ const pending = item.type === "pending" ? item.pending : null;
770
+ const message = item.type === "message" ? item.message : pendingAsMessage(item.pending);
771
+ if (message.kind === "system") {
772
+ return /* @__PURE__ */ jsx("div", { className: cn("flex justify-center px-4 py-1", className), role: "note", "data-testid": "genesis-chat-system", children: /* @__PURE__ */ jsx("span", { className: "max-w-[85%] rounded-full bg-muted px-3 py-1 text-center text-xs text-muted-foreground", children: /* @__PURE__ */ jsx(ChatMarkdown, { text: message.body, me: ctx.me }) }) });
773
+ }
774
+ const mine = message.senderId === ctx.me;
775
+ const sender = message.senderId ? ctx.resolveUser(message.senderId) : { name: "Someone" };
776
+ const deleted = Boolean(message.deletedAt);
777
+ const note = message.visibility === "observers";
778
+ const showHeader = ctx.isGroup && !mine && startsGroup2;
779
+ const tone = mine && !note && !deleted ? "primary" : "default";
780
+ const views = pending ? pendingViews(pending) : message.attachments.map((a) => toAttachmentView(a, ctx.urls));
781
+ const editing = ctx.editingId === message.id;
782
+ const reactions = pending ? [] : summarizeReactions(message, ctx.me);
783
+ const highlighted = ctx.highlightedId === message.id;
784
+ const actionable = !pending && !deleted;
785
+ const bubble = /* @__PURE__ */ jsxs(
786
+ "div",
787
+ {
788
+ className: cn(
789
+ "relative min-w-0 max-w-full rounded-2xl px-3 py-2 text-sm leading-relaxed shadow-sm transition-shadow",
790
+ tone === "primary" ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
791
+ mine ? startsGroup2 ? "rounded-tr-md" : "" : startsGroup2 ? "rounded-tl-md" : "",
792
+ note && "border border-warning/50 bg-warning/15 text-foreground",
793
+ deleted && "border border-dashed bg-transparent text-muted-foreground shadow-none",
794
+ highlighted && "ring-2 ring-ring ring-offset-2 ring-offset-background",
795
+ pending?.status === "failed" && "opacity-80"
796
+ ),
797
+ "data-testid": deleted ? "genesis-chat-deleted" : note ? "genesis-chat-note" : mine ? "genesis-chat-mine" : "genesis-chat-theirs",
798
+ children: [
799
+ note ? /* @__PURE__ */ jsxs("div", { className: "mb-1 flex items-center gap-1 text-xs font-semibold text-foreground/80", children: [
800
+ /* @__PURE__ */ jsx(Lock, { className: "size-3", "aria-hidden": true }),
801
+ " Internal note"
802
+ ] }) : null,
803
+ deleted ? /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-1.5 italic", children: [
804
+ /* @__PURE__ */ jsx(Ban, { className: "size-3.5", "aria-hidden": true }),
805
+ " Message deleted"
806
+ ] }) : editing ? /* @__PURE__ */ jsx(EditBox, { message }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
807
+ message.replyToId ? /* @__PURE__ */ jsx(ReplyQuote, { id: message.replyToId, tone }) : null,
808
+ views.length ? /* @__PURE__ */ jsx("div", { className: cn(message.body ? "mb-1.5" : ""), children: /* @__PURE__ */ jsx(MessageAttachments, { views, message: pending ? void 0 : message, mine, tone, slot: ctx.attachmentSlot }) }) : null,
809
+ message.body ? /* @__PURE__ */ jsx(ChatMarkdown, { text: message.body, me: ctx.me, tone }) : null,
810
+ /* @__PURE__ */ jsx(Meta, { message, pending, tone })
811
+ ] })
812
+ ]
813
+ }
814
+ );
815
+ return /* @__PURE__ */ jsxs(
816
+ "article",
817
+ {
818
+ id: `genesis-chat-msg-${message.id}`,
819
+ "data-message-id": pending ? void 0 : message.id,
820
+ "aria-label": messageLabel(message, mine ? "You" : sender.name),
821
+ className: cn("group/msg flex gap-2 px-3", mine ? "flex-row-reverse" : "flex-row", startsGroup2 ? "mt-2" : "mt-0.5", className),
822
+ "data-testid": pending ? `genesis-chat-pending-${pending.status}` : "genesis-chat-message",
823
+ children: [
824
+ ctx.isGroup && !mine ? showHeader ? /* @__PURE__ */ jsx(UserAvatar, { name: sender.name, src: sender.avatarUrl, size: "sm", className: "mt-5" }) : /* @__PURE__ */ jsx("span", { className: "w-8 shrink-0", "aria-hidden": true }) : null,
825
+ /* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0 max-w-[min(78%,36rem)] flex-col", mine ? "items-end" : "items-start"), children: [
826
+ showHeader ? /* @__PURE__ */ jsx("span", { className: "mb-0.5 px-3 text-xs font-medium text-muted-foreground", children: sender.name }) : null,
827
+ /* @__PURE__ */ jsxs("div", { className: cn("flex max-w-full items-center gap-1", mine ? "flex-row-reverse" : "flex-row"), children: [
828
+ actionable ? /* @__PURE__ */ jsxs(ContextMenu, { children: [
829
+ /* @__PURE__ */ jsx(ContextMenuTrigger, { asChild: true, children: bubble }),
830
+ /* @__PURE__ */ jsx(ContextMenuContent, { className: "w-56", onCloseAutoFocus: (e) => returnFocus(e, keepFocus), children: /* @__PURE__ */ jsx(ActionItems, { message, mine, keepFocus, Item: ContextMenuItem, Separator: ContextMenuSeparator }) })
831
+ ] }) : bubble,
832
+ actionable && !editing ? /* @__PURE__ */ jsx(HoverBar, { message, mine }) : null
833
+ ] }),
834
+ reactions.length ? /* @__PURE__ */ jsx("div", { className: cn("-mt-1 flex flex-wrap gap-1 px-2", mine ? "justify-end" : "justify-start"), "data-testid": "genesis-chat-reactions", children: reactions.map((r) => /* @__PURE__ */ jsx(Tooltip, { content: r.userIds.map((u) => u === ctx.me ? "You" : ctx.resolveUser(u).name).join(", "), children: /* @__PURE__ */ jsxs(
835
+ "button",
836
+ {
837
+ type: "button",
838
+ "aria-pressed": r.mine,
839
+ "aria-label": `${r.emoji} ${r.count}${r.mine ? ", including you" : ""}. ${r.mine ? "Remove" : "Add"} your reaction`,
840
+ onClick: () => ctx.react(message, r.emoji, !r.mine),
841
+ disabled: !ctx.canPost,
842
+ className: cn(
843
+ "inline-flex h-6 items-center gap-1 rounded-full border bg-background px-2 text-xs shadow-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none",
844
+ r.mine && "border-primary bg-primary/10 text-primary"
845
+ ),
846
+ "data-testid": `genesis-chat-reaction-${r.emoji}`,
847
+ children: [
848
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: r.emoji }),
849
+ /* @__PURE__ */ jsx("span", { className: "tabular-nums", "aria-hidden": true, children: r.count })
850
+ ]
851
+ }
852
+ ) }, r.emoji)) }) : null,
853
+ !pending && message.threadCount > 0 && ctx.openThread ? /* @__PURE__ */ jsxs(Button, { type: "button", variant: "link", size: "sm", className: "h-6 px-2 text-xs", onClick: () => ctx.openThread?.(message), children: [
854
+ /* @__PURE__ */ jsx(MessageSquare, { className: "size-3", "aria-hidden": true }),
855
+ message.threadCount === 1 ? "1 reply" : `${message.threadCount} replies`
856
+ ] }) : null,
857
+ pending?.status === "failed" ? /* @__PURE__ */ jsxs("div", { className: "mt-1 flex items-center gap-1 text-xs text-destructive", role: "alert", children: [
858
+ /* @__PURE__ */ jsx(AlertCircle, { className: "size-3.5", "aria-hidden": true }),
859
+ /* @__PURE__ */ jsxs("span", { children: [
860
+ "Not sent",
861
+ pending.error && pending.error !== "Not sent" ? `: ${pending.error}` : ""
862
+ ] }),
863
+ /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "ghost", className: "h-6 px-2 text-xs", onClick: () => ctx.retry(pending.clientId), "data-testid": "genesis-chat-retry", children: "Retry" }),
864
+ /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "ghost", className: "h-6 px-2 text-xs text-destructive hover:text-destructive", onClick: () => ctx.discard(pending.clientId), "data-testid": "genesis-chat-discard", children: "Delete" })
865
+ ] }) : null
866
+ ] })
867
+ ]
868
+ }
869
+ );
870
+ }
871
+ function Meta({ message, pending, tone }) {
872
+ const muted = tone === "primary" ? "text-primary-foreground/75" : "text-muted-foreground";
873
+ return /* @__PURE__ */ jsxs("span", { className: cn("float-right ml-3 mt-1 flex translate-y-1 items-center gap-1 text-[11px] leading-none", muted), children: [
874
+ message.editedAt ? /* @__PURE__ */ jsx("span", { children: "edited" }) : null,
875
+ pending?.status === "sending" ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
876
+ /* @__PURE__ */ jsx(Clock, { className: "size-3", "aria-hidden": true }),
877
+ /* @__PURE__ */ jsx("span", { children: "Sending\u2026" })
878
+ ] }) : pending?.status === "failed" ? /* @__PURE__ */ jsx(AlertCircle, { className: "size-3", "aria-label": "Not sent" }) : /* @__PURE__ */ jsx("time", { dateTime: message.createdAt, children: clockTime(message.createdAt) })
879
+ ] });
880
+ }
881
+ function ReplyQuote({ id, tone }) {
882
+ const ctx = useThreadContext();
883
+ const original = ctx.byId.get(id);
884
+ const who = original?.senderId ? original.senderId === ctx.me ? "You" : ctx.resolveUser(original.senderId).name : null;
885
+ const text = original ? previewText(original.body, original.attachments[0]?.kind ?? null, Boolean(original.deletedAt)) : "Earlier message";
886
+ return /* @__PURE__ */ jsxs(
887
+ "button",
888
+ {
889
+ type: "button",
890
+ onClick: () => ctx.jumpTo(id),
891
+ className: cn(
892
+ "mb-1.5 block w-full min-w-0 rounded-md border-l-4 px-2 py-1 text-left text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
893
+ tone === "primary" ? "border-primary-foreground/60 bg-primary-foreground/15" : "border-primary bg-background/60"
894
+ ),
895
+ "aria-label": `Replying to ${who ?? "a message"}: ${text}. Show it`,
896
+ "data-testid": "genesis-chat-reply-quote",
897
+ children: [
898
+ who ? /* @__PURE__ */ jsx("span", { className: "block font-semibold", children: who }) : null,
899
+ /* @__PURE__ */ jsx("span", { className: "line-clamp-2 opacity-80", children: text })
900
+ ]
901
+ }
902
+ );
903
+ }
904
+ function EditBox({ message }) {
905
+ const ctx = useThreadContext();
906
+ const [value, setValue] = useState(message.body);
907
+ const [busy, setBusy] = useState(false);
908
+ const ref = useRef(null);
909
+ useEffect(() => {
910
+ const el = ref.current;
911
+ if (!el) return;
912
+ el.focus();
913
+ el.setSelectionRange(el.value.length, el.value.length);
914
+ }, []);
915
+ const save = async () => {
916
+ const body = value.trim();
917
+ if (!body || body === message.body) return ctx.startEdit(null);
918
+ setBusy(true);
919
+ try {
920
+ await ctx.saveEdit(message, body);
921
+ ctx.startEdit(null);
922
+ } finally {
923
+ setBusy(false);
924
+ }
925
+ };
926
+ return /* @__PURE__ */ jsxs("div", { className: "flex min-w-[16rem] flex-col gap-2", children: [
927
+ /* @__PURE__ */ jsx(
928
+ Textarea,
929
+ {
930
+ ref,
931
+ value,
932
+ onChange: (e) => setValue(e.target.value),
933
+ onKeyDown: (e) => {
934
+ if (e.key === "Escape") {
935
+ e.preventDefault();
936
+ ctx.startEdit(null);
937
+ } else if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
938
+ e.preventDefault();
939
+ void save();
940
+ }
941
+ },
942
+ id: `genesis-chat-edit-${message.id}`,
943
+ "aria-label": "Edit message",
944
+ className: "min-h-[60px] resize-none bg-background text-foreground"
945
+ }
946
+ ),
947
+ /* @__PURE__ */ jsxs("div", { className: "flex justify-end gap-2", children: [
948
+ /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "ghost", onClick: () => ctx.startEdit(null), className: cn("h-7", "text-current"), children: "Cancel" }),
949
+ /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: "secondary", onClick: () => void save(), loading: busy, className: "h-7", children: "Save" })
950
+ ] })
951
+ ] });
952
+ }
953
+ function ActionItems({ message, mine, keepFocus, Item, Separator }) {
954
+ const ctx = useThreadContext();
955
+ const pinned = ctx.pins.has(message.id);
956
+ const canDelete = mine || ctx.canModerate;
957
+ const canEdit = mine && message.kind === "text" && message.body.length > 0;
958
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
959
+ ctx.canPost ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
960
+ /* @__PURE__ */ jsx("div", { className: "flex justify-between gap-0.5 px-1 py-1", role: "group", "aria-label": "React", children: ctx.quickReactions.map((emoji) => {
961
+ const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
962
+ return /* @__PURE__ */ jsx(
963
+ Item,
964
+ {
965
+ className: cn("justify-center rounded-full px-2 text-lg", on && "bg-primary/10"),
966
+ onSelect: () => ctx.react(message, emoji, !on),
967
+ "aria-label": `${on ? "Remove" : "React with"} ${emoji}`,
968
+ children: emoji
969
+ },
970
+ emoji
971
+ );
972
+ }) }),
973
+ /* @__PURE__ */ jsx(Separator, {}),
974
+ /* @__PURE__ */ jsxs(Item, { className: "gap-2", onSelect: () => {
975
+ keepFocus.current = ctx.focusComposer;
976
+ ctx.reply(message);
977
+ }, children: [
978
+ /* @__PURE__ */ jsx(Reply, { className: "size-4", "aria-hidden": true }),
979
+ " Reply"
980
+ ] })
981
+ ] }) : null,
982
+ message.body ? /* @__PURE__ */ jsxs(Item, { className: "gap-2", onSelect: () => ctx.copy(message), children: [
983
+ /* @__PURE__ */ jsx(Copy, { className: "size-4", "aria-hidden": true }),
984
+ " Copy text"
985
+ ] }) : null,
986
+ canEdit && ctx.canPost ? /* @__PURE__ */ jsxs(Item, { className: "gap-2", onSelect: () => {
987
+ keepFocus.current = () => document.getElementById(`genesis-chat-edit-${message.id}`)?.focus();
988
+ ctx.startEdit(message.id);
989
+ }, children: [
990
+ /* @__PURE__ */ jsx(Pencil, { className: "size-4", "aria-hidden": true }),
991
+ " Edit"
992
+ ] }) : null,
993
+ ctx.canPin ? /* @__PURE__ */ jsxs(Item, { className: "gap-2", onSelect: () => ctx.pin(message, !pinned), children: [
994
+ pinned ? /* @__PURE__ */ jsx(PinOff, { className: "size-4", "aria-hidden": true }) : /* @__PURE__ */ jsx(Pin, { className: "size-4", "aria-hidden": true }),
995
+ " ",
996
+ pinned ? "Unpin" : "Pin"
997
+ ] }) : null,
998
+ /* @__PURE__ */ jsxs(Item, { className: "gap-2", onSelect: () => ctx.save(message), children: [
999
+ /* @__PURE__ */ jsx(Bookmark, { className: "size-4", "aria-hidden": true }),
1000
+ " Save"
1001
+ ] }),
1002
+ !mine && message.senderId ? /* @__PURE__ */ jsxs(Item, { className: "gap-2", onSelect: () => ctx.askReport(message), children: [
1003
+ /* @__PURE__ */ jsx(Flag, { className: "size-4", "aria-hidden": true }),
1004
+ " Report"
1005
+ ] }) : null,
1006
+ canDelete ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
1007
+ /* @__PURE__ */ jsx(Separator, {}),
1008
+ /* @__PURE__ */ jsxs(Item, { onSelect: () => ctx.askDelete(message), className: "gap-2 text-destructive focus:text-destructive", children: [
1009
+ /* @__PURE__ */ jsx(Trash2, { className: "size-4", "aria-hidden": true }),
1010
+ " Delete"
1011
+ ] })
1012
+ ] }) : null
1013
+ ] });
1014
+ }
1015
+ function HoverBar({ message, mine }) {
1016
+ const ctx = useThreadContext();
1017
+ const keepFocus = useRef(null);
1018
+ const [open, setOpen] = useState(null);
1019
+ return /* @__PURE__ */ jsxs(
1020
+ "div",
1021
+ {
1022
+ className: cn(
1023
+ "flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/msg:opacity-100 [@media(hover:none)]:hidden",
1024
+ open && "opacity-100",
1025
+ mine ? "flex-row-reverse" : "flex-row"
1026
+ ),
1027
+ children: [
1028
+ ctx.canPost ? /* @__PURE__ */ jsxs(Popover, { open: open === "react", onOpenChange: (o) => setOpen(o ? "react" : null), children: [
1029
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "size-7 rounded-full text-muted-foreground", "aria-label": "React", children: /* @__PURE__ */ jsx(SmilePlus, {}) }) }),
1030
+ /* @__PURE__ */ jsx(PopoverContent, { side: "top", align: mine ? "end" : "start", className: "flex w-auto gap-0.5 rounded-full p-1", children: ctx.quickReactions.map((emoji) => {
1031
+ const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
1032
+ return /* @__PURE__ */ jsx(
1033
+ "button",
1034
+ {
1035
+ type: "button",
1036
+ className: cn("flex size-9 items-center justify-center rounded-full text-xl transition-transform hover:scale-125 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", on && "bg-primary/10"),
1037
+ "aria-label": `${on ? "Remove" : "React with"} ${emoji}`,
1038
+ "aria-pressed": on,
1039
+ onClick: () => {
1040
+ ctx.react(message, emoji, !on);
1041
+ setOpen(null);
1042
+ },
1043
+ children: emoji
1044
+ },
1045
+ emoji
1046
+ );
1047
+ }) })
1048
+ ] }) : null,
1049
+ ctx.canPost ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "size-7 rounded-full text-muted-foreground", "aria-label": "Reply", onClick: () => ctx.reply(message), children: /* @__PURE__ */ jsx(Reply, {}) }) : null,
1050
+ /* @__PURE__ */ jsxs(DropdownMenu, { open: open === "more", onOpenChange: (o) => setOpen(o ? "more" : null), children: [
1051
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "size-7 rounded-full text-muted-foreground", "aria-label": "More actions", "data-testid": "genesis-chat-more", children: /* @__PURE__ */ jsx(MoreHorizontal, {}) }) }),
1052
+ /* @__PURE__ */ jsx(DropdownMenuContent, { align: mine ? "end" : "start", className: "w-56", onCloseAutoFocus: (e) => returnFocus(e, keepFocus), children: /* @__PURE__ */ jsx(ActionItems, { message, mine, keepFocus, Item: DropdownMenuItem, Separator: DropdownMenuSeparator }) })
1053
+ ] })
1054
+ ]
1055
+ }
1056
+ );
1057
+ }
1058
+ var SAMPLE_MS = 100;
1059
+ function pickVoiceMimeType() {
1060
+ if (typeof MediaRecorder === "undefined") return null;
1061
+ for (const type of ["audio/webm;codecs=opus", "audio/mp4", "audio/webm"]) {
1062
+ try {
1063
+ if (MediaRecorder.isTypeSupported(type)) return type;
1064
+ } catch {
1065
+ }
1066
+ }
1067
+ return "";
1068
+ }
1069
+ function voiceFile(recording) {
1070
+ return {
1071
+ data: recording.blob,
1072
+ name: recording.name,
1073
+ contentType: recording.contentType,
1074
+ previewUrl: typeof URL !== "undefined" && URL.createObjectURL ? URL.createObjectURL(recording.blob) : void 0,
1075
+ meta: { kind: "voice", durationMs: recording.durationMs, waveform: recording.waveform, bytes: recording.blob.size }
1076
+ };
1077
+ }
1078
+ function useVoiceRecorder(options = {}) {
1079
+ const maxMs = options.maxMs ?? CHAT_LIMITS.recordingMs;
1080
+ const [state, setState] = useState("idle");
1081
+ const [elapsedMs, setElapsedMs] = useState(0);
1082
+ const [levels, setLevels] = useState([]);
1083
+ const [error, setError] = useState(null);
1084
+ const opts = useRef(options);
1085
+ opts.current = options;
1086
+ const session = useRef(null);
1087
+ const cleanup = useCallback(() => {
1088
+ const s = session.current;
1089
+ if (!s) return;
1090
+ clearInterval(s.timer);
1091
+ for (const t of s.stream.getTracks()) t.stop();
1092
+ void s.context?.close().catch(() => void 0);
1093
+ session.current = null;
1094
+ }, []);
1095
+ const stop = useCallback(async () => {
1096
+ const s = session.current;
1097
+ if (!s) return null;
1098
+ if (s.recorder.state !== "inactive") s.recorder.stop();
1099
+ return s.done;
1100
+ }, []);
1101
+ const abandoned = useRef(false);
1102
+ const cancel = useCallback(() => {
1103
+ const s = session.current;
1104
+ if (!s) {
1105
+ abandoned.current = true;
1106
+ setState((st) => st === "requesting" ? "idle" : st);
1107
+ return;
1108
+ }
1109
+ s.discard = true;
1110
+ if (s.recorder.state !== "inactive") s.recorder.stop();
1111
+ else cleanup();
1112
+ }, [cleanup]);
1113
+ const start = useCallback(async () => {
1114
+ if (session.current) return;
1115
+ const mimeType = pickVoiceMimeType();
1116
+ if (mimeType === null || typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
1117
+ setState("unsupported");
1118
+ setError("This browser can't record voice messages.");
1119
+ return;
1120
+ }
1121
+ setState("requesting");
1122
+ setError(null);
1123
+ abandoned.current = false;
1124
+ let stream;
1125
+ try {
1126
+ stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true } });
1127
+ } catch (e) {
1128
+ const name = e?.name;
1129
+ if (name === "NotAllowedError" || name === "SecurityError") {
1130
+ setState("denied");
1131
+ setError("Microphone access is blocked. Allow it for this site in your browser's settings to record voice messages.");
1132
+ } else if (name === "NotFoundError") {
1133
+ setState("error");
1134
+ setError("No microphone was found.");
1135
+ } else {
1136
+ setState("error");
1137
+ setError("The microphone couldn't be started.");
1138
+ }
1139
+ return;
1140
+ }
1141
+ if (abandoned.current) {
1142
+ for (const t of stream.getTracks()) t.stop();
1143
+ return;
1144
+ }
1145
+ let recorder;
1146
+ try {
1147
+ recorder = new MediaRecorder(stream, mimeType ? { mimeType } : void 0);
1148
+ } catch {
1149
+ for (const t of stream.getTracks()) t.stop();
1150
+ setState("unsupported");
1151
+ setError("This browser can't record voice messages.");
1152
+ return;
1153
+ }
1154
+ let context = null;
1155
+ let analyser = null;
1156
+ try {
1157
+ const Ctx = window.AudioContext ?? window.webkitAudioContext;
1158
+ if (Ctx) {
1159
+ context = new Ctx();
1160
+ analyser = context.createAnalyser();
1161
+ analyser.fftSize = 1024;
1162
+ context.createMediaStreamSource(stream).connect(analyser);
1163
+ }
1164
+ } catch {
1165
+ context = null;
1166
+ analyser = null;
1167
+ }
1168
+ const buffer = analyser ? new Float32Array(analyser.fftSize) : null;
1169
+ const chunks = [];
1170
+ const samples = [];
1171
+ const startedAt = Date.now();
1172
+ let resolveDone;
1173
+ const done = new Promise((r) => resolveDone = r);
1174
+ const timer = setInterval(() => {
1175
+ const s = session.current;
1176
+ if (!s) return;
1177
+ let level = 0;
1178
+ if (analyser && buffer) {
1179
+ analyser.getFloatTimeDomainData(buffer);
1180
+ for (let i = 0; i < buffer.length; i++) level = Math.max(level, Math.abs(buffer[i]));
1181
+ }
1182
+ s.samples.push(level);
1183
+ setLevels((prev) => [...prev, level].slice(-32));
1184
+ const elapsed = Date.now() - s.startedAt;
1185
+ setElapsedMs(elapsed);
1186
+ opts.current.onTick?.();
1187
+ if (elapsed >= maxMs && s.recorder.state === "recording") {
1188
+ void stop().then((r) => r && opts.current.onLimit?.(r));
1189
+ }
1190
+ }, SAMPLE_MS);
1191
+ recorder.ondataavailable = (e) => {
1192
+ if (e.data && e.data.size > 0) chunks.push(e.data);
1193
+ };
1194
+ recorder.onstop = () => {
1195
+ const s = session.current;
1196
+ const discard = s?.discard ?? true;
1197
+ const durationMs = Math.min(maxMs, Date.now() - startedAt);
1198
+ const contentType = (recorder.mimeType || mimeType || "audio/webm").split(";")[0].trim();
1199
+ cleanup();
1200
+ setState("idle");
1201
+ setElapsedMs(0);
1202
+ setLevels([]);
1203
+ if (discard || chunks.length === 0) {
1204
+ resolveDone(null);
1205
+ return;
1206
+ }
1207
+ const blob = new Blob(chunks, { type: contentType });
1208
+ const ext = fileExtension("", contentType) || "webm";
1209
+ resolveDone({
1210
+ blob,
1211
+ contentType,
1212
+ name: `voice-${new Date(startedAt).toISOString().replace(/[:.]/g, "-")}.${ext}`,
1213
+ durationMs,
1214
+ waveform: waveformPeaks(samples, 48)
1215
+ });
1216
+ };
1217
+ session.current = { recorder, stream, context, timer, chunks, samples, startedAt, mimeType: mimeType ?? "", discard: false, done };
1218
+ recorder.start(250);
1219
+ setElapsedMs(0);
1220
+ setLevels([]);
1221
+ setState("recording");
1222
+ }, [cleanup, maxMs, stop]);
1223
+ useEffect(() => () => {
1224
+ const s = session.current;
1225
+ if (s) {
1226
+ s.discard = true;
1227
+ try {
1228
+ if (s.recorder.state !== "inactive") s.recorder.stop();
1229
+ } catch {
1230
+ }
1231
+ clearInterval(s.timer);
1232
+ for (const t of s.stream.getTracks()) t.stop();
1233
+ void s.context?.close().catch(() => void 0);
1234
+ session.current = null;
1235
+ }
1236
+ }, []);
1237
+ return {
1238
+ state,
1239
+ recording: state === "recording",
1240
+ elapsedMs,
1241
+ maxMs,
1242
+ /** The latest levels, 0–1, newest last: a live meter. */
1243
+ levels,
1244
+ error,
1245
+ clearError: () => {
1246
+ setError(null);
1247
+ setState((s) => s === "denied" || s === "error" || s === "unsupported" ? "idle" : s);
1248
+ },
1249
+ start,
1250
+ stop,
1251
+ cancel
1252
+ };
1253
+ }
1254
+ var isTouch = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches;
1255
+ var objectUrl = (f) => typeof URL !== "undefined" && typeof URL.createObjectURL === "function" ? URL.createObjectURL(f) : null;
1256
+ var revoke = (u) => {
1257
+ if (u && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(u);
1258
+ };
1259
+ var MAX_HEIGHT = 160;
1260
+ function ChatComposer({
1261
+ conversation: state,
1262
+ replyTo,
1263
+ onCancelReply,
1264
+ resolveUser,
1265
+ placeholder = "Message",
1266
+ accessory,
1267
+ onSent,
1268
+ autoFocus,
1269
+ className,
1270
+ ref
1271
+ }) {
1272
+ const { userId: me } = useChat();
1273
+ const [text, setText] = useState("");
1274
+ const [drafts, setDrafts] = useState([]);
1275
+ const [internal, setInternal] = useState(false);
1276
+ const textarea = useRef(null);
1277
+ const picker = useRef(null);
1278
+ const draftsRef = useRef(drafts);
1279
+ draftsRef.current = drafts;
1280
+ const recorder = useVoiceRecorder({
1281
+ maxMs: recordingLimitMs(state.role),
1282
+ onTick: state.signalRecording,
1283
+ onLimit: (rec) => {
1284
+ void sendVoice(rec);
1285
+ toast.info(`Voice messages stop at ${formatDuration(recordingLimitMs(state.role))}. Sent.`);
1286
+ }
1287
+ });
1288
+ useEffect(() => () => draftsRef.current.forEach((d) => revoke(d.previewUrl)), []);
1289
+ const autosize = useCallback(() => {
1290
+ const el = textarea.current;
1291
+ if (!el) return;
1292
+ el.style.height = "auto";
1293
+ el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT)}px`;
1294
+ }, []);
1295
+ useEffect(autosize, [text, autosize]);
1296
+ useEffect(() => {
1297
+ if (replyTo) textarea.current?.focus();
1298
+ }, [replyTo]);
1299
+ const addFiles = useCallback((list) => {
1300
+ const files = Array.from(list);
1301
+ if (files.length === 0) return;
1302
+ const next = [];
1303
+ let room = CHAT_LIMITS.attachmentsPerMessage - draftsRef.current.length;
1304
+ for (const file of files) {
1305
+ const ext = fileExtension(file.name, file.type);
1306
+ if (!isAllowedExtension(ext)) {
1307
+ toast.error(`${file.name} can't be sent`, { description: "Photos, videos, audio, PDFs and office documents can." });
1308
+ continue;
1309
+ }
1310
+ const type = file.type || "application/octet-stream";
1311
+ const kind = attachmentKindFor(type);
1312
+ const limit = kind === "video" ? CHAT_LIMITS.videoBytes : CHAT_LIMITS.fileBytes;
1313
+ if (file.size > limit) {
1314
+ toast.error(`${file.name} is too big`, { description: `The limit is ${Math.round(limit / 1024 / 1024)} MB.` });
1315
+ continue;
1316
+ }
1317
+ if (room <= 0) {
1318
+ toast.error(`Up to ${CHAT_LIMITS.attachmentsPerMessage} files per message`);
1319
+ break;
1320
+ }
1321
+ room -= 1;
1322
+ next.push({ id: randomId(), file, kind, previewUrl: kind === "image" || kind === "video" ? objectUrl(file) : null });
1323
+ }
1324
+ if (next.length) setDrafts((d) => [...d, ...next]);
1325
+ }, []);
1326
+ const insertText = useCallback((value) => {
1327
+ const el = textarea.current;
1328
+ setText((t) => {
1329
+ if (!el) return t + value;
1330
+ const start = el.selectionStart ?? t.length;
1331
+ const end = el.selectionEnd ?? t.length;
1332
+ return t.slice(0, start) + value + t.slice(end);
1333
+ });
1334
+ el?.focus();
1335
+ }, []);
1336
+ useImperativeHandle(ref, () => ({ addFiles, insertText, focus: () => textarea.current?.focus() }), [addFiles, insertText]);
1337
+ const removeDraft = (id) => setDrafts((d) => {
1338
+ const gone = d.find((x) => x.id === id);
1339
+ revoke(gone?.previewUrl ?? null);
1340
+ return d.filter((x) => x.id !== id);
1341
+ });
1342
+ const common = () => ({
1343
+ replyToId: replyTo?.id ?? null,
1344
+ visibility: internal ? "observers" : "everyone"
1345
+ });
1346
+ const submit = () => {
1347
+ const body = text.trim();
1348
+ if (!body && drafts.length === 0) return;
1349
+ if (body.length > CHAT_LIMITS.bodyChars) {
1350
+ toast.error(`Messages can be up to ${CHAT_LIMITS.bodyChars.toLocaleString()} characters`);
1351
+ return;
1352
+ }
1353
+ const files = drafts.map((d) => ({
1354
+ data: d.file,
1355
+ name: d.file.name,
1356
+ contentType: d.file.type || "application/octet-stream",
1357
+ previewUrl: d.previewUrl ?? void 0,
1358
+ meta: { kind: d.kind, bytes: d.file.size, name: d.file.name }
1359
+ }));
1360
+ void state.send({ body, mentions: parseMentions(body), ...common() }, files);
1361
+ setText("");
1362
+ setDrafts([]);
1363
+ onCancelReply?.();
1364
+ onSent?.();
1365
+ textarea.current?.focus();
1366
+ };
1367
+ async function sendVoice(rec) {
1368
+ if (!rec) return;
1369
+ if (rec.durationMs < 700) {
1370
+ toast.info("Hold on a little longer to record a voice message");
1371
+ return;
1372
+ }
1373
+ await state.send({ body: "", ...common() }, [voiceFile(rec)]);
1374
+ onCancelReply?.();
1375
+ onSent?.();
1376
+ }
1377
+ const onKeyDown = (e) => {
1378
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && !isTouch()) {
1379
+ e.preventDefault();
1380
+ submit();
1381
+ } else if (e.key === "Escape" && replyTo) {
1382
+ onCancelReply?.();
1383
+ }
1384
+ };
1385
+ const onChange = (e) => {
1386
+ setText(e.target.value);
1387
+ if (e.target.value) state.signalTyping();
1388
+ };
1389
+ const onPaste = (e) => {
1390
+ const files = Array.from(e.clipboardData?.files ?? []);
1391
+ if (files.length) {
1392
+ e.preventDefault();
1393
+ addFiles(files);
1394
+ }
1395
+ };
1396
+ if (state.accessLoading) {
1397
+ return /* @__PURE__ */ jsx("div", { className: cn("border-t p-3", className), "aria-busy": true, children: /* @__PURE__ */ jsx(Skeleton, { className: "h-10 w-full rounded-2xl" }) });
1398
+ }
1399
+ if (!state.canPost) {
1400
+ const adminsOnly = state.conversation?.settings?.who_posts === "admins";
1401
+ return /* @__PURE__ */ jsxs("div", { className: cn("border-t p-3 text-center text-sm text-muted-foreground", className), role: "status", "data-testid": "genesis-chat-cannot-post", children: [
1402
+ /* @__PURE__ */ jsx(Lock, { className: "mr-1 inline size-3.5 align-[-2px]", "aria-hidden": true }),
1403
+ adminsOnly ? "Only admins can post here" : "You can't post in this conversation"
1404
+ ] });
1405
+ }
1406
+ const hasContent = text.trim().length > 0 || drafts.length > 0;
1407
+ const replyName = replyTo?.senderId ? replyTo.senderId === me ? "yourself" : resolveUser?.(replyTo.senderId).name ?? "message" : "message";
1408
+ return /* @__PURE__ */ jsxs("div", { className: cn("border-t bg-background", className), "data-testid": "genesis-chat-composer", children: [
1409
+ recorder.error ? /* @__PURE__ */ jsxs(Alert, { variant: "destructive", className: "m-2 w-auto", "data-testid": "genesis-chat-mic-error", children: [
1410
+ /* @__PURE__ */ jsx(Mic, { className: "size-4", "aria-hidden": true }),
1411
+ /* @__PURE__ */ jsxs(AlertDescription, { className: "flex items-start justify-between gap-2", children: [
1412
+ /* @__PURE__ */ jsx("span", { children: recorder.error }),
1413
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "size-6 shrink-0", onClick: recorder.clearError, "aria-label": "Dismiss", children: /* @__PURE__ */ jsx(X, {}) })
1414
+ ] })
1415
+ ] }) : null,
1416
+ replyTo ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 border-b px-3 py-2", "data-testid": "genesis-chat-reply-banner", children: [
1417
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1 border-l-4 border-primary pl-2", children: [
1418
+ /* @__PURE__ */ jsxs("div", { className: "text-xs font-semibold text-primary", children: [
1419
+ "Replying to ",
1420
+ replyName
1421
+ ] }),
1422
+ /* @__PURE__ */ jsx("div", { className: "truncate text-xs text-muted-foreground", children: previewText(replyTo.body, replyTo.attachments[0]?.kind ?? null, Boolean(replyTo.deletedAt)) })
1423
+ ] }),
1424
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "size-7 shrink-0", onClick: onCancelReply, "aria-label": "Cancel reply", children: /* @__PURE__ */ jsx(X, {}) })
1425
+ ] }) : null,
1426
+ drafts.length ? /* @__PURE__ */ jsx("ul", { className: "flex gap-2 overflow-x-auto px-3 pt-3", "aria-label": "Attachments to send", children: drafts.map((d) => /* @__PURE__ */ jsxs("li", { className: "relative shrink-0", children: [
1427
+ d.kind === "image" && d.previewUrl ? /* @__PURE__ */ jsx("img", { src: d.previewUrl, alt: d.file.name, className: "size-16 rounded-lg border object-cover" }) : d.kind === "video" && d.previewUrl ? /* @__PURE__ */ jsx("video", { src: d.previewUrl, muted: true, playsInline: true, preload: "metadata", className: "size-16 rounded-lg border object-cover", "aria-label": d.file.name }) : /* @__PURE__ */ jsxs("span", { className: "flex h-16 w-40 items-center gap-2 rounded-lg border bg-muted px-2 text-xs", children: [
1428
+ /* @__PURE__ */ jsx(FileText, { className: "size-4 shrink-0", "aria-hidden": true }),
1429
+ /* @__PURE__ */ jsx("span", { className: "line-clamp-2 break-all", children: d.file.name })
1430
+ ] }),
1431
+ /* @__PURE__ */ jsx(
1432
+ Button,
1433
+ {
1434
+ type: "button",
1435
+ size: "icon",
1436
+ variant: "secondary",
1437
+ className: "absolute -right-1.5 -top-1.5 size-5 rounded-full border shadow-sm [&_svg]:size-3",
1438
+ onClick: () => removeDraft(d.id),
1439
+ "aria-label": `Remove ${d.file.name}`,
1440
+ children: /* @__PURE__ */ jsx(X, {})
1441
+ }
1442
+ )
1443
+ ] }, d.id)) }) : null,
1444
+ recorder.recording || recorder.state === "requesting" ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 p-2", "data-testid": "genesis-chat-recording", children: [
1445
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "shrink-0 rounded-full text-destructive hover:text-destructive", onClick: recorder.cancel, "aria-label": "Cancel recording", children: /* @__PURE__ */ jsx(Trash2, {}) }),
1446
+ /* @__PURE__ */ jsx("span", { className: "size-2.5 shrink-0 animate-pulse rounded-full bg-destructive", "aria-hidden": true }),
1447
+ /* @__PURE__ */ jsxs("span", { className: "w-24 shrink-0 text-sm tabular-nums", role: "timer", "aria-live": "off", children: [
1448
+ formatDuration(recorder.elapsedMs),
1449
+ /* @__PURE__ */ jsxs("span", { className: "text-muted-foreground", children: [
1450
+ " / ",
1451
+ formatDuration(recorder.maxMs)
1452
+ ] })
1453
+ ] }),
1454
+ /* @__PURE__ */ jsx("span", { className: "flex h-8 flex-1 items-center justify-end gap-[2px] overflow-hidden", "aria-hidden": true, children: recorder.levels.map((l, i) => /* @__PURE__ */ jsx("span", { className: "w-[3px] shrink-0 rounded-full bg-primary", style: { height: `${Math.max(10, Math.min(100, l * 140))}%` } }, i)) }),
1455
+ /* @__PURE__ */ jsx("span", { className: "sr-only", role: "status", children: recorder.state === "requesting" ? "Asking for the microphone" : "Recording a voice message" }),
1456
+ /* @__PURE__ */ jsx(
1457
+ Button,
1458
+ {
1459
+ type: "button",
1460
+ size: "icon",
1461
+ className: "shrink-0 rounded-full",
1462
+ disabled: !recorder.recording,
1463
+ onClick: () => void recorder.stop().then(sendVoice),
1464
+ "aria-label": "Send voice message",
1465
+ "data-testid": "genesis-chat-voice-send",
1466
+ children: /* @__PURE__ */ jsx(SendHorizontal, {})
1467
+ }
1468
+ )
1469
+ ] }) : /* @__PURE__ */ jsxs("div", { className: "flex items-end gap-1 p-2", children: [
1470
+ /* @__PURE__ */ jsx(
1471
+ "input",
1472
+ {
1473
+ ref: picker,
1474
+ type: "file",
1475
+ multiple: true,
1476
+ className: "hidden",
1477
+ tabIndex: -1,
1478
+ "aria-hidden": true,
1479
+ accept: "image/*,video/*,audio/*,.pdf,.txt,.csv,.docx,.xlsx,.pptx,.heic,.heif",
1480
+ onChange: (e) => {
1481
+ if (e.target.files) addFiles(e.target.files);
1482
+ e.target.value = "";
1483
+ },
1484
+ "data-testid": "genesis-chat-file-input"
1485
+ }
1486
+ ),
1487
+ /* @__PURE__ */ jsx(Tooltip, { content: "Attach photos or files", children: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "shrink-0 rounded-full text-muted-foreground", onClick: () => picker.current?.click(), "aria-label": "Attach photos or files", children: /* @__PURE__ */ jsx(Paperclip, {}) }) }),
1488
+ accessory,
1489
+ state.role === "observer" ? /* @__PURE__ */ jsx(Tooltip, { content: internal ? "Internal note: only observers see it" : "Write an internal note", children: /* @__PURE__ */ jsx(
1490
+ Toggle,
1491
+ {
1492
+ pressed: internal,
1493
+ onPressedChange: setInternal,
1494
+ "aria-label": "Internal note",
1495
+ size: "sm",
1496
+ className: "h-10 shrink-0 rounded-full data-[state=on]:bg-warning/20",
1497
+ children: /* @__PURE__ */ jsx(Lock, { className: "size-4" })
1498
+ }
1499
+ ) }) : null,
1500
+ /* @__PURE__ */ jsx(
1501
+ Textarea,
1502
+ {
1503
+ ref: textarea,
1504
+ rows: 1,
1505
+ value: text,
1506
+ onChange,
1507
+ onKeyDown,
1508
+ onPaste,
1509
+ placeholder: internal ? "Internal note" : placeholder,
1510
+ "aria-label": internal ? "Internal note" : placeholder,
1511
+ autoFocus,
1512
+ maxLength: CHAT_LIMITS.bodyChars,
1513
+ enterKeyHint: "send",
1514
+ className: cn(
1515
+ "max-h-40 min-h-10 flex-1 resize-none rounded-2xl py-2 leading-snug",
1516
+ internal && "border-warning/60 bg-warning/10"
1517
+ ),
1518
+ "data-testid": "genesis-chat-input"
1519
+ }
1520
+ ),
1521
+ hasContent ? /* @__PURE__ */ jsx(Button, { type: "button", size: "icon", className: "shrink-0 rounded-full", onClick: submit, "aria-label": "Send", "data-testid": "genesis-chat-send", children: /* @__PURE__ */ jsx(SendHorizontal, {}) }) : /* @__PURE__ */ jsx(Tooltip, { content: "Record a voice message", children: /* @__PURE__ */ jsx(
1522
+ Button,
1523
+ {
1524
+ type: "button",
1525
+ variant: "ghost",
1526
+ size: "icon",
1527
+ className: "shrink-0 rounded-full text-muted-foreground",
1528
+ onClick: () => void recorder.start(),
1529
+ "aria-label": "Record a voice message",
1530
+ "data-testid": "genesis-chat-mic",
1531
+ children: /* @__PURE__ */ jsx(Mic, {})
1532
+ }
1533
+ ) })
1534
+ ] })
1535
+ ] });
1536
+ }
1537
+ var REPORT_REASONS = [
1538
+ { value: "spam", label: "Spam" },
1539
+ { value: "harassment", label: "Harassment or bullying" },
1540
+ { value: "safety", label: "Someone may be at risk" },
1541
+ { value: "other", label: "Something else" }
1542
+ ];
1543
+ var itemKey = (item) => item.type === "message" ? item.message.clientId || item.message.id : item.pending.clientId;
1544
+ function errorText(e) {
1545
+ if (e instanceof ChatError) {
1546
+ if (e.forbidden) return "You can't do that here";
1547
+ if (e.rateLimited) return "Slow down a little and try again";
1548
+ if (e.transient) return "Check your connection and try again";
1549
+ }
1550
+ return e instanceof Error ? e.message : "Something went wrong";
1551
+ }
1552
+ function DefaultHeader({ title, subtitle, otherUserId, resolveUser, onBack, backClassName }) {
1553
+ const other = otherUserId ? resolveUser(otherUserId) : null;
1554
+ return /* @__PURE__ */ jsxs("header", { className: "flex min-h-14 items-center gap-3 border-b px-3 py-2", "data-testid": "genesis-chat-thread-header", children: [
1555
+ onBack ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: cn("-ml-1 shrink-0", backClassName), onClick: onBack, "aria-label": "Back to conversations", children: /* @__PURE__ */ jsx(ArrowLeft, {}) }) : null,
1556
+ /* @__PURE__ */ jsx(UserAvatar, { name: title, src: other?.avatarUrl, size: "sm" }),
1557
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
1558
+ /* @__PURE__ */ jsx("h2", { className: "truncate text-sm font-semibold leading-tight", children: title }),
1559
+ subtitle ? /* @__PURE__ */ jsx("p", { className: "truncate text-xs text-muted-foreground", children: subtitle }) : null
1560
+ ] })
1561
+ ] });
1562
+ }
1563
+ function DefaultEmpty2({ canPost }) {
1564
+ return /* @__PURE__ */ jsx(EmptyState, { icon: MessageSquare, title: "No messages yet", description: canPost ? "Say hello." : void 0 });
1565
+ }
1566
+ function ChatThread({
1567
+ conversationId,
1568
+ resolveUser,
1569
+ title: titleProp,
1570
+ onBack,
1571
+ backClassName,
1572
+ threadRootId,
1573
+ topic,
1574
+ onOpenThread,
1575
+ quickReactions = QUICK_REACTIONS,
1576
+ placeholder,
1577
+ slots,
1578
+ className
1579
+ }) {
1580
+ const { userId: me } = useChat();
1581
+ const state = useConversation(conversationId, { threadRootId, topic });
1582
+ const actions = useMessageActions();
1583
+ const pinIds = usePins(conversationId);
1584
+ const { items, messages, members, role, canPost, conversation, activity } = state;
1585
+ const paths = useMemo(
1586
+ () => messages.flatMap((m) => m.attachments.flatMap((a) => [a.path, a.posterPath])).filter((p) => Boolean(p)),
1587
+ [messages]
1588
+ );
1589
+ const urls = useFileUrls(paths);
1590
+ const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
1591
+ const pins = useMemo(() => new Set(pinIds), [pinIds]);
1592
+ const [replyTo, setReplyTo] = useState(null);
1593
+ const [editingId, setEditingId] = useState(null);
1594
+ const [highlightedId, setHighlightedId] = useState(null);
1595
+ const [confirmDelete, setConfirmDelete] = useState(null);
1596
+ const [reporting, setReporting] = useState(null);
1597
+ const [dragging, setDragging] = useState(false);
1598
+ const [newCount, setNewCount] = useState(0);
1599
+ const [announcement, setAnnouncement] = useState("");
1600
+ const composer = useRef(null);
1601
+ useEffect(() => {
1602
+ setReplyTo(null);
1603
+ setEditingId(null);
1604
+ setNewCount(0);
1605
+ }, [conversationId]);
1606
+ useCallback((id) => id === me ? "You" : resolveUser(id).name, [me, resolveUser]);
1607
+ const others = members.filter((m) => m.userId !== me);
1608
+ const otherUserId = members.length === 2 && others.length === 1 ? others[0].userId : null;
1609
+ const isGroup = members.length > 2 || role === "observer";
1610
+ const title = titleProp ?? conversation?.title ?? (otherUserId ? resolveUser(otherUserId).name : others.length ? others.length <= 3 ? others.map((m) => resolveUser(m.userId).name).join(", ") : `${others.slice(0, 2).map((m) => resolveUser(m.userId).name).join(", ")} and ${others.length - 2} others` : "Conversation");
1611
+ const arrived = useRef(/* @__PURE__ */ new Map());
1612
+ const live = activityLine(
1613
+ activity.filter((a) => a.at > (arrived.current.get(a.userId) ?? 0)),
1614
+ (id) => resolveUser(id).name
1615
+ );
1616
+ const subtitle = members.length > 2 ? `${members.length} members` : null;
1617
+ const scroller = useRef(null);
1618
+ const content = useRef(null);
1619
+ const atBottom = useRef(true);
1620
+ const initialised = useRef(null);
1621
+ const edges = useRef({ first: null, last: null });
1622
+ const anchor = useRef(null);
1623
+ const scrollToBottom = useCallback((smooth = false) => {
1624
+ const el = scroller.current;
1625
+ if (!el) return;
1626
+ if (smooth && typeof el.scrollTo === "function") el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
1627
+ else el.scrollTop = el.scrollHeight;
1628
+ atBottom.current = true;
1629
+ setNewCount(0);
1630
+ }, []);
1631
+ useLayoutEffect(() => {
1632
+ const el = scroller.current;
1633
+ if (!el || items.length === 0) return;
1634
+ const first = itemKey(items[0]);
1635
+ const lastItem = items[items.length - 1];
1636
+ const last = itemKey(lastItem);
1637
+ const prev = edges.current;
1638
+ edges.current = { first, last };
1639
+ if (initialised.current !== conversationId) {
1640
+ initialised.current = conversationId;
1641
+ const unread = state.firstUnreadId ? document.getElementById(`genesis-chat-msg-${state.firstUnreadId}`) : null;
1642
+ if (unread && typeof unread.scrollIntoView === "function") unread.scrollIntoView({ block: "center" });
1643
+ else scrollToBottom();
1644
+ return;
1645
+ }
1646
+ if (anchor.current && first !== prev.first) {
1647
+ el.scrollTop = el.scrollHeight - anchor.current.height + anchor.current.top;
1648
+ anchor.current = null;
1649
+ }
1650
+ if (last !== prev.last) {
1651
+ const mine = itemSender(lastItem) === me;
1652
+ if (atBottom.current || mine) scrollToBottom();
1653
+ else setNewCount((n) => n + 1);
1654
+ if (!mine && lastItem.type === "message" && lastItem.message.kind !== "system") {
1655
+ const m = lastItem.message;
1656
+ if (m.senderId) arrived.current.set(m.senderId, Date.now());
1657
+ setAnnouncement(`${m.senderId ? resolveUser(m.senderId).name : "Someone"}: ${previewText(m.body, m.attachments[0]?.kind ?? null, Boolean(m.deletedAt))}`);
1658
+ }
1659
+ }
1660
+ }, [items, conversationId, me, resolveUser, scrollToBottom, state.firstUnreadId]);
1661
+ const hasItems = items.length > 0;
1662
+ useEffect(() => {
1663
+ const el = content.current;
1664
+ if (!el || typeof ResizeObserver === "undefined") return;
1665
+ const ro = new ResizeObserver(() => {
1666
+ if (atBottom.current && scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight;
1667
+ });
1668
+ ro.observe(el);
1669
+ return () => ro.disconnect();
1670
+ }, [hasItems]);
1671
+ const { hasEarlier, loadingEarlier, loadEarlier } = state;
1672
+ const loadOlder = useCallback(() => {
1673
+ const el = scroller.current;
1674
+ if (!el || !hasEarlier || loadingEarlier) return;
1675
+ anchor.current = { height: el.scrollHeight, top: el.scrollTop };
1676
+ void loadEarlier().catch((e) => toast.error(errorText(e)));
1677
+ }, [hasEarlier, loadingEarlier, loadEarlier]);
1678
+ const onScroll = () => {
1679
+ const el = scroller.current;
1680
+ if (!el) return;
1681
+ const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
1682
+ atBottom.current = bottom;
1683
+ if (bottom && newCount) setNewCount(0);
1684
+ if (el.scrollTop < 200) loadOlder();
1685
+ };
1686
+ const jumpTo = useCallback((id) => {
1687
+ const el = document.getElementById(`genesis-chat-msg-${id}`);
1688
+ if (!el) {
1689
+ toast.info("That message is further up", { description: "Scroll up to load it." });
1690
+ return;
1691
+ }
1692
+ if (typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", behavior: "smooth" });
1693
+ setHighlightedId(id);
1694
+ setTimeout(() => setHighlightedId((h) => h === id ? null : h), 1600);
1695
+ }, []);
1696
+ const run = useCallback(async (p, done) => {
1697
+ try {
1698
+ await p;
1699
+ if (done) toast.success(done);
1700
+ } catch (e) {
1701
+ toast.error(errorText(e));
1702
+ }
1703
+ }, []);
1704
+ const ctx = {
1705
+ me,
1706
+ resolveUser,
1707
+ role,
1708
+ isGroup,
1709
+ canModerate: role === "owner" || role === "admin",
1710
+ canPin: role === "owner" || role === "admin" || members.length === 2,
1711
+ canPost,
1712
+ pins,
1713
+ urls,
1714
+ byId,
1715
+ quickReactions,
1716
+ attachmentSlot: slots?.attachment,
1717
+ highlightedId,
1718
+ editingId,
1719
+ reply: (m) => {
1720
+ setReplyTo(m);
1721
+ composer.current?.focus();
1722
+ },
1723
+ focusComposer: () => composer.current?.focus(),
1724
+ startEdit: setEditingId,
1725
+ saveEdit: async (m, body2) => {
1726
+ try {
1727
+ await actions.edit(m, body2, parseMentions(body2));
1728
+ } catch (e) {
1729
+ toast.error(errorText(e));
1730
+ throw e;
1731
+ }
1732
+ },
1733
+ askDelete: setConfirmDelete,
1734
+ askReport: setReporting,
1735
+ react: (m, emoji, on) => void run(actions.react(m, emoji, on)),
1736
+ pin: (m, on) => void run(actions.pin(m, on), on ? "Pinned" : "Unpinned"),
1737
+ save: (m) => void run(actions.save(m, true), "Saved"),
1738
+ copy: (m) => {
1739
+ const text = plainText(m.body);
1740
+ if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) void run(navigator.clipboard.writeText(text), "Copied");
1741
+ else toast.error("Copying isn't available here");
1742
+ },
1743
+ jumpTo,
1744
+ retry: (clientId) => void state.retry(clientId),
1745
+ discard: (clientId) => void state.discard(clientId),
1746
+ openThread: onOpenThread
1747
+ };
1748
+ const hasFiles = (e) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
1749
+ const dropProps = canPost ? {
1750
+ onDragOver: (e) => {
1751
+ if (!hasFiles(e)) return;
1752
+ e.preventDefault();
1753
+ if (!dragging) setDragging(true);
1754
+ },
1755
+ onDragLeave: (e) => {
1756
+ if (!e.currentTarget.contains(e.relatedTarget)) setDragging(false);
1757
+ },
1758
+ onDrop: (e) => {
1759
+ if (!hasFiles(e)) return;
1760
+ e.preventDefault();
1761
+ setDragging(false);
1762
+ composer.current?.addFiles(e.dataTransfer.files);
1763
+ }
1764
+ } : {};
1765
+ const pinnedMessage = pinIds.length ? byId.get(pinIds[pinIds.length - 1]) : void 0;
1766
+ let body;
1767
+ if (state.isLoading) {
1768
+ body = /* @__PURE__ */ jsx("div", { className: "flex flex-col gap-3 p-4", "aria-busy": true, "aria-label": "Loading messages", "data-testid": "genesis-chat-thread-loading", children: [40, 60, 30, 50].map((w, i) => /* @__PURE__ */ jsx(Skeleton, { className: cn("h-9 rounded-2xl", i % 2 ? "self-end" : "self-start"), style: { width: `${w}%` } }, i)) });
1769
+ } else if (state.error && items.length === 0) {
1770
+ body = /* @__PURE__ */ jsx(
1771
+ EmptyState,
1772
+ {
1773
+ icon: MessageSquare,
1774
+ title: "Couldn't load this conversation",
1775
+ description: errorText(state.error),
1776
+ action: /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => void state.refetch(), children: "Try again" })
1777
+ }
1778
+ );
1779
+ } else if (items.length === 0) {
1780
+ body = renderSlot(DefaultEmpty2, slots?.empty, { conversationId, canPost });
1781
+ } else {
1782
+ body = /* @__PURE__ */ jsxs("div", { ref: content, className: "flex min-h-full flex-col justify-end pb-2 pt-1", children: [
1783
+ hasEarlier ? /* @__PURE__ */ jsx("div", { className: "flex justify-center py-2", children: loadingEarlier ? /* @__PURE__ */ jsx(Spinner, { size: "sm", "aria-label": "Loading earlier messages" }) : /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "sm", className: "h-7 text-xs text-muted-foreground", onClick: loadOlder, children: "Load earlier messages" }) }) : null,
1784
+ items.map((item, i) => {
1785
+ const id = item.type === "message" ? item.message.id : null;
1786
+ return /* @__PURE__ */ jsxs("div", { children: [
1787
+ startsDay(items, i) ? /* @__PURE__ */ jsx("div", { className: "sticky top-1 z-10 flex justify-center py-2", role: "separator", "aria-label": dayLabel(itemTime(item)), children: /* @__PURE__ */ jsx("span", { className: "rounded-full border bg-background/95 px-3 py-0.5 text-xs font-medium text-muted-foreground shadow-sm", children: dayLabel(itemTime(item)) }) }) : null,
1788
+ id && id === state.firstUnreadId ? /* @__PURE__ */ jsxs("div", { className: "my-2 flex items-center gap-2 px-4", role: "separator", "aria-label": "New messages", "data-testid": "genesis-chat-new-line", children: [
1789
+ /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-primary/40" }),
1790
+ /* @__PURE__ */ jsx("span", { className: "text-xs font-semibold text-primary", children: "New" }),
1791
+ /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-primary/40" })
1792
+ ] }) : null,
1793
+ /* @__PURE__ */ jsx(MessageRow, { item, startsGroup: startsGroup(items, i) })
1794
+ ] }, itemKey(item));
1795
+ })
1796
+ ] });
1797
+ }
1798
+ return /* @__PURE__ */ jsxs(ThreadContext.Provider, { value: ctx, children: [
1799
+ /* @__PURE__ */ jsxs(
1800
+ "section",
1801
+ {
1802
+ className: cn("relative flex h-full min-h-0 flex-col bg-background", className),
1803
+ "aria-label": title,
1804
+ "data-testid": "genesis-chat-thread",
1805
+ ...dropProps,
1806
+ children: [
1807
+ renderSlot(DefaultHeader, slots?.header, {
1808
+ conversationId,
1809
+ conversation,
1810
+ title,
1811
+ subtitle,
1812
+ activity: live,
1813
+ otherUserId,
1814
+ members,
1815
+ role,
1816
+ resolveUser,
1817
+ onBack,
1818
+ backClassName
1819
+ }),
1820
+ pinnedMessage ? /* @__PURE__ */ jsxs(
1821
+ "button",
1822
+ {
1823
+ type: "button",
1824
+ onClick: () => jumpTo(pinnedMessage.id),
1825
+ className: "flex items-center gap-2 border-b bg-muted/40 px-3 py-1.5 text-left text-xs hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
1826
+ "aria-label": `Pinned message: ${previewText(pinnedMessage.body, pinnedMessage.attachments[0]?.kind ?? null, Boolean(pinnedMessage.deletedAt))}. Show it`,
1827
+ children: [
1828
+ /* @__PURE__ */ jsx(Pin, { className: "size-3.5 shrink-0 text-primary", "aria-hidden": true }),
1829
+ /* @__PURE__ */ jsx("span", { className: "truncate", children: previewText(pinnedMessage.body, pinnedMessage.attachments[0]?.kind ?? null, Boolean(pinnedMessage.deletedAt)) }),
1830
+ pinIds.length > 1 ? /* @__PURE__ */ jsxs("span", { className: "ml-auto shrink-0 text-muted-foreground", children: [
1831
+ "+",
1832
+ pinIds.length - 1
1833
+ ] }) : null
1834
+ ]
1835
+ }
1836
+ ) : null,
1837
+ /* @__PURE__ */ jsxs("div", { className: "relative min-h-0 flex-1", children: [
1838
+ /* @__PURE__ */ jsx("div", { ref: scroller, onScroll, className: "h-full overflow-y-auto overscroll-contain", role: "log", "aria-live": "off", "aria-label": "Messages", "data-testid": "genesis-chat-scroller", children: body }),
1839
+ newCount > 0 ? /* @__PURE__ */ jsxs(
1840
+ Button,
1841
+ {
1842
+ type: "button",
1843
+ size: "sm",
1844
+ className: "absolute bottom-3 left-1/2 h-8 -translate-x-1/2 rounded-full shadow-md",
1845
+ onClick: () => scrollToBottom(true),
1846
+ "data-testid": "genesis-chat-new-pill",
1847
+ children: [
1848
+ "New messages ",
1849
+ /* @__PURE__ */ jsx(ArrowDown, {})
1850
+ ]
1851
+ }
1852
+ ) : null,
1853
+ dragging ? /* @__PURE__ */ jsxs("div", { className: "pointer-events-none absolute inset-2 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary bg-background/90 text-sm font-medium text-primary", children: [
1854
+ /* @__PURE__ */ jsx(ImagePlus, { className: "size-6", "aria-hidden": true }),
1855
+ "Drop to attach"
1856
+ ] }) : null
1857
+ ] }),
1858
+ /* @__PURE__ */ jsx("div", { className: "h-5 px-4 text-xs italic text-muted-foreground", "data-testid": "genesis-chat-activity", children: live ? /* @__PURE__ */ jsxs("span", { className: "inline-flex items-center gap-1.5", children: [
1859
+ /* @__PURE__ */ jsxs("span", { className: "inline-flex gap-0.5", "aria-hidden": true, children: [
1860
+ /* @__PURE__ */ jsx("span", { className: "size-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.3s]" }),
1861
+ /* @__PURE__ */ jsx("span", { className: "size-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.15s]" }),
1862
+ /* @__PURE__ */ jsx("span", { className: "size-1 animate-bounce rounded-full bg-muted-foreground" })
1863
+ ] }),
1864
+ live
1865
+ ] }) : null }),
1866
+ /* @__PURE__ */ jsx("div", { className: "sr-only", "aria-live": "polite", "aria-atomic": "true", "data-testid": "genesis-chat-announcer", children: announcement }),
1867
+ /* @__PURE__ */ jsx(
1868
+ ChatComposer,
1869
+ {
1870
+ ref: composer,
1871
+ conversation: state,
1872
+ replyTo,
1873
+ onCancelReply: () => setReplyTo(null),
1874
+ resolveUser,
1875
+ placeholder,
1876
+ accessory: renderSlot(EmptyAccessory, slots?.composerAccessory, {
1877
+ conversationId,
1878
+ insertText: (t) => composer.current?.insertText(t),
1879
+ addFiles: (f) => composer.current?.addFiles(f)
1880
+ })
1881
+ }
1882
+ )
1883
+ ]
1884
+ }
1885
+ ),
1886
+ /* @__PURE__ */ jsx(AlertDialog, { open: confirmDelete !== null, onOpenChange: (o) => !o && setConfirmDelete(null), children: /* @__PURE__ */ jsxs(AlertDialogContent, { children: [
1887
+ /* @__PURE__ */ jsxs(AlertDialogHeader, { children: [
1888
+ /* @__PURE__ */ jsx(AlertDialogTitle, { children: "Delete this message?" }),
1889
+ /* @__PURE__ */ jsx(AlertDialogDescription, { children: confirmDelete?.senderId === me ? "It's deleted for everyone in the conversation." : "It's deleted for everyone in the conversation, including the person who sent it." })
1890
+ ] }),
1891
+ /* @__PURE__ */ jsxs(AlertDialogFooter, { children: [
1892
+ /* @__PURE__ */ jsx(AlertDialogCancel, { children: "Cancel" }),
1893
+ /* @__PURE__ */ jsx(
1894
+ AlertDialogAction,
1895
+ {
1896
+ className: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
1897
+ onClick: () => {
1898
+ const m = confirmDelete;
1899
+ setConfirmDelete(null);
1900
+ if (m) void run(actions.remove(m));
1901
+ },
1902
+ "data-testid": "genesis-chat-confirm-delete",
1903
+ children: "Delete"
1904
+ }
1905
+ )
1906
+ ] })
1907
+ ] }) }),
1908
+ /* @__PURE__ */ jsx(
1909
+ ReportDialog,
1910
+ {
1911
+ message: reporting,
1912
+ onClose: () => setReporting(null),
1913
+ onSubmit: async (m, reason, note) => {
1914
+ await run(actions.report(m, reason, note || void 0), "Reported. Thank you.");
1915
+ setReporting(null);
1916
+ }
1917
+ }
1918
+ )
1919
+ ] });
1920
+ }
1921
+ function EmptyAccessory(_props) {
1922
+ return null;
1923
+ }
1924
+ function ReportDialog({
1925
+ message,
1926
+ onClose,
1927
+ onSubmit
1928
+ }) {
1929
+ const [reason, setReason] = useState("spam");
1930
+ const [note, setNote] = useState("");
1931
+ const [busy, setBusy] = useState(false);
1932
+ useEffect(() => {
1933
+ if (message) {
1934
+ setReason("spam");
1935
+ setNote("");
1936
+ }
1937
+ }, [message]);
1938
+ return /* @__PURE__ */ jsx(Dialog, { open: message !== null, onOpenChange: (o) => !o && onClose(), children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-md", children: [
1939
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [
1940
+ /* @__PURE__ */ jsx(DialogTitle, { children: "Report this message" }),
1941
+ /* @__PURE__ */ jsx(DialogDescription, { children: "The people who look after this conversation see your report and the messages around it." })
1942
+ ] }),
1943
+ /* @__PURE__ */ jsx(RadioGroup, { value: reason, onValueChange: (v) => setReason(v), "aria-label": "Reason", children: REPORT_REASONS.map((r) => /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
1944
+ /* @__PURE__ */ jsx(RadioGroupItem, { value: r.value, id: `genesis-chat-report-${r.value}` }),
1945
+ /* @__PURE__ */ jsx(Label, { htmlFor: `genesis-chat-report-${r.value}`, children: r.label })
1946
+ ] }, r.value)) }),
1947
+ /* @__PURE__ */ jsx(Textarea, { value: note, onChange: (e) => setNote(e.target.value), placeholder: "Anything else? (optional)", "aria-label": "Details", maxLength: 1e3 }),
1948
+ /* @__PURE__ */ jsxs(DialogFooter, { children: [
1949
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: onClose, children: "Cancel" }),
1950
+ /* @__PURE__ */ jsx(
1951
+ Button,
1952
+ {
1953
+ type: "button",
1954
+ variant: "destructive",
1955
+ loading: busy,
1956
+ onClick: async () => {
1957
+ if (!message) return;
1958
+ setBusy(true);
1959
+ try {
1960
+ await onSubmit(message, reason, note.trim());
1961
+ } finally {
1962
+ setBusy(false);
1963
+ }
1964
+ },
1965
+ children: "Report"
1966
+ }
1967
+ )
1968
+ ] })
1969
+ ] }) });
1970
+ }
1971
+ function NoSelection({ hint }) {
1972
+ return /* @__PURE__ */ jsx("div", { className: "flex h-full items-center justify-center", children: /* @__PURE__ */ jsx(EmptyState, { icon: MessageSquare, title: "Choose a conversation", description: hint ?? "Pick one on the left to read and reply." }) });
1973
+ }
1974
+ function ChatPanel({
1975
+ resolveUser,
1976
+ selectedId: controlled,
1977
+ defaultSelectedId = null,
1978
+ onSelectedChange,
1979
+ inboxHeader,
1980
+ inboxProps,
1981
+ threadProps,
1982
+ slots,
1983
+ className
1984
+ }) {
1985
+ const [own, setOwn] = useState(defaultSelectedId);
1986
+ const selected = controlled !== void 0 ? controlled : own;
1987
+ const select = (id) => {
1988
+ if (controlled === void 0) setOwn(id);
1989
+ onSelectedChange?.(id);
1990
+ };
1991
+ return /* @__PURE__ */ jsx(
1992
+ Split,
1993
+ {
1994
+ ratio: "1/3",
1995
+ at: "md",
1996
+ gap: "none",
1997
+ testID: "genesis-chat-panel",
1998
+ className: cn(
1999
+ "h-full min-h-0 overflow-hidden rounded-lg border bg-background",
2000
+ "[&>[data-slot=split-start]]:min-h-0 [&>[data-slot=split-end]]:min-h-0 max-md:[&>[data-slot]]:flex-1",
2001
+ selected ? "max-md:[&>[data-slot=split-start]]:hidden" : "max-md:[&>[data-slot=split-end]]:hidden",
2002
+ className
2003
+ ),
2004
+ start: /* @__PURE__ */ jsxs("div", { className: "flex h-full min-h-0 flex-col md:border-r", children: [
2005
+ inboxHeader ? /* @__PURE__ */ jsx("div", { className: "shrink-0 border-b px-3 py-2", children: inboxHeader }) : null,
2006
+ /* @__PURE__ */ jsx(ChatInbox, { ...inboxProps, resolveUser, selectedId: selected, onSelect: select, className: cn("min-h-0 flex-1 overflow-y-auto", inboxProps?.className) })
2007
+ ] }),
2008
+ end: selected ? /* @__PURE__ */ jsx(ChatThread, { ...threadProps, conversationId: selected, resolveUser, onBack: () => select(null), backClassName: "md:hidden" }, selected) : renderSlot(NoSelection, slots?.noSelection, {})
2009
+ }
2010
+ );
2011
+ }
2012
+
2013
+ // src/web/memory-client.ts
2014
+ var counter = 0;
2015
+ var uuid = () => typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `00000000-0000-4000-8000-${String(++counter).padStart(12, "0")}`;
2016
+ function attachment(messageId, a, i) {
2017
+ return {
2018
+ id: a.id ?? uuid(),
2019
+ messageId,
2020
+ position: a.position ?? i,
2021
+ kind: a.kind ?? "file",
2022
+ ref: a.ref ?? {},
2023
+ provider: a.provider ?? "storage",
2024
+ path: a.path ?? null,
2025
+ providerId: a.providerId ?? null,
2026
+ playbackId: a.playbackId ?? null,
2027
+ url: a.url ?? null,
2028
+ status: a.status ?? "ready",
2029
+ name: a.name ?? null,
2030
+ mime: a.mime ?? null,
2031
+ bytes: a.bytes ?? null,
2032
+ width: a.width ?? null,
2033
+ height: a.height ?? null,
2034
+ durationMs: a.durationMs ?? null,
2035
+ waveform: a.waveform ?? null,
2036
+ posterPath: a.posterPath ?? null,
2037
+ thumbhash: a.thumbhash ?? null,
2038
+ transcript: a.transcript ?? null,
2039
+ transcriptStatus: a.transcriptStatus ?? null
2040
+ };
2041
+ }
2042
+ function createMemoryChatClient(options) {
2043
+ const { me } = options;
2044
+ const files = { ...options.files ?? {} };
2045
+ const convs = /* @__PURE__ */ new Map();
2046
+ const convListeners = /* @__PURE__ */ new Map();
2047
+ const inboxListeners = /* @__PURE__ */ new Set();
2048
+ const calls = [];
2049
+ let failing = 0;
2050
+ let held = null;
2051
+ const wait = () => new Promise((r) => setTimeout(r, options.latencyMs ?? 0));
2052
+ const makeMessage = (c, m) => {
2053
+ const id = m.id ?? uuid();
2054
+ const seq = ++c.conversation.lastSeq;
2055
+ const createdAt = m.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
2056
+ c.conversation.lastMessageAt = createdAt;
2057
+ return {
2058
+ id,
2059
+ conversationId: c.conversation.id,
2060
+ seq,
2061
+ clientId: m.clientId ?? id,
2062
+ senderId: m.senderId ?? null,
2063
+ senderRole: m.senderRole ?? (m.kind === "system" ? "system" : "member"),
2064
+ kind: m.kind ?? "text",
2065
+ body: m.body ?? "",
2066
+ mentions: m.mentions ?? [],
2067
+ replyToId: m.replyToId ?? null,
2068
+ threadRootId: m.threadRootId ?? null,
2069
+ threadCount: m.threadCount ?? 0,
2070
+ threadLastAt: m.threadLastAt ?? null,
2071
+ topic: m.topic ?? null,
2072
+ visibility: m.visibility ?? "everyone",
2073
+ silent: m.silent ?? false,
2074
+ meta: m.meta ?? {},
2075
+ createdAt,
2076
+ editedAt: m.editedAt ?? null,
2077
+ deletedAt: m.deletedAt ?? null,
2078
+ attachments: (m.attachments ?? []).map((a, i) => attachment(id, a, i)),
2079
+ reactions: m.reactions ?? []
2080
+ };
2081
+ };
2082
+ for (const seed of options.conversations) {
2083
+ const c = {
2084
+ conversation: {
2085
+ id: seed.id,
2086
+ kind: seed.kind ?? (seed.members.length === 2 ? "direct" : "group"),
2087
+ title: seed.title ?? null,
2088
+ subjectType: null,
2089
+ subjectId: null,
2090
+ settings: seed.settings ?? {},
2091
+ status: null,
2092
+ assigneeId: null,
2093
+ lastSeq: 0,
2094
+ lastMessageAt: null,
2095
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2096
+ archivedAt: null
2097
+ },
2098
+ members: seed.members.map((m) => ({
2099
+ conversationId: seed.id,
2100
+ userId: m.userId,
2101
+ role: m.role ?? "member",
2102
+ lastReadSeq: m.lastReadSeq ?? 0,
2103
+ joinedAt: (/* @__PURE__ */ new Date()).toISOString()
2104
+ })),
2105
+ myRole: seed.myRole !== void 0 ? seed.myRole : seed.members.find((m) => m.userId === me)?.role ?? "member",
2106
+ canPost: seed.canPost ?? true,
2107
+ muted: seed.muted ?? false,
2108
+ pinned: seed.pinned ?? false,
2109
+ messages: [],
2110
+ pins: seed.pins ?? []
2111
+ };
2112
+ for (const m of seed.messages ?? []) c.messages.push(makeMessage(c, m));
2113
+ convs.set(seed.id, c);
2114
+ }
2115
+ const get = (id) => {
2116
+ const c = convs.get(id);
2117
+ if (!c) throw new ChatError("Conversation not found", "42501", "conversation");
2118
+ return c;
2119
+ };
2120
+ const findMessage = (id) => {
2121
+ for (const c of convs.values()) {
2122
+ const m = c.messages.find((x) => x.id === id);
2123
+ if (m) return { c, m };
2124
+ }
2125
+ throw new ChatError("Message not found", "42501", "message");
2126
+ };
2127
+ const visible = (c, m) => m.visibility !== "observers" || c.myRole === "observer";
2128
+ const clone = (m) => ({ ...m, attachments: m.attachments.map((a) => ({ ...a })), reactions: [...m.reactions] });
2129
+ const emit = (conversationId, s) => {
2130
+ for (const l of convListeners.get(conversationId) ?? []) l.onSignal(s);
2131
+ for (const l of inboxListeners) l({ type: "inbox", conversationId });
2132
+ };
2133
+ const log = (method, ...args) => calls.push({ method, args });
2134
+ function inboxItem(c) {
2135
+ const mine = c.members.find((m) => m.userId === me);
2136
+ const lastRead = mine?.lastReadSeq ?? 0;
2137
+ const seen = c.messages.filter((m) => visible(c, m));
2138
+ const last = seen[seen.length - 1];
2139
+ const others = c.members.filter((m) => m.userId !== me);
2140
+ const unreadList = seen.filter((m) => m.seq > lastRead && m.senderId !== me && !m.deletedAt);
2141
+ return {
2142
+ conversationId: c.conversation.id,
2143
+ kind: c.conversation.kind,
2144
+ title: c.conversation.title,
2145
+ subjectType: null,
2146
+ subjectId: null,
2147
+ status: null,
2148
+ role: c.myRole ?? "member",
2149
+ otherUserId: c.members.length === 2 && others.length === 1 ? others[0].userId : null,
2150
+ memberCount: c.members.length,
2151
+ lastSeq: c.conversation.lastSeq,
2152
+ lastReadSeq: lastRead,
2153
+ unread: Math.min(99, unreadList.length),
2154
+ mentioned: unreadList.some((m) => m.mentions.includes(me)),
2155
+ muted: c.muted,
2156
+ pinned: c.pinned,
2157
+ archived: false,
2158
+ lastMessageAt: c.conversation.lastMessageAt,
2159
+ lastMessage: last ? {
2160
+ id: last.id,
2161
+ seq: last.seq,
2162
+ senderId: last.senderId,
2163
+ kind: last.kind,
2164
+ body: last.body,
2165
+ deleted: Boolean(last.deletedAt),
2166
+ createdAt: last.createdAt,
2167
+ attachment: last.attachments[0]?.kind ?? null
2168
+ } : null
2169
+ };
2170
+ }
2171
+ const client = {
2172
+ supabase: null,
2173
+ calls,
2174
+ async inbox(opts = {}) {
2175
+ log("inbox", opts);
2176
+ await wait();
2177
+ if (opts.archived) return [];
2178
+ return [...convs.values()].filter((c) => !opts.kind || c.conversation.kind === opts.kind).map(inboxItem).sort((a, b) => Number(b.pinned) - Number(a.pinned) || (b.lastMessageAt ?? "").localeCompare(a.lastMessageAt ?? ""));
2179
+ },
2180
+ async observed() {
2181
+ return [];
2182
+ },
2183
+ async unreadTotal() {
2184
+ return [...convs.values()].reduce((n, c) => n + inboxItem(c).unread, 0);
2185
+ },
2186
+ async conversation(id) {
2187
+ await wait();
2188
+ return convs.has(id) ? { ...get(id).conversation } : null;
2189
+ },
2190
+ async kind(kind) {
2191
+ return { kind, label: kind, maxMembers: kind === "direct" ? 2 : null, whoPosts: "members", membership: "admins", threads: true, observersPost: false };
2192
+ },
2193
+ async myRole(id) {
2194
+ return convs.get(id)?.myRole ?? null;
2195
+ },
2196
+ async canPost(id) {
2197
+ return convs.get(id)?.canPost ?? false;
2198
+ },
2199
+ async members(id) {
2200
+ return get(id).members.map((m) => ({ ...m }));
2201
+ },
2202
+ async messages(id, opts = {}) {
2203
+ log("messages", id, opts);
2204
+ await wait();
2205
+ const c = get(id);
2206
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
2207
+ let list = c.messages.filter((m) => visible(c, m) && (opts.threadRootId ? m.threadRootId === opts.threadRootId : m.threadRootId === null));
2208
+ if (opts.topic) list = list.filter((m) => m.topic === opts.topic);
2209
+ if (opts.afterSeq != null) return { messages: list.filter((m) => m.seq > opts.afterSeq).slice(0, limit).map(clone), hasEarlier: false };
2210
+ if (opts.beforeSeq != null) list = list.filter((m) => m.seq < opts.beforeSeq);
2211
+ const page = list.slice(-limit);
2212
+ return { messages: page.map(clone), hasEarlier: list.length > page.length };
2213
+ },
2214
+ async message(id) {
2215
+ try {
2216
+ const { c, m } = findMessage(id);
2217
+ return visible(c, m) ? clone(m) : null;
2218
+ } catch {
2219
+ return null;
2220
+ }
2221
+ },
2222
+ async startConversation(kind, memberIds, opts = {}) {
2223
+ const id = uuid();
2224
+ convs.set(id, {
2225
+ conversation: {
2226
+ id,
2227
+ kind,
2228
+ title: opts.title ?? null,
2229
+ subjectType: null,
2230
+ subjectId: null,
2231
+ settings: {},
2232
+ status: null,
2233
+ assigneeId: null,
2234
+ lastSeq: 0,
2235
+ lastMessageAt: null,
2236
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
2237
+ archivedAt: null
2238
+ },
2239
+ members: [me, ...memberIds.filter((m) => m !== me)].map((userId) => ({ conversationId: id, userId, role: userId === me ? "owner" : "member", lastReadSeq: 0, joinedAt: (/* @__PURE__ */ new Date()).toISOString() })),
2240
+ myRole: "owner",
2241
+ canPost: true,
2242
+ muted: false,
2243
+ pinned: false,
2244
+ messages: [],
2245
+ pins: []
2246
+ });
2247
+ for (const l of inboxListeners) l({ type: "inbox", conversationId: id });
2248
+ return id;
2249
+ },
2250
+ async send(input) {
2251
+ log("send", input);
2252
+ await wait();
2253
+ if (held) {
2254
+ const hold = held;
2255
+ held = null;
2256
+ await hold;
2257
+ }
2258
+ if (failing > 0) {
2259
+ failing -= 1;
2260
+ throw new ChatError("Failed to fetch", void 0, "send_message");
2261
+ }
2262
+ const c = get(input.conversationId);
2263
+ if (!c.canPost) throw new ChatError("You can't post here", "42501", "send_message");
2264
+ const existing = c.messages.find((m2) => m2.clientId === input.clientId);
2265
+ if (existing) return clone(existing);
2266
+ const m = makeMessage(c, {
2267
+ clientId: input.clientId,
2268
+ senderId: me,
2269
+ senderRole: c.myRole ?? "member",
2270
+ body: input.body ?? "",
2271
+ mentions: input.mentions ?? [],
2272
+ replyToId: input.replyToId ?? null,
2273
+ threadRootId: input.threadRootId ?? null,
2274
+ topic: input.topic ?? null,
2275
+ visibility: input.visibility ?? "everyone",
2276
+ silent: input.silent ?? false,
2277
+ attachments: (input.attachments ?? []).map((a) => ({ ...a, durationMs: a.durationMs ?? null, transcriptStatus: a.kind === "voice" ? "pending" : null }))
2278
+ });
2279
+ c.messages.push(m);
2280
+ const mine = c.members.find((x) => x.userId === me);
2281
+ if (mine) mine.lastReadSeq = m.seq;
2282
+ emit(c.conversation.id, { type: "message", conversationId: c.conversation.id, id: m.id, seq: m.seq, op: "insert" });
2283
+ return clone(m);
2284
+ },
2285
+ async edit(messageId, body, mentions = []) {
2286
+ log("edit", messageId, body);
2287
+ const { c, m } = findMessage(messageId);
2288
+ if (m.senderId !== me) throw new ChatError("You can't edit this message", "42501", "edit_message");
2289
+ Object.assign(m, { body, mentions, editedAt: (/* @__PURE__ */ new Date()).toISOString() });
2290
+ emit(c.conversation.id, { type: "message", conversationId: c.conversation.id, id: m.id, op: "update" });
2291
+ return clone(m);
2292
+ },
2293
+ async remove(messageId) {
2294
+ log("remove", messageId);
2295
+ const { c, m } = findMessage(messageId);
2296
+ Object.assign(m, { body: "", attachments: [], reactions: [], deletedAt: (/* @__PURE__ */ new Date()).toISOString() });
2297
+ emit(c.conversation.id, { type: "message", conversationId: c.conversation.id, id: m.id, op: "update" });
2298
+ },
2299
+ async react(messageId, emoji, on = true) {
2300
+ log("react", messageId, emoji, on);
2301
+ const { c, m } = findMessage(messageId);
2302
+ m.reactions = m.reactions.filter((r) => !(r.emoji === emoji && r.userId === me));
2303
+ if (on) m.reactions.push({ emoji, userId: me });
2304
+ emit(c.conversation.id, { type: "reaction", conversationId: c.conversation.id, messageId });
2305
+ },
2306
+ async markRead(conversationId, seq) {
2307
+ const c = get(conversationId);
2308
+ const mine = c.members.find((x) => x.userId === me);
2309
+ if (mine) mine.lastReadSeq = Math.max(mine.lastReadSeq, seq ?? c.conversation.lastSeq);
2310
+ return mine?.lastReadSeq ?? 0;
2311
+ },
2312
+ async setMySettings(conversationId, s) {
2313
+ const c = get(conversationId);
2314
+ if (s.mutedUntil !== void 0) c.muted = s.mutedUntil !== null;
2315
+ if (s.pinned !== void 0) c.pinned = s.pinned;
2316
+ },
2317
+ async updateConversation(conversationId, s) {
2318
+ const c = get(conversationId);
2319
+ if (s.title !== void 0) c.conversation.title = s.title;
2320
+ if (s.settings) c.conversation.settings = { ...c.conversation.settings, ...s.settings };
2321
+ },
2322
+ async addMembers(conversationId, userIds) {
2323
+ const c = get(conversationId);
2324
+ for (const userId of userIds) if (!c.members.some((m) => m.userId === userId)) c.members.push({ conversationId, userId, role: "member", lastReadSeq: 0, joinedAt: (/* @__PURE__ */ new Date()).toISOString() });
2325
+ },
2326
+ async removeMember(conversationId, userId) {
2327
+ const c = get(conversationId);
2328
+ c.members = c.members.filter((m) => m.userId !== (userId ?? me));
2329
+ },
2330
+ async setStatus(conversationId, status, assigneeId) {
2331
+ const c = get(conversationId);
2332
+ c.conversation.status = status;
2333
+ c.conversation.assigneeId = assigneeId ?? null;
2334
+ },
2335
+ async pin(messageId, on = true) {
2336
+ log("pin", messageId, on);
2337
+ const { c } = findMessage(messageId);
2338
+ c.pins = c.pins.filter((p) => p !== messageId);
2339
+ if (on) c.pins.push(messageId);
2340
+ emit(c.conversation.id, { type: "conversation", conversationId: c.conversation.id });
2341
+ },
2342
+ async pins(conversationId) {
2343
+ return [...convs.get(conversationId)?.pins ?? []];
2344
+ },
2345
+ async save(messageId, on = true, note) {
2346
+ log("save", messageId, on, note);
2347
+ },
2348
+ async report(messageId, reason, note) {
2349
+ log("report", messageId, reason, note);
2350
+ return uuid();
2351
+ },
2352
+ async upload(conversationId, userId, file, opts) {
2353
+ log("upload", conversationId, opts.name);
2354
+ await wait();
2355
+ const path = `${conversationId}/${userId}/${opts.id ?? uuid()}-${opts.name}`;
2356
+ const blob = file instanceof Blob ? file : new Blob([file], { type: opts.contentType });
2357
+ files[path] = typeof URL !== "undefined" && typeof URL.createObjectURL === "function" ? URL.createObjectURL(blob) : `memory://${path}`;
2358
+ return path;
2359
+ },
2360
+ async fileUrls(paths) {
2361
+ const out = {};
2362
+ for (const p of paths) if (files[p]) out[p] = files[p];
2363
+ return out;
2364
+ },
2365
+ subscribeInbox(_userId, onSignal) {
2366
+ inboxListeners.add(onSignal);
2367
+ return { unsubscribe: () => void inboxListeners.delete(onSignal) };
2368
+ },
2369
+ subscribeConversation(conversationId, _me, handlers) {
2370
+ const set = convListeners.get(conversationId) ?? /* @__PURE__ */ new Set();
2371
+ convListeners.set(conversationId, set);
2372
+ const entry = { onSignal: handlers.onSignal, onActivity: handlers.onActivity };
2373
+ set.add(entry);
2374
+ return {
2375
+ signal(activity) {
2376
+ log("signal", conversationId, activity);
2377
+ },
2378
+ unsubscribe: () => void set.delete(entry)
2379
+ };
2380
+ },
2381
+ receive(conversationId, senderId, body, extra = {}) {
2382
+ const c = get(conversationId);
2383
+ const m = makeMessage(c, { senderId, body, ...extra });
2384
+ c.messages.push(m);
2385
+ emit(conversationId, { type: "message", conversationId, id: m.id, seq: m.seq, op: "insert" });
2386
+ return clone(m);
2387
+ },
2388
+ activity(conversationId, userId, activity) {
2389
+ for (const l of convListeners.get(conversationId) ?? []) l.onActivity?.({ userId, activity, at: Date.now() });
2390
+ },
2391
+ failNextSends(count = 1) {
2392
+ failing = count;
2393
+ },
2394
+ holdNextSend() {
2395
+ let release;
2396
+ held = new Promise((r) => release = r);
2397
+ return () => release();
2398
+ }
2399
+ };
2400
+ return client;
2401
+ }
2402
+
2403
+ export { ChatComposer, ChatInbox, ChatMarkdown, ChatPanel, ChatThread, FileChip, GenericAttachment, ImageGrid, Lightbox, MessageAttachments, MessageRow, QUICK_REACTIONS, VideoAttachment, VoiceNote, clockTime, createMemoryChatClient, formatBytes, inboxTitle, isSafeHref, messageLabel, parseMarkdown, pickVoiceMimeType, plainText, relativeTime, toAttachmentView, useVoiceRecorder, voiceFile };
2404
+ //# sourceMappingURL=web.js.map
2405
+ //# sourceMappingURL=web.js.map