@marktiderman/genesis-chat 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. package/dist/chunk-SAY24E6V.js.map +0 -1
package/dist/web.js CHANGED
@@ -1,11 +1,13 @@
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';
1
+ import { snippetParts, isHttpsUrl, scheduleQuickPicks, isSchedulable, scheduleLabel, extractLinks, topicKey } from './chunk-BJCE5O7V.js';
2
+ import { useChat, useInbox, useSearch, useScheduled, useAvailability, useTopics, useConversation, useMessageActions, usePins, useFileUrls, useLinkPreviews, useBroadcast } from './chunk-PZTOY2LY.js';
3
+ import { previewText, summarizeReactions, activityLine, itemSender, startsDay, dayLabel, itemTime, startsGroup, parseMentions } from './chunk-5D5A7I4Q.js';
4
+ import { fileExtension, formatDuration, nextPlaybackRate, awaySummary, awayLine, WEEKDAYS, CHAT_LIMITS, waveformPeaks, recordingLimitMs, isAllowedExtension, attachmentKindFor, randomId, ChatError } from './chunk-7S6HYNWW.js';
5
+ import { createContext, useRef, useState, Fragment, Component, useCallback, useEffect, useMemo, useImperativeHandle, useLayoutEffect, useContext } from 'react';
6
+ import { MessageSquare, Pin, BellOff, AtSign, Search, X, Mic, ClipboardList, ExternalLink, VideoOff, Video, RotateCcw, SendHorizontal, Square, Circle, Pause, Play, Paperclip, ChevronLeft, ChevronRight, FileText, Download, Lock, Ban, EyeOff, ShieldAlert, AlertCircle, Clock, Reply, Copy, Pencil, PinOff, Bookmark, Flag, Trash2, SmilePlus, Plus, MoreHorizontal, ChevronUp, CalendarClock, Moon, RefreshCw, Smile, Hash, LockOpen, ArrowDown, ImagePlus, Megaphone, Link2, ArrowLeft } from 'lucide-react';
7
+ import { Skeleton, cn, EmptyState, Button, UserAvatar, Badge, Input, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Alert, AlertDescription, Spinner, Collapsible, CollapsibleTrigger, CollapsibleContent, VisuallyHidden, ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuSeparator, ContextMenuItem, Tooltip, Textarea, Popover, PopoverTrigger, PopoverContent, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuSeparator, DropdownMenuItem, DropdownMenuLabel, Label, DialogFooter, toast, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Toggle, ToggleGroup, ToggleGroupItem, Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction, RadioGroup, RadioGroupItem, Split, Checkbox } from '@marktiderman/genesis-ui';
7
8
  import { renderSlot } from '@marktiderman/genesis-core/slots';
8
9
  import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
10
+ import { EmojiPicker } from 'frimousse';
9
11
 
10
12
  // src/web/format.ts
11
13
  function clockTime(iso, locale) {
@@ -145,6 +147,80 @@ function InboxRow({ item, me, resolveUser, fallbackTitle, selected, onSelect })
145
147
  }
146
148
  );
147
149
  }
150
+ function SearchSnippet({ snippet, className }) {
151
+ return /* @__PURE__ */ jsx("span", { className, children: snippetParts(snippet).map(
152
+ (p, i) => p.match ? /* @__PURE__ */ jsx("mark", { className: "rounded-sm bg-warning/30 px-0.5 text-foreground", children: p.text }, i) : /* @__PURE__ */ jsx("span", { children: p.text }, i)
153
+ ) });
154
+ }
155
+ function ChatSearch({ resolveUser, conversationId, onOpen, conversationTitle, autoFocus, debounceMs, className }) {
156
+ const [value, setValue] = useState("");
157
+ const { hits, query, isSearching } = useSearch(value, { conversationId, debounceMs });
158
+ const list = useRef(null);
159
+ const onKeyDown = (e) => {
160
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
161
+ const buttons = Array.from(list.current?.querySelectorAll("button[data-search-hit]") ?? []);
162
+ if (!buttons.length) return;
163
+ e.preventDefault();
164
+ const at = buttons.indexOf(document.activeElement);
165
+ const to = e.key === "ArrowDown" ? Math.min(buttons.length - 1, at + 1) : at <= 0 ? -1 : at - 1;
166
+ if (to < 0) e.currentTarget.querySelector("input")?.focus();
167
+ else buttons[to]?.focus();
168
+ };
169
+ const short = value.trim().length < 2;
170
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex min-h-0 flex-col", className), onKeyDown, "data-testid": "genesis-chat-search", role: "search", children: [
171
+ /* @__PURE__ */ jsxs("div", { className: "relative p-2", children: [
172
+ /* @__PURE__ */ jsx(Search, { className: "pointer-events-none absolute left-4.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground", "aria-hidden": true }),
173
+ /* @__PURE__ */ jsx(
174
+ Input,
175
+ {
176
+ type: "search",
177
+ value,
178
+ onChange: (e) => setValue(e.target.value),
179
+ placeholder: conversationId ? "Search this conversation" : "Search messages",
180
+ "aria-label": conversationId ? "Search this conversation" : "Search messages",
181
+ autoFocus,
182
+ className: "pl-8 pr-8",
183
+ "data-testid": "genesis-chat-search-input"
184
+ }
185
+ ),
186
+ value ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "absolute right-3 top-1/2 size-6 -translate-y-1/2", onClick: () => setValue(""), "aria-label": "Clear search", children: /* @__PURE__ */ jsx(X, {}) }) : null
187
+ ] }),
188
+ /* @__PURE__ */ jsx("div", { className: "sr-only", "aria-live": "polite", children: short || isSearching ? "" : hits.length === 0 ? "No results" : hits.length === 1 ? "1 result" : `${hits.length} results` }),
189
+ short ? null : isSearching && hits.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2 p-3", "aria-busy": true, "aria-label": "Searching", children: [
190
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-12 w-full" }),
191
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-12 w-full" })
192
+ ] }) : hits.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { icon: Search, title: "No results", description: `Nothing matches \u201C${query}\u201D.` }) : /* @__PURE__ */ jsx("ul", { ref: list, className: "min-h-0 flex-1 overflow-y-auto", "aria-label": "Results", children: hits.map((h) => {
193
+ const who = h.senderId ? resolveUser(h.senderId).name : "Someone";
194
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
195
+ "button",
196
+ {
197
+ type: "button",
198
+ "data-search-hit": true,
199
+ onClick: () => onOpen(h),
200
+ className: "flex w-full flex-col gap-0.5 px-3 py-2 text-left hover:bg-accent focus-visible:bg-accent focus-visible:outline-none",
201
+ "data-testid": "genesis-chat-search-hit",
202
+ children: [
203
+ /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
204
+ /* @__PURE__ */ jsx("span", { className: "truncate font-medium text-foreground", children: who }),
205
+ !conversationId && conversationTitle ? /* @__PURE__ */ jsxs("span", { className: "truncate", children: [
206
+ "in ",
207
+ conversationTitle(h.conversationId)
208
+ ] }) : null,
209
+ /* @__PURE__ */ jsx("span", { className: "ml-auto shrink-0", children: relativeTime(h.createdAt) })
210
+ ] }),
211
+ /* @__PURE__ */ jsxs("span", { className: "flex items-start gap-1.5 text-sm", children: [
212
+ h.inTranscript ? /* @__PURE__ */ jsxs(Badge, { variant: "secondary", className: "mt-0.5 shrink-0 gap-1 px-1.5 py-0 text-[10px]", "data-testid": "genesis-chat-search-transcript", children: [
213
+ /* @__PURE__ */ jsx(Mic, { className: "size-3", "aria-hidden": true }),
214
+ " Transcript"
215
+ ] }) : null,
216
+ /* @__PURE__ */ jsx(SearchSnippet, { snippet: h.snippet, className: "line-clamp-2" })
217
+ ] })
218
+ ]
219
+ }
220
+ ) }, `${h.messageId}:${h.inTranscript}`);
221
+ }) })
222
+ ] });
223
+ }
148
224
  function isSafeHref(href) {
149
225
  try {
150
226
  const url = new URL(href);
@@ -330,6 +406,453 @@ function renderNodes(nodes, opts) {
330
406
  }
331
407
  });
332
408
  }
409
+ function linkPreviewImageSrc(imageUrl, images = "off", proxyUrl) {
410
+ if (!isHttpsUrl(imageUrl)) return null;
411
+ if (images === "direct") return imageUrl;
412
+ if (images !== "proxy" || !proxyUrl) return null;
413
+ try {
414
+ return proxyUrl(imageUrl) || null;
415
+ } catch {
416
+ return null;
417
+ }
418
+ }
419
+ function ChatLinkPreview({ preview, images = "off", proxyUrl, tone = "default", className }) {
420
+ const [imageFailed, setImageFailed] = useState(false);
421
+ if (preview.status !== "ok" || !isSafeHref(preview.url) || !isHttpsUrl(preview.url)) return null;
422
+ let host = preview.url;
423
+ try {
424
+ host = new URL(preview.url).hostname.replace(/^www\./, "");
425
+ } catch {
426
+ }
427
+ const image = imageFailed ? null : linkPreviewImageSrc(preview.imageUrl, images, proxyUrl);
428
+ const title = preview.title?.trim() || host;
429
+ const primary = tone === "primary";
430
+ return /* @__PURE__ */ jsxs(
431
+ "a",
432
+ {
433
+ href: preview.url,
434
+ target: "_blank",
435
+ rel: "noopener noreferrer nofollow",
436
+ referrerPolicy: "no-referrer",
437
+ className: cn(
438
+ "mt-1.5 flex w-[min(20rem,100%)] flex-col overflow-hidden rounded-lg border-l-4 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
439
+ primary ? "border-primary-foreground/60 bg-primary-foreground/15" : "border-primary bg-background hover:bg-accent",
440
+ className
441
+ ),
442
+ "aria-label": `${title}, ${preview.siteName ?? host}. Open link`,
443
+ "data-testid": "genesis-chat-link-preview",
444
+ children: [
445
+ image ? /* @__PURE__ */ jsx(
446
+ "img",
447
+ {
448
+ src: image,
449
+ alt: "",
450
+ loading: "lazy",
451
+ referrerPolicy: "no-referrer",
452
+ onError: () => setImageFailed(true),
453
+ className: "aspect-[1.91/1] w-full bg-muted object-cover",
454
+ "data-testid": "genesis-chat-link-preview-image"
455
+ }
456
+ ) : null,
457
+ /* @__PURE__ */ jsxs("span", { className: "flex flex-col gap-0.5 px-2.5 py-2", children: [
458
+ /* @__PURE__ */ jsx("span", { className: cn("truncate text-[11px] uppercase tracking-wide", primary ? "text-primary-foreground/75" : "text-muted-foreground"), children: preview.siteName ?? host }),
459
+ /* @__PURE__ */ jsx("span", { className: "line-clamp-2 text-sm font-semibold leading-snug", children: title }),
460
+ preview.description ? /* @__PURE__ */ jsx("span", { className: cn("line-clamp-2 text-xs", primary ? "text-primary-foreground/85" : "text-muted-foreground"), children: preview.description }) : null
461
+ ] })
462
+ ]
463
+ }
464
+ );
465
+ }
466
+ function ChatAttachmentCard({ title, subtitle, icon: Icon = ClipboardList, action, tone = "default", className }) {
467
+ const primary = tone === "primary";
468
+ const href = action?.href && isSafeHref(action.href) ? action.href : null;
469
+ return /* @__PURE__ */ jsxs(
470
+ "div",
471
+ {
472
+ className: cn("flex w-[min(20rem,100%)] items-center gap-3 rounded-lg p-2.5", primary ? "bg-primary-foreground/15" : "border bg-background", className),
473
+ "data-testid": "genesis-chat-attachment-card",
474
+ children: [
475
+ /* @__PURE__ */ jsx("span", { className: cn("flex size-10 shrink-0 items-center justify-center rounded-md", primary ? "bg-primary-foreground/20" : "bg-primary/10 text-primary"), children: /* @__PURE__ */ jsx(Icon, { className: "size-5", "aria-hidden": true }) }),
476
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
477
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm font-semibold", children: title }),
478
+ subtitle ? /* @__PURE__ */ jsx("span", { className: cn("block truncate text-xs", primary ? "text-primary-foreground/80" : "text-muted-foreground"), children: subtitle }) : null
479
+ ] }),
480
+ action && (href || action.onPress) ? href ? /* @__PURE__ */ jsx(Button, { asChild: true, size: "sm", variant: primary ? "secondary" : "default", className: "shrink-0", children: /* @__PURE__ */ jsxs("a", { href, target: /^https?:/i.test(href) ? "_blank" : void 0, rel: "noopener noreferrer", children: [
481
+ action.label,
482
+ /^https?:/i.test(href) ? /* @__PURE__ */ jsx(ExternalLink, { "aria-hidden": true }) : null
483
+ ] }) }) : /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: primary ? "secondary" : "default", className: "shrink-0", onClick: action.onPress, children: action.label }) : null
484
+ ]
485
+ }
486
+ );
487
+ }
488
+ var ChatErrorBoundary = class extends Component {
489
+ state = { failed: false };
490
+ static getDerivedStateFromError() {
491
+ return { failed: true };
492
+ }
493
+ componentDidCatch(error, info) {
494
+ if (this.props.onError) this.props.onError(error, info);
495
+ else console.error("genesis-chat: a message couldn't be drawn", error);
496
+ }
497
+ render() {
498
+ return this.state.failed ? this.props.fallback : this.props.children;
499
+ }
500
+ };
501
+ function pickVideoMimeType() {
502
+ if (typeof MediaRecorder === "undefined") return null;
503
+ for (const type of ["video/mp4;codecs=avc1,mp4a", "video/mp4", "video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm"]) {
504
+ try {
505
+ if (MediaRecorder.isTypeSupported(type)) return type;
506
+ } catch {
507
+ }
508
+ }
509
+ return "";
510
+ }
511
+ function useVideoRecorder({ maxMs, onTick }) {
512
+ const [state, setState] = useState("idle");
513
+ const [error, setError] = useState(null);
514
+ const [elapsedMs, setElapsedMs] = useState(0);
515
+ const [stream, setStream] = useState(null);
516
+ const [recording, setRecording] = useState(null);
517
+ const recorder = useRef(null);
518
+ const chunks = useRef([]);
519
+ const startedAt = useRef(0);
520
+ const timer = useRef(null);
521
+ const streamRef = useRef(null);
522
+ const urlRef = useRef(null);
523
+ const session = useRef(0);
524
+ const tick = useRef(onTick);
525
+ tick.current = onTick;
526
+ const stopTimer = () => {
527
+ if (timer.current) clearInterval(timer.current);
528
+ timer.current = null;
529
+ };
530
+ const releaseUrl = () => {
531
+ if (urlRef.current && typeof URL !== "undefined" && URL.revokeObjectURL) URL.revokeObjectURL(urlRef.current);
532
+ urlRef.current = null;
533
+ };
534
+ const stopStream = useCallback(() => {
535
+ streamRef.current?.getTracks().forEach((t) => t.stop());
536
+ streamRef.current = null;
537
+ setStream(null);
538
+ }, []);
539
+ const open = useCallback(async () => {
540
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia || pickVideoMimeType() === null) {
541
+ setState("unsupported");
542
+ setError("This browser can't record video.");
543
+ return false;
544
+ }
545
+ const token = session.current;
546
+ setState("requesting");
547
+ setError(null);
548
+ try {
549
+ const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true });
550
+ if (token !== session.current) {
551
+ s.getTracks().forEach((t) => t.stop());
552
+ return false;
553
+ }
554
+ if (streamRef.current && streamRef.current !== s) streamRef.current.getTracks().forEach((t) => t.stop());
555
+ streamRef.current = s;
556
+ setStream(s);
557
+ setState("previewing");
558
+ return true;
559
+ } catch (e) {
560
+ if (token !== session.current) return false;
561
+ const denied = e instanceof Error && (e.name === "NotAllowedError" || e.name === "SecurityError");
562
+ setState(denied ? "denied" : "error");
563
+ setError(denied ? "Allow the camera and microphone in your browser's settings to record a video." : "The camera couldn't start.");
564
+ return false;
565
+ }
566
+ }, []);
567
+ const stop = useCallback(() => {
568
+ const r = recorder.current;
569
+ if (r && r.state !== "inactive") r.stop();
570
+ }, []);
571
+ const start = useCallback(async () => {
572
+ if (!streamRef.current && !await open()) return;
573
+ const s = streamRef.current;
574
+ const mime = pickVideoMimeType() ?? "";
575
+ let r;
576
+ try {
577
+ r = mime ? new MediaRecorder(s, { mimeType: mime, videoBitsPerSecond: 25e5 }) : new MediaRecorder(s);
578
+ } catch {
579
+ stopStream();
580
+ setState("error");
581
+ setError("This browser can't record video.");
582
+ return;
583
+ }
584
+ r.onerror = () => {
585
+ stopTimer();
586
+ r.onstop = null;
587
+ recorder.current = null;
588
+ stopStream();
589
+ setState("error");
590
+ setError("The recording stopped. Try again.");
591
+ };
592
+ releaseUrl();
593
+ setRecording(null);
594
+ chunks.current = [];
595
+ r.ondataavailable = (ev) => {
596
+ if (ev.data && ev.data.size > 0) chunks.current.push(ev.data);
597
+ };
598
+ r.onstop = () => {
599
+ stopTimer();
600
+ const durationMs = Date.now() - startedAt.current;
601
+ const contentType = (r.mimeType || mime || "video/webm").split(";")[0].trim() || "video/webm";
602
+ const blob = new Blob(chunks.current, { type: contentType });
603
+ const url = typeof URL !== "undefined" && URL.createObjectURL ? URL.createObjectURL(blob) : null;
604
+ urlRef.current = url;
605
+ setRecording({ blob, contentType, name: `video-${Date.now()}.${fileExtension("", contentType) || "webm"}`, durationMs, url });
606
+ setState("recorded");
607
+ stopStream();
608
+ };
609
+ recorder.current = r;
610
+ startedAt.current = Date.now();
611
+ setElapsedMs(0);
612
+ r.start(1e3);
613
+ setState("recording");
614
+ timer.current = setInterval(() => {
615
+ const ms = Date.now() - startedAt.current;
616
+ setElapsedMs(ms);
617
+ tick.current?.();
618
+ if (ms >= maxMs) stop();
619
+ }, 250);
620
+ }, [maxMs, open, stop, stopStream]);
621
+ const retake = useCallback(async () => {
622
+ releaseUrl();
623
+ setRecording(null);
624
+ setElapsedMs(0);
625
+ await open();
626
+ }, [open]);
627
+ const handOff = useCallback(() => {
628
+ urlRef.current = null;
629
+ }, []);
630
+ const reset = useCallback(() => {
631
+ session.current += 1;
632
+ stopTimer();
633
+ const r = recorder.current;
634
+ if (r && r.state !== "inactive") {
635
+ r.onstop = null;
636
+ r.stop();
637
+ }
638
+ recorder.current = null;
639
+ stopStream();
640
+ releaseUrl();
641
+ setRecording(null);
642
+ setElapsedMs(0);
643
+ setError(null);
644
+ setState("idle");
645
+ }, [stopStream]);
646
+ useEffect(() => () => reset(), [reset]);
647
+ return { state, error, elapsedMs, maxMs, stream, recording, open, start, stop, retake, reset, handOff };
648
+ }
649
+ function videoFile(rec) {
650
+ return {
651
+ data: rec.blob,
652
+ name: rec.name,
653
+ contentType: rec.contentType,
654
+ previewUrl: rec.url ?? void 0,
655
+ meta: { kind: "video", durationMs: rec.durationMs, bytes: rec.blob.size }
656
+ };
657
+ }
658
+ async function uploadVideo(videoUpload, conversationId, rec, fetchImpl = fetch, signal) {
659
+ const upload = await videoUpload.create(conversationId);
660
+ signal?.throwIfAborted?.();
661
+ const res = await fetchImpl(upload.url, { method: "PUT", body: rec.blob, headers: { "Content-Type": rec.contentType }, signal });
662
+ if (!res.ok) throw new Error(`The video didn't upload (${res.status})`);
663
+ return {
664
+ kind: "video",
665
+ provider: upload.provider,
666
+ providerId: upload.providerId,
667
+ status: "pending",
668
+ name: rec.name,
669
+ mime: rec.contentType,
670
+ bytes: rec.blob.size,
671
+ durationMs: rec.durationMs
672
+ };
673
+ }
674
+ function Preview({ stream }) {
675
+ const ref = useRef(null);
676
+ useEffect(() => {
677
+ const el = ref.current;
678
+ if (!el) return;
679
+ try {
680
+ el.srcObject = stream;
681
+ } catch {
682
+ }
683
+ }, [stream]);
684
+ return /* @__PURE__ */ jsx("video", { ref, autoPlay: true, muted: true, playsInline: true, className: "h-full w-full -scale-x-100 object-cover", "aria-label": "Camera preview", "data-testid": "genesis-chat-video-preview" });
685
+ }
686
+ function ChatVideoRecorder({ open, onOpenChange, conversationId, maxMs, send, extra, videoUpload, onTick, onSent, fetchImpl }) {
687
+ const rec = useVideoRecorder({ maxMs, onTick });
688
+ const [sending, setSending] = useState(false);
689
+ const [sendError, setSendError] = useState(null);
690
+ const upload = useRef(null);
691
+ const { open: openCamera, reset } = rec;
692
+ useEffect(() => {
693
+ if (open) void openCamera();
694
+ else reset();
695
+ }, [open, openCamera, reset]);
696
+ useEffect(() => () => upload.current?.abort(), []);
697
+ const submit = async () => {
698
+ const take = rec.recording;
699
+ if (!take || upload.current) return;
700
+ const controller = new AbortController();
701
+ upload.current = controller;
702
+ setSending(true);
703
+ setSendError(null);
704
+ try {
705
+ if (videoUpload) {
706
+ const attachment2 = await uploadVideo(videoUpload, conversationId, take, fetchImpl, controller.signal);
707
+ if (controller.signal.aborted) return;
708
+ await send({ ...extra, body: "", attachments: [attachment2] });
709
+ } else {
710
+ await send({ ...extra, body: "" }, [videoFile(take)]);
711
+ rec.handOff();
712
+ }
713
+ onSent?.();
714
+ onOpenChange(false);
715
+ } catch (e) {
716
+ if (!controller.signal.aborted) setSendError(e instanceof Error ? e.message : "The video didn't send");
717
+ } finally {
718
+ upload.current = null;
719
+ setSending(false);
720
+ }
721
+ };
722
+ const close = (o) => {
723
+ if (!o) upload.current?.abort();
724
+ onOpenChange(o);
725
+ };
726
+ const recording = rec.state === "recording";
727
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: close, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-lg", "data-testid": "genesis-chat-video-recorder", children: [
728
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [
729
+ /* @__PURE__ */ jsx(DialogTitle, { children: "Record a video" }),
730
+ /* @__PURE__ */ jsxs(DialogDescription, { children: [
731
+ "Up to ",
732
+ formatDuration(maxMs),
733
+ ". You can watch it before you send it."
734
+ ] })
735
+ ] }),
736
+ rec.error ? /* @__PURE__ */ jsxs(Alert, { variant: "destructive", children: [
737
+ /* @__PURE__ */ jsx(VideoOff, { className: "size-4", "aria-hidden": true }),
738
+ /* @__PURE__ */ jsx(AlertDescription, { children: rec.error })
739
+ ] }) : null,
740
+ /* @__PURE__ */ jsxs("div", { className: "relative aspect-video w-full overflow-hidden rounded-lg bg-muted", children: [
741
+ rec.state === "recorded" && rec.recording?.url ? (
742
+ // eslint-disable-next-line jsx-a11y/media-has-caption -- your own recording, reviewed before sending
743
+ /* @__PURE__ */ jsx("video", { src: rec.recording.url, controls: true, playsInline: true, className: "h-full w-full object-contain", "aria-label": "Your recording", "data-testid": "genesis-chat-video-review" })
744
+ ) : rec.stream ? /* @__PURE__ */ jsx(Preview, { stream: rec.stream }) : /* @__PURE__ */ jsx("span", { className: "absolute inset-0 flex items-center justify-center", children: rec.state === "requesting" ? /* @__PURE__ */ jsx(Spinner, { "aria-label": "Starting the camera" }) : /* @__PURE__ */ jsx(Video, { className: "size-8 text-muted-foreground", "aria-hidden": true }) }),
745
+ recording ? /* @__PURE__ */ jsxs("span", { className: "absolute left-2 top-2 flex items-center gap-1.5 rounded-full bg-background/85 px-2 py-0.5 text-xs font-medium tabular-nums", role: "timer", "aria-live": "off", children: [
746
+ /* @__PURE__ */ jsx("span", { className: "size-2 animate-pulse rounded-full bg-destructive", "aria-hidden": true }),
747
+ formatDuration(rec.elapsedMs),
748
+ " / ",
749
+ formatDuration(maxMs)
750
+ ] }) : null,
751
+ sending ? /* @__PURE__ */ jsxs("span", { className: "absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background/70 text-sm font-medium", role: "status", children: [
752
+ /* @__PURE__ */ jsx(Spinner, {}),
753
+ " Uploading\u2026",
754
+ videoUpload ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", onClick: () => upload.current?.abort(), "data-testid": "genesis-chat-video-cancel-upload", children: "Cancel upload" }) : null
755
+ ] }) : null
756
+ ] }),
757
+ /* @__PURE__ */ jsx("div", { className: "sr-only", role: "status", children: recording ? "Recording" : rec.state === "recorded" ? "Recorded. Watch it, retake it or send it." : "" }),
758
+ sendError ? /* @__PURE__ */ jsx("p", { className: "text-sm text-destructive", role: "alert", children: sendError }) : null,
759
+ /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center gap-3", children: rec.state === "recorded" ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
760
+ /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", onClick: () => void rec.retake(), disabled: sending, "data-testid": "genesis-chat-video-retake", children: [
761
+ /* @__PURE__ */ jsx(RotateCcw, {}),
762
+ " Retake"
763
+ ] }),
764
+ /* @__PURE__ */ jsxs(Button, { type: "button", onClick: () => void submit(), loading: sending, "data-testid": "genesis-chat-video-send", children: [
765
+ /* @__PURE__ */ jsx(SendHorizontal, {}),
766
+ " Send video"
767
+ ] })
768
+ ] }) : recording ? /* @__PURE__ */ jsxs(Button, { type: "button", variant: "destructive", className: "rounded-full", onClick: rec.stop, "aria-label": "Stop recording", "data-testid": "genesis-chat-video-stop", children: [
769
+ /* @__PURE__ */ jsx(Square, { className: "fill-current" }),
770
+ " Stop"
771
+ ] }) : /* @__PURE__ */ jsxs(
772
+ Button,
773
+ {
774
+ type: "button",
775
+ className: "rounded-full",
776
+ onClick: () => void rec.start(),
777
+ disabled: rec.state === "requesting" || rec.state === "unsupported",
778
+ "aria-label": "Start recording",
779
+ "data-testid": "genesis-chat-video-record",
780
+ children: [
781
+ /* @__PURE__ */ jsx(Circle, { className: "fill-destructive text-destructive" }),
782
+ " Record"
783
+ ]
784
+ }
785
+ ) })
786
+ ] }) });
787
+ }
788
+ var muxStreamUrl = (playbackId) => `https://stream.mux.com/${encodeURIComponent(playbackId)}.m3u8`;
789
+ var muxPosterUrl = (playbackId) => `https://image.mux.com/${encodeURIComponent(playbackId)}/thumbnail.jpg`;
790
+ function ChatVideoPlayer({ src, playbackId, poster, processing, failed, uploading, label, className }) {
791
+ const ref = useRef(null);
792
+ const [hlsFailed, setHlsFailed] = useState(false);
793
+ const stream = playbackId ? muxStreamUrl(playbackId) : null;
794
+ useEffect(() => {
795
+ const el = ref.current;
796
+ if (!el || !stream) return;
797
+ if (typeof el.canPlayType === "function" && el.canPlayType("application/vnd.apple.mpegurl")) {
798
+ el.src = stream;
799
+ return;
800
+ }
801
+ let hls = null;
802
+ let cancelled = false;
803
+ import('hls.js').then((mod) => {
804
+ const Hls = mod.default;
805
+ if (cancelled) return;
806
+ if (!Hls?.isSupported?.()) {
807
+ setHlsFailed(true);
808
+ return;
809
+ }
810
+ hls = new Hls();
811
+ hls.loadSource(stream);
812
+ hls.attachMedia(el);
813
+ }).catch(() => {
814
+ if (!cancelled) setHlsFailed(true);
815
+ });
816
+ return () => {
817
+ cancelled = true;
818
+ hls?.destroy();
819
+ };
820
+ }, [stream]);
821
+ const posterUrl = poster ?? (playbackId ? muxPosterUrl(playbackId) : null);
822
+ if (failed) {
823
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex aspect-video w-[min(20rem,100%)] items-center justify-center gap-2 rounded-xl bg-muted text-sm text-muted-foreground", className), "data-testid": "genesis-chat-video-failed", children: [
824
+ /* @__PURE__ */ jsx(VideoOff, { className: "size-4", "aria-hidden": true }),
825
+ " This video couldn't be processed"
826
+ ] });
827
+ }
828
+ if (processing && !playbackId) {
829
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex aspect-video w-[min(20rem,100%)] flex-col items-center justify-center gap-2 rounded-xl bg-muted text-sm text-muted-foreground", className), role: "status", "data-testid": "genesis-chat-video-processing", children: [
830
+ /* @__PURE__ */ jsx(Spinner, { size: "sm" }),
831
+ " Processing\u2026"
832
+ ] });
833
+ }
834
+ return /* @__PURE__ */ jsxs("div", { className: cn("relative w-[min(20rem,100%)] overflow-hidden rounded-xl bg-muted", className), "data-testid": "genesis-chat-video", children: [
835
+ stream || src ? (
836
+ // eslint-disable-next-line jsx-a11y/media-has-caption -- transcripts come from the provider's captions
837
+ /* @__PURE__ */ jsx(
838
+ "video",
839
+ {
840
+ ref,
841
+ controls: true,
842
+ playsInline: true,
843
+ preload: "metadata",
844
+ poster: posterUrl ?? void 0,
845
+ src: stream ? void 0 : src ?? void 0,
846
+ className: "block max-h-80 w-full",
847
+ "aria-label": label ?? "Video",
848
+ "data-testid": stream ? "genesis-chat-video-hls" : void 0
849
+ }
850
+ )
851
+ ) : /* @__PURE__ */ jsx("span", { className: "block aspect-video w-full animate-pulse bg-muted" }),
852
+ hlsFailed ? /* @__PURE__ */ jsx("span", { className: "absolute inset-x-0 bottom-0 bg-background/85 px-2 py-1 text-xs", children: "This browser can't stream this video." }) : null,
853
+ uploading ? /* @__PURE__ */ jsx("span", { className: "absolute inset-0 flex items-center justify-center bg-background/40", role: "status", "aria-label": "Uploading", children: /* @__PURE__ */ jsx(Spinner, { size: "sm", className: "text-foreground" }) }) : null
854
+ ] });
855
+ }
333
856
  var playing = null;
334
857
  var BARS = 48;
335
858
  function resample(peaks, n = BARS) {
@@ -602,13 +1125,20 @@ function Lightbox({ images, index, onIndexChange }) {
602
1125
  ] }) });
603
1126
  }
604
1127
  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
- ] });
1128
+ const a = view.attachment;
1129
+ const hosted = a != null && a.provider !== "storage" && a.provider !== "external" && a.provider !== "none";
1130
+ return /* @__PURE__ */ jsx(
1131
+ ChatVideoPlayer,
1132
+ {
1133
+ src: hosted ? null : view.url,
1134
+ playbackId: hosted ? a.playbackId : null,
1135
+ poster: view.posterUrl,
1136
+ processing: hosted && a.status === "pending",
1137
+ failed: a?.status === "failed",
1138
+ uploading: !a && view.pending,
1139
+ label: view.name ?? "Video"
1140
+ }
1141
+ );
612
1142
  }
613
1143
  function FileChip({ view, tone = "default" }) {
614
1144
  const primary = tone === "primary";
@@ -665,7 +1195,25 @@ function LinkAttachment({ view, tone }) {
665
1195
  }
666
1196
  );
667
1197
  }
668
- function MessageAttachments({ views, message, mine, tone, slot }) {
1198
+ function attachmentKindOf(kinds, kind) {
1199
+ if (!kinds || !Object.prototype.hasOwnProperty.call(kinds, kind)) return null;
1200
+ const k = kinds[kind];
1201
+ return k && typeof k.title === "function" ? k : null;
1202
+ }
1203
+ function attachmentCardOf(k, a) {
1204
+ try {
1205
+ const title = k.title(a);
1206
+ if (typeof title !== "string") return null;
1207
+ const sub = typeof k.subtitle === "function" ? k.subtitle(a) : null;
1208
+ const act = typeof k.action === "function" ? k.action(a) : null;
1209
+ const action = act && typeof act === "object" && typeof act.label === "string" && (typeof act.href === "string" || typeof act.onPress === "function") ? { label: act.label, href: typeof act.href === "string" ? act.href : void 0, onPress: typeof act.onPress === "function" ? act.onPress : void 0 } : null;
1210
+ return { title, subtitle: typeof sub === "string" ? sub : null, action };
1211
+ } catch (e) {
1212
+ console.error(`genesis-chat: the "${a.kind}" card couldn't be drawn`, e);
1213
+ return null;
1214
+ }
1215
+ }
1216
+ function MessageAttachments({ views, message, mine, tone, slot, kinds }) {
669
1217
  if (views.length === 0) return null;
670
1218
  const images = views.filter((v) => v.kind === "image");
671
1219
  const others = views.filter((v) => v.kind !== "image");
@@ -695,11 +1243,69 @@ function MessageAttachments({ views, message, mine, tone, slot }) {
695
1243
  return /* @__PURE__ */ jsx(LinkAttachment, { view: v, tone }, v.key);
696
1244
  default:
697
1245
  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;
1246
+ const generic = /* @__PURE__ */ jsx(GenericAttachment, { attachment: v.attachment, message, url: v.url, mine });
1247
+ const k = attachmentKindOf(kinds, v.kind);
1248
+ if (k) {
1249
+ const card = attachmentCardOf(k, v.attachment);
1250
+ return card ? /* @__PURE__ */ jsx(ChatAttachmentCard, { ...card, tone }, v.key) : /* @__PURE__ */ jsx(GenericAttachment, { attachment: v.attachment, message, url: v.url, mine }, v.key);
1251
+ }
1252
+ return Custom ? /* @__PURE__ */ jsx(ChatErrorBoundary, { fallback: generic, children: /* @__PURE__ */ jsx(Custom, { attachment: v.attachment, message, url: v.url, mine }) }, v.key) : null;
699
1253
  }
700
1254
  })
701
1255
  ] });
702
1256
  }
1257
+ var canPickEmoji = (emojibaseUrl) => Boolean(emojibaseUrl?.trim());
1258
+ function ChatEmojiPicker({ onSelect, emojibaseUrl, locale, columns = 8, className }) {
1259
+ if (!canPickEmoji(emojibaseUrl)) {
1260
+ if (typeof console !== "undefined") console.warn("genesis-chat: ChatEmojiPicker needs `emojibaseUrl` (self-hosted emojibase-data); it loads nothing without it.");
1261
+ return /* @__PURE__ */ jsx("p", { className: cn("w-[18.5rem] p-3 text-sm text-muted-foreground", className), role: "note", "data-testid": "genesis-chat-emoji-picker-unset", children: "The emoji picker isn't set up." });
1262
+ }
1263
+ return /* @__PURE__ */ jsxs(
1264
+ EmojiPicker.Root,
1265
+ {
1266
+ onEmojiSelect: (e) => onSelect(e.emoji),
1267
+ emojibaseUrl,
1268
+ locale,
1269
+ columns,
1270
+ className: cn("isolate flex h-80 w-[18.5rem] flex-col bg-popover text-popover-foreground", className),
1271
+ "data-testid": "genesis-chat-emoji-picker",
1272
+ children: [
1273
+ /* @__PURE__ */ jsx(
1274
+ EmojiPicker.Search,
1275
+ {
1276
+ autoFocus: true,
1277
+ "aria-label": "Search emoji",
1278
+ placeholder: "Search emoji",
1279
+ className: "m-2 h-9 shrink-0 rounded-md border border-input bg-background px-3 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
1280
+ }
1281
+ ),
1282
+ /* @__PURE__ */ jsxs(EmojiPicker.Viewport, { className: "relative flex-1 outline-none", children: [
1283
+ /* @__PURE__ */ jsx(EmojiPicker.Loading, { className: "absolute inset-0 flex items-center justify-center", children: /* @__PURE__ */ jsx(Spinner, { size: "sm", "aria-label": "Loading emoji" }) }),
1284
+ /* @__PURE__ */ jsx(EmojiPicker.Empty, { className: "absolute inset-0 flex items-center justify-center text-sm text-muted-foreground", children: "No emoji found" }),
1285
+ /* @__PURE__ */ jsx(
1286
+ EmojiPicker.List,
1287
+ {
1288
+ className: "select-none pb-1.5",
1289
+ components: {
1290
+ CategoryHeader: ({ category, ...props }) => /* @__PURE__ */ jsx("div", { className: "bg-popover px-3 pb-1 pt-2 text-xs font-medium text-muted-foreground", ...props, children: category.label }),
1291
+ Row: ({ children, ...props }) => /* @__PURE__ */ jsx("div", { className: "scroll-my-1.5 px-1.5", ...props, children }),
1292
+ Emoji: ({ emoji, ...props }) => /* @__PURE__ */ jsx(
1293
+ "button",
1294
+ {
1295
+ type: "button",
1296
+ className: cn("flex size-8 items-center justify-center rounded-md text-xl", emoji.isActive && "bg-accent"),
1297
+ ...props,
1298
+ children: emoji.emoji
1299
+ }
1300
+ )
1301
+ }
1302
+ }
1303
+ )
1304
+ ] })
1305
+ ]
1306
+ }
1307
+ );
1308
+ }
703
1309
  var ThreadContext = createContext(null);
704
1310
  function useThreadContext() {
705
1311
  const ctx = useContext(ThreadContext);
@@ -729,6 +1335,7 @@ function pendingAsMessage(p) {
729
1335
  createdAt: p.createdAt,
730
1336
  editedAt: null,
731
1337
  deletedAt: null,
1338
+ moderation: null,
732
1339
  attachments: [],
733
1340
  reactions: []
734
1341
  };
@@ -782,6 +1389,10 @@ function MessageRow({ item, startsGroup: startsGroup2, className }) {
782
1389
  const reactions = pending ? [] : summarizeReactions(message, ctx.me);
783
1390
  const highlighted = ctx.highlightedId === message.id;
784
1391
  const actionable = !pending && !deleted;
1392
+ const hidden = !deleted && message.moderation === "hidden";
1393
+ const flagged = !deleted && message.moderation === "flagged";
1394
+ const observer = ctx.role === "observer";
1395
+ const preview = !pending && !deleted && !hidden && message.body ? ctx.previews[message.id]?.[0] : void 0;
785
1396
  const bubble = /* @__PURE__ */ jsxs(
786
1397
  "div",
787
1398
  {
@@ -792,7 +1403,8 @@ function MessageRow({ item, startsGroup: startsGroup2, className }) {
792
1403
  note && "border border-warning/50 bg-warning/15 text-foreground",
793
1404
  deleted && "border border-dashed bg-transparent text-muted-foreground shadow-none",
794
1405
  highlighted && "ring-2 ring-ring ring-offset-2 ring-offset-background",
795
- pending?.status === "failed" && "opacity-80"
1406
+ pending?.status === "failed" && "opacity-80",
1407
+ hidden && "opacity-70"
796
1408
  ),
797
1409
  "data-testid": deleted ? "genesis-chat-deleted" : note ? "genesis-chat-note" : mine ? "genesis-chat-mine" : "genesis-chat-theirs",
798
1410
  children: [
@@ -805,8 +1417,13 @@ function MessageRow({ item, startsGroup: startsGroup2, className }) {
805
1417
  " Message deleted"
806
1418
  ] }) : editing ? /* @__PURE__ */ jsx(EditBox, { message }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
807
1419
  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,
1420
+ 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, kinds: ctx.attachmentKinds }) }) : null,
809
1421
  message.body ? /* @__PURE__ */ jsx(ChatMarkdown, { text: message.body, me: ctx.me, tone }) : null,
1422
+ preview ? /* @__PURE__ */ jsx(ChatLinkPreview, { preview, images: ctx.linkPreviewImages, proxyUrl: ctx.linkPreviewProxyUrl, tone }) : null,
1423
+ hidden && mine && !observer ? /* @__PURE__ */ jsxs("span", { className: "mt-1 flex items-center gap-1 text-xs font-medium", "data-testid": "genesis-chat-hidden-notice", children: [
1424
+ /* @__PURE__ */ jsx(EyeOff, { className: "size-3", "aria-hidden": true }),
1425
+ " Hidden by moderation. Only you can see it."
1426
+ ] }) : null,
810
1427
  /* @__PURE__ */ jsx(Meta, { message, pending, tone })
811
1428
  ] })
812
1429
  ]
@@ -817,13 +1434,25 @@ function MessageRow({ item, startsGroup: startsGroup2, className }) {
817
1434
  {
818
1435
  id: `genesis-chat-msg-${message.id}`,
819
1436
  "data-message-id": pending ? void 0 : message.id,
820
- "aria-label": messageLabel(message, mine ? "You" : sender.name),
1437
+ "aria-label": `${messageLabel(message, mine ? "You" : sender.name)}${hidden ? " (hidden by moderation)" : flagged && observer ? " (flagged)" : ""}`,
821
1438
  className: cn("group/msg flex gap-2 px-3", mine ? "flex-row-reverse" : "flex-row", startsGroup2 ? "mt-2" : "mt-0.5", className),
822
1439
  "data-testid": pending ? `genesis-chat-pending-${pending.status}` : "genesis-chat-message",
823
1440
  children: [
824
1441
  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
1442
  /* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0 max-w-[min(78%,36rem)] flex-col", mine ? "items-end" : "items-start"), children: [
826
1443
  showHeader ? /* @__PURE__ */ jsx("span", { className: "mb-0.5 px-3 text-xs font-medium text-muted-foreground", children: sender.name }) : null,
1444
+ observer && (hidden || flagged) ? /* @__PURE__ */ jsxs(
1445
+ Badge,
1446
+ {
1447
+ variant: hidden ? "destructive" : "outline",
1448
+ className: "mb-0.5 gap-1 px-1.5 py-0 text-[10px]",
1449
+ "data-testid": hidden ? "genesis-chat-moderation-hidden" : "genesis-chat-moderation-flagged",
1450
+ children: [
1451
+ hidden ? /* @__PURE__ */ jsx(EyeOff, { className: "size-3", "aria-hidden": true }) : /* @__PURE__ */ jsx(ShieldAlert, { className: "size-3", "aria-hidden": true }),
1452
+ hidden ? "Hidden by moderation" : "Flagged"
1453
+ ]
1454
+ }
1455
+ ) : null,
827
1456
  /* @__PURE__ */ jsxs("div", { className: cn("flex max-w-full items-center gap-1", mine ? "flex-row-reverse" : "flex-row"), children: [
828
1457
  actionable ? /* @__PURE__ */ jsxs(ContextMenu, { children: [
829
1458
  /* @__PURE__ */ jsx(ContextMenuTrigger, { asChild: true, children: bubble }),
@@ -1016,6 +1645,7 @@ function HoverBar({ message, mine }) {
1016
1645
  const ctx = useThreadContext();
1017
1646
  const keepFocus = useRef(null);
1018
1647
  const [open, setOpen] = useState(null);
1648
+ const [full, setFull] = useState(false);
1019
1649
  return /* @__PURE__ */ jsxs(
1020
1650
  "div",
1021
1651
  {
@@ -1025,27 +1655,61 @@ function HoverBar({ message, mine }) {
1025
1655
  mine ? "flex-row-reverse" : "flex-row"
1026
1656
  ),
1027
1657
  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,
1658
+ ctx.canPost ? /* @__PURE__ */ jsxs(
1659
+ Popover,
1660
+ {
1661
+ open: open === "react",
1662
+ onOpenChange: (o) => {
1663
+ setOpen(o ? "react" : null);
1664
+ if (!o) setFull(false);
1665
+ },
1666
+ children: [
1667
+ /* @__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, {}) }) }),
1668
+ full ? /* @__PURE__ */ jsx(PopoverContent, { side: "top", align: mine ? "end" : "start", className: "w-auto overflow-hidden p-0", children: /* @__PURE__ */ jsx(
1669
+ ChatEmojiPicker,
1670
+ {
1671
+ emojibaseUrl: ctx.emojibaseUrl,
1672
+ onSelect: (emoji) => {
1673
+ const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
1674
+ ctx.react(message, emoji, !on);
1675
+ setOpen(null);
1676
+ setFull(false);
1677
+ }
1678
+ }
1679
+ ) }) : /* @__PURE__ */ jsxs(PopoverContent, { side: "top", align: mine ? "end" : "start", className: "flex w-auto gap-0.5 rounded-full p-1", children: [
1680
+ ctx.quickReactions.map((emoji) => {
1681
+ const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
1682
+ return /* @__PURE__ */ jsx(
1683
+ "button",
1684
+ {
1685
+ type: "button",
1686
+ 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"),
1687
+ "aria-label": `${on ? "Remove" : "React with"} ${emoji}`,
1688
+ "aria-pressed": on,
1689
+ onClick: () => {
1690
+ ctx.react(message, emoji, !on);
1691
+ setOpen(null);
1692
+ },
1693
+ children: emoji
1694
+ },
1695
+ emoji
1696
+ );
1697
+ }),
1698
+ canPickEmoji(ctx.emojibaseUrl) ? /* @__PURE__ */ jsx(
1699
+ "button",
1700
+ {
1701
+ type: "button",
1702
+ className: "flex size-9 items-center justify-center rounded-full text-muted-foreground hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
1703
+ "aria-label": "More reactions",
1704
+ onClick: () => setFull(true),
1705
+ "data-testid": "genesis-chat-more-reactions",
1706
+ children: /* @__PURE__ */ jsx(Plus, { className: "size-4" })
1707
+ }
1708
+ ) : null
1709
+ ] })
1710
+ ]
1711
+ }
1712
+ ) : null,
1049
1713
  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
1714
  /* @__PURE__ */ jsxs(DropdownMenu, { open: open === "more", onOpenChange: (o) => setOpen(o ? "more" : null), children: [
1051
1715
  /* @__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, {}) }) }),
@@ -1055,6 +1719,345 @@ function HoverBar({ message, mine }) {
1055
1719
  }
1056
1720
  );
1057
1721
  }
1722
+ function toLocalInput(d) {
1723
+ const p = (n) => String(n).padStart(2, "0");
1724
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
1725
+ }
1726
+ function ChatScheduleMenu({ onSchedule, disabled, now }) {
1727
+ const [picking, setPicking] = useState(false);
1728
+ const picks = useMemo(() => scheduleQuickPicks(now ?? /* @__PURE__ */ new Date()), [now]);
1729
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
1730
+ /* @__PURE__ */ jsxs(DropdownMenu, { children: [
1731
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
1732
+ Button,
1733
+ {
1734
+ type: "button",
1735
+ variant: "ghost",
1736
+ size: "icon",
1737
+ className: "-ml-1 size-8 shrink-0 self-center rounded-full text-muted-foreground",
1738
+ disabled,
1739
+ "aria-label": "Send options",
1740
+ "data-testid": "genesis-chat-send-menu",
1741
+ children: /* @__PURE__ */ jsx(ChevronUp, {})
1742
+ }
1743
+ ) }),
1744
+ /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "end", side: "top", className: "w-60", children: [
1745
+ /* @__PURE__ */ jsxs(DropdownMenuLabel, { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
1746
+ /* @__PURE__ */ jsx(CalendarClock, { className: "size-3.5", "aria-hidden": true }),
1747
+ " Schedule message"
1748
+ ] }),
1749
+ picks.map((p) => /* @__PURE__ */ jsxs(DropdownMenuItem, { className: "gap-2", onSelect: () => onSchedule(p.at), "data-testid": "genesis-chat-schedule-pick", children: [
1750
+ /* @__PURE__ */ jsx(Clock, { className: "size-4", "aria-hidden": true }),
1751
+ " ",
1752
+ p.label
1753
+ ] }, p.label)),
1754
+ /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
1755
+ /* @__PURE__ */ jsxs(DropdownMenuItem, { className: "gap-2", onSelect: () => setPicking(true), "data-testid": "genesis-chat-schedule-custom", children: [
1756
+ /* @__PURE__ */ jsx(CalendarClock, { className: "size-4", "aria-hidden": true }),
1757
+ " Pick a date and time\u2026"
1758
+ ] })
1759
+ ] })
1760
+ ] }),
1761
+ /* @__PURE__ */ jsx(ScheduleDialog, { open: picking, onOpenChange: setPicking, onSchedule, now })
1762
+ ] });
1763
+ }
1764
+ function ScheduleDialog({ open, onOpenChange, onSchedule, now }) {
1765
+ const base = now ?? /* @__PURE__ */ new Date();
1766
+ const [value, setValue] = useState(() => toLocalInput(new Date(base.getTime() + 60 * 6e4)));
1767
+ useEffect(() => {
1768
+ if (open) setValue(toLocalInput(new Date((now ?? /* @__PURE__ */ new Date()).getTime() + 60 * 6e4)));
1769
+ }, [open]);
1770
+ const at = new Date(value);
1771
+ const ok = isSchedulable(at, now ?? /* @__PURE__ */ new Date());
1772
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-sm", children: [
1773
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [
1774
+ /* @__PURE__ */ jsx(DialogTitle, { children: "Schedule message" }),
1775
+ /* @__PURE__ */ jsx(DialogDescription, { children: "It sends at this time, in your time zone, as if you'd sent it then." })
1776
+ ] }),
1777
+ /* @__PURE__ */ jsxs(
1778
+ "form",
1779
+ {
1780
+ className: "flex flex-col gap-2",
1781
+ onSubmit: (e) => {
1782
+ e.preventDefault();
1783
+ if (!ok) return;
1784
+ onSchedule(at);
1785
+ onOpenChange(false);
1786
+ },
1787
+ children: [
1788
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-schedule-at", children: "Date and time" }),
1789
+ /* @__PURE__ */ jsx(
1790
+ Input,
1791
+ {
1792
+ id: "genesis-chat-schedule-at",
1793
+ type: "datetime-local",
1794
+ value,
1795
+ min: toLocalInput(new Date(base.getTime() + 6e4)),
1796
+ onChange: (e) => setValue(e.target.value),
1797
+ "aria-invalid": !ok,
1798
+ "aria-describedby": "genesis-chat-schedule-hint",
1799
+ "data-testid": "genesis-chat-schedule-input"
1800
+ }
1801
+ ),
1802
+ /* @__PURE__ */ jsx("p", { id: "genesis-chat-schedule-hint", className: cn("text-xs", ok ? "text-muted-foreground" : "text-destructive"), children: ok ? scheduleLabel(at, now ?? /* @__PURE__ */ new Date()) : "Pick a time from a minute to a year from now." }),
1803
+ /* @__PURE__ */ jsxs(DialogFooter, { className: "mt-2", children: [
1804
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => onOpenChange(false), children: "Cancel" }),
1805
+ /* @__PURE__ */ jsx(Button, { type: "submit", disabled: !ok, "data-testid": "genesis-chat-schedule-confirm", children: "Schedule" })
1806
+ ] })
1807
+ ]
1808
+ }
1809
+ )
1810
+ ] }) });
1811
+ }
1812
+ function ChatScheduledList({ conversationId, conversationTitle, className }) {
1813
+ const { items, isLoading, cancel } = useScheduled(conversationId);
1814
+ if (isLoading) {
1815
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col gap-2 p-3", className), "aria-busy": true, "aria-label": "Loading scheduled messages", children: [
1816
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-12 w-full" }),
1817
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-12 w-full" })
1818
+ ] });
1819
+ }
1820
+ if (items.length === 0) {
1821
+ return /* @__PURE__ */ jsx(EmptyState, { icon: CalendarClock, title: "Nothing scheduled", description: "Use the arrow beside Send to schedule a message.", className });
1822
+ }
1823
+ return /* @__PURE__ */ jsx("ul", { className: cn("flex flex-col divide-y", className), "aria-label": "Scheduled messages", "data-testid": "genesis-chat-scheduled-list", children: items.map((s) => /* @__PURE__ */ jsxs("li", { className: "flex items-start gap-3 px-3 py-2.5", "data-testid": "genesis-chat-scheduled-item", children: [
1824
+ s.status === "failed" ? /* @__PURE__ */ jsx(AlertCircle, { className: "mt-0.5 size-4 shrink-0 text-destructive", "aria-hidden": true }) : /* @__PURE__ */ jsx(Clock, { className: "mt-0.5 size-4 shrink-0 text-muted-foreground", "aria-hidden": true }),
1825
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
1826
+ /* @__PURE__ */ jsxs("div", { className: "text-xs font-medium text-muted-foreground", children: [
1827
+ s.status === "failed" ? "Couldn't send" : scheduleLabel(s.sendAt),
1828
+ !conversationId && conversationTitle ? ` \xB7 ${conversationTitle(s.conversationId)}` : null
1829
+ ] }),
1830
+ /* @__PURE__ */ jsx("div", { className: "truncate text-sm", children: previewText(s.body, s.attachments[0]?.kind ?? null) }),
1831
+ s.error ? /* @__PURE__ */ jsx("div", { className: "text-xs text-destructive", children: s.error }) : null
1832
+ ] }),
1833
+ s.status === "scheduled" ? /* @__PURE__ */ jsxs(
1834
+ Button,
1835
+ {
1836
+ type: "button",
1837
+ variant: "ghost",
1838
+ size: "sm",
1839
+ className: "h-7 shrink-0 gap-1 text-xs",
1840
+ onClick: () => void cancel(s.id).then(() => toast.success("Scheduled message cancelled"), () => toast.error("Couldn't cancel it")),
1841
+ "aria-label": `Cancel the message scheduled for ${scheduleLabel(s.sendAt)}`,
1842
+ "data-testid": "genesis-chat-scheduled-cancel",
1843
+ children: [
1844
+ /* @__PURE__ */ jsx(X, { className: "size-3.5", "aria-hidden": true }),
1845
+ " Cancel"
1846
+ ]
1847
+ }
1848
+ ) : null
1849
+ ] }, s.id)) });
1850
+ }
1851
+ function ChatAwayBanner({ availability, name, people, now, className }) {
1852
+ const line = people ? awaySummary(people, { now }) : awayLine(availability, name ?? "They", { now });
1853
+ if (!line) return null;
1854
+ return /* @__PURE__ */ jsxs(Alert, { className: cn("rounded-none border-x-0 border-t-0 px-3 py-2", className), role: "status", "data-testid": "genesis-chat-away-banner", children: [
1855
+ /* @__PURE__ */ jsx(Moon, { className: "size-4", "aria-hidden": true }),
1856
+ /* @__PURE__ */ jsx(AlertDescription, { className: "text-xs", children: line })
1857
+ ] });
1858
+ }
1859
+ var DAY_NAMES = {
1860
+ mon: "Monday",
1861
+ tue: "Tuesday",
1862
+ wed: "Wednesday",
1863
+ thu: "Thursday",
1864
+ fri: "Friday",
1865
+ sat: "Saturday",
1866
+ sun: "Sunday"
1867
+ };
1868
+ var FALLBACK_ZONES = [
1869
+ "UTC",
1870
+ "America/New_York",
1871
+ "America/Chicago",
1872
+ "America/Denver",
1873
+ "America/Los_Angeles",
1874
+ "America/Toronto",
1875
+ "America/Sao_Paulo",
1876
+ "Europe/London",
1877
+ "Europe/Paris",
1878
+ "Europe/Berlin",
1879
+ "Africa/Johannesburg",
1880
+ "Asia/Dubai",
1881
+ "Asia/Kolkata",
1882
+ "Asia/Singapore",
1883
+ "Asia/Tokyo",
1884
+ "Australia/Sydney",
1885
+ "Pacific/Auckland"
1886
+ ];
1887
+ function timeZones() {
1888
+ const intl = Intl;
1889
+ try {
1890
+ const all = intl.supportedValuesOf?.("timeZone");
1891
+ if (all?.length) return all.includes("UTC") ? all : ["UTC", ...all];
1892
+ } catch {
1893
+ }
1894
+ return FALLBACK_ZONES;
1895
+ }
1896
+ var deviceZone = () => {
1897
+ try {
1898
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
1899
+ } catch {
1900
+ return "UTC";
1901
+ }
1902
+ };
1903
+ var defaultDays = () => Object.fromEntries(WEEKDAYS.map((d) => [d, { on: d !== "sat" && d !== "sun", from: "09:00", to: "17:00" }]));
1904
+ function daysFrom(hours) {
1905
+ if (!hours) return defaultDays();
1906
+ return Object.fromEntries(
1907
+ WEEKDAYS.map((d) => {
1908
+ const first = hours[d]?.[0];
1909
+ return [d, first ? { on: true, from: first[0], to: first[1] } : { on: false, from: "09:00", to: "17:00" }];
1910
+ })
1911
+ );
1912
+ }
1913
+ function ChatAvailabilitySettings({ onSaved, className }) {
1914
+ const { mine, mineLoading, setMine } = useAvailability();
1915
+ const zones = useMemo(timeZones, []);
1916
+ const [message, setMessage] = useState("");
1917
+ const [until, setUntil] = useState("");
1918
+ const [zone, setZone] = useState(deviceZone);
1919
+ const [useHours, setUseHours] = useState(false);
1920
+ const [days, setDays] = useState(defaultDays);
1921
+ const [busy, setBusy] = useState(false);
1922
+ useEffect(() => {
1923
+ if (mineLoading) return;
1924
+ setMessage(mine?.awayMessage ?? "");
1925
+ setUntil(mine?.awayUntil ? toLocalInput(new Date(mine.awayUntil)) : "");
1926
+ setZone(mine?.timeZone ?? deviceZone());
1927
+ setUseHours(Boolean(mine?.hours && Object.keys(mine.hours).length));
1928
+ setDays(daysFrom(mine?.hours));
1929
+ }, [mine, mineLoading]);
1930
+ if (mineLoading) {
1931
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col gap-3", className), "aria-busy": true, "aria-label": "Loading availability", children: [
1932
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-20 w-full" }),
1933
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-40 w-full" })
1934
+ ] });
1935
+ }
1936
+ const badRange = useHours && WEEKDAYS.some((d) => days[d].on && days[d].from === days[d].to);
1937
+ const save = async () => {
1938
+ if (badRange) return;
1939
+ const hours = useHours ? Object.fromEntries(
1940
+ WEEKDAYS.filter((d) => days[d].on).map((d) => {
1941
+ const kept = (mine?.hours?.[d] ?? []).slice(1);
1942
+ return [d, [[days[d].from, days[d].to], ...kept]];
1943
+ })
1944
+ ) : null;
1945
+ setBusy(true);
1946
+ try {
1947
+ await setMine({
1948
+ awayMessage: message.trim() || null,
1949
+ awayUntil: until ? new Date(until).toISOString() : null,
1950
+ timeZone: zone,
1951
+ hours
1952
+ });
1953
+ toast.success("Availability saved");
1954
+ onSaved?.();
1955
+ } catch (e) {
1956
+ toast.error(e instanceof Error ? e.message : "Couldn't save");
1957
+ } finally {
1958
+ setBusy(false);
1959
+ }
1960
+ };
1961
+ return /* @__PURE__ */ jsxs(
1962
+ "form",
1963
+ {
1964
+ className: cn("flex flex-col gap-6", className),
1965
+ onSubmit: (e) => {
1966
+ e.preventDefault();
1967
+ void save();
1968
+ },
1969
+ "data-testid": "genesis-chat-availability-settings",
1970
+ children: [
1971
+ /* @__PURE__ */ jsxs("fieldset", { className: "flex flex-col gap-2", children: [
1972
+ /* @__PURE__ */ jsx("legend", { className: "mb-1 text-sm font-semibold", children: "Away message" }),
1973
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-away-message", className: "sr-only", children: "Away message" }),
1974
+ /* @__PURE__ */ jsx(
1975
+ Textarea,
1976
+ {
1977
+ id: "genesis-chat-away-message",
1978
+ value: message,
1979
+ onChange: (e) => setMessage(e.target.value),
1980
+ maxLength: 280,
1981
+ placeholder: "Back Monday. For anything urgent, call the office.",
1982
+ className: "min-h-20"
1983
+ }
1984
+ ),
1985
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-end gap-2", children: [
1986
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
1987
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-away-until", className: "text-xs text-muted-foreground", children: "Away until (optional)" }),
1988
+ /* @__PURE__ */ jsx(Input, { id: "genesis-chat-away-until", type: "datetime-local", value: until, onChange: (e) => setUntil(e.target.value), className: "w-56" })
1989
+ ] }),
1990
+ until || message ? /* @__PURE__ */ jsx(
1991
+ Button,
1992
+ {
1993
+ type: "button",
1994
+ variant: "ghost",
1995
+ size: "sm",
1996
+ onClick: () => {
1997
+ setUntil("");
1998
+ setMessage("");
1999
+ },
2000
+ children: "I'm back"
2001
+ }
2002
+ ) : null
2003
+ ] })
2004
+ ] }),
2005
+ /* @__PURE__ */ jsxs("fieldset", { className: "flex flex-col gap-3", children: [
2006
+ /* @__PURE__ */ jsx("legend", { className: "mb-1 flex w-full items-center justify-between gap-2 text-sm font-semibold", children: /* @__PURE__ */ jsx("span", { children: "Working hours" }) }),
2007
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3", children: [
2008
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-use-hours", className: "text-sm font-normal text-muted-foreground", children: "Tell people when you usually reply" }),
2009
+ /* @__PURE__ */ jsx(Switch, { id: "genesis-chat-use-hours", checked: useHours, onCheckedChange: setUseHours })
2010
+ ] }),
2011
+ useHours ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", role: "group", "aria-label": "Hours per day", children: [
2012
+ WEEKDAYS.map((d) => /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", "data-testid": `genesis-chat-hours-${d}`, children: [
2013
+ /* @__PURE__ */ jsx(
2014
+ Switch,
2015
+ {
2016
+ checked: days[d].on,
2017
+ onCheckedChange: (on) => setDays((x) => ({ ...x, [d]: { ...x[d], on } })),
2018
+ "aria-label": `Working on ${DAY_NAMES[d]}`
2019
+ }
2020
+ ),
2021
+ /* @__PURE__ */ jsx("span", { className: cn("w-24", !days[d].on && "text-muted-foreground"), children: DAY_NAMES[d] }),
2022
+ days[d].on ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
2023
+ /* @__PURE__ */ jsx(
2024
+ Input,
2025
+ {
2026
+ type: "time",
2027
+ value: days[d].from,
2028
+ onChange: (e) => setDays((x) => ({ ...x, [d]: { ...x[d], from: e.target.value } })),
2029
+ "aria-label": `${DAY_NAMES[d]} from`,
2030
+ className: "h-8 w-28"
2031
+ }
2032
+ ),
2033
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "\u2013" }),
2034
+ /* @__PURE__ */ jsx(
2035
+ Input,
2036
+ {
2037
+ type: "time",
2038
+ value: days[d].to,
2039
+ onChange: (e) => setDays((x) => ({ ...x, [d]: { ...x[d], to: e.target.value } })),
2040
+ "aria-label": `${DAY_NAMES[d]} to`,
2041
+ className: "h-8 w-28"
2042
+ }
2043
+ )
2044
+ ] }) : /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: "Not working" })
2045
+ ] }, d)),
2046
+ badRange ? /* @__PURE__ */ jsx("p", { className: "text-xs text-destructive", role: "alert", children: "A day's start and end can't be the same time." }) : /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: "An end before the start runs past midnight." })
2047
+ ] }) : null,
2048
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
2049
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-time-zone", className: "text-xs text-muted-foreground", children: "Time zone" }),
2050
+ /* @__PURE__ */ jsxs(Select, { value: zone, onValueChange: setZone, children: [
2051
+ /* @__PURE__ */ jsx(SelectTrigger, { id: "genesis-chat-time-zone", className: "w-72", "aria-label": "Time zone", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
2052
+ /* @__PURE__ */ jsx(SelectContent, { className: "max-h-72", children: (zones.includes(zone) ? zones : [zone, ...zones]).map((z) => /* @__PURE__ */ jsx(SelectItem, { value: z, children: z.replace(/_/g, " ") }, z)) })
2053
+ ] })
2054
+ ] })
2055
+ ] }),
2056
+ /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, { type: "submit", loading: busy, disabled: badRange, "data-testid": "genesis-chat-availability-save", children: "Save" }) })
2057
+ ]
2058
+ }
2059
+ );
2060
+ }
1058
2061
  var SAMPLE_MS = 100;
1059
2062
  function pickVoiceMimeType() {
1060
2063
  if (typeof MediaRecorder === "undefined") return null;
@@ -1266,10 +2269,21 @@ function ChatComposer({
1266
2269
  accessory,
1267
2270
  onSent,
1268
2271
  autoFocus,
2272
+ disabledReason,
2273
+ topic,
2274
+ scheduling = true,
2275
+ video = true,
2276
+ emoji = true,
2277
+ emojibaseUrl,
1269
2278
  className,
1270
2279
  ref
1271
2280
  }) {
1272
- const { userId: me } = useChat();
2281
+ const { userId: me, videoUpload } = useChat();
2282
+ const conversationId = state.conversation?.id ?? null;
2283
+ const scheduled = useScheduled(conversationId, { enabled: scheduling && Boolean(conversationId) });
2284
+ const [recordingVideo, setRecordingVideo] = useState(false);
2285
+ const [emojiOpen, setEmojiOpen] = useState(false);
2286
+ const canRecordVideo = video && typeof MediaRecorder !== "undefined" && typeof navigator !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia);
1273
2287
  const [text, setText] = useState("");
1274
2288
  const [drafts, setDrafts] = useState([]);
1275
2289
  const [internal, setInternal] = useState(false);
@@ -1277,6 +2291,9 @@ function ChatComposer({
1277
2291
  const picker = useRef(null);
1278
2292
  const draftsRef = useRef(drafts);
1279
2293
  draftsRef.current = drafts;
2294
+ const scheduleId = useRef(null);
2295
+ const [schedulingNow, setSchedulingNow] = useState(false);
2296
+ const schedulingRef = useRef(false);
1280
2297
  const recorder = useVoiceRecorder({
1281
2298
  maxMs: recordingLimitMs(state.role),
1282
2299
  onTick: state.signalRecording,
@@ -1341,8 +2358,39 @@ function ChatComposer({
1341
2358
  });
1342
2359
  const common = () => ({
1343
2360
  replyToId: replyTo?.id ?? null,
1344
- visibility: internal ? "observers" : "everyone"
2361
+ visibility: internal ? "observers" : "everyone",
2362
+ ...topic ? { topic } : {}
1345
2363
  });
2364
+ const scheduleAt = async (at) => {
2365
+ const body = text.trim();
2366
+ if (!body && drafts.length === 0 || !conversationId || schedulingRef.current) return;
2367
+ schedulingRef.current = true;
2368
+ setSchedulingNow(true);
2369
+ scheduleId.current ??= randomId();
2370
+ const files = drafts.map((d) => ({
2371
+ data: d.file,
2372
+ name: d.file.name,
2373
+ contentType: d.file.type || "application/octet-stream",
2374
+ meta: { kind: d.kind, bytes: d.file.size, name: d.file.name }
2375
+ }));
2376
+ try {
2377
+ await scheduled.schedule(
2378
+ { conversationId, clientId: scheduleId.current, body, mentions: parseMentions(body), replyToId: replyTo?.id ?? null, topic: topic ?? null, sendAt: at },
2379
+ files
2380
+ );
2381
+ scheduleId.current = null;
2382
+ setText("");
2383
+ drafts.forEach((d) => revoke(d.previewUrl));
2384
+ setDrafts([]);
2385
+ onCancelReply?.();
2386
+ toast.success(`Scheduled for ${scheduleLabel(at)}`);
2387
+ } catch (e) {
2388
+ toast.error(e instanceof Error ? e.message : "Couldn't schedule it");
2389
+ } finally {
2390
+ schedulingRef.current = false;
2391
+ setSchedulingNow(false);
2392
+ }
2393
+ };
1346
2394
  const submit = () => {
1347
2395
  const body = text.trim();
1348
2396
  if (!body && drafts.length === 0) return;
@@ -1396,6 +2444,24 @@ function ChatComposer({
1396
2444
  if (state.accessLoading) {
1397
2445
  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
2446
  }
2447
+ if (state.accessError) {
2448
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex items-center justify-center gap-2 border-t p-3 text-sm text-muted-foreground", className), role: "alert", "data-testid": "genesis-chat-access-error", children: [
2449
+ /* @__PURE__ */ jsx("span", { children: "Couldn't check whether you can post here." }),
2450
+ /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", size: "sm", className: "h-7 gap-1", onClick: () => void state.refetchAccess?.(), "data-testid": "genesis-chat-access-retry", children: [
2451
+ /* @__PURE__ */ jsx(RefreshCw, { className: "size-3.5", "aria-hidden": true }),
2452
+ " Try again"
2453
+ ] })
2454
+ ] });
2455
+ }
2456
+ if (!state.canPost && state.conversationLoading) {
2457
+ 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" }) });
2458
+ }
2459
+ if (state.canPost && disabledReason) {
2460
+ return /* @__PURE__ */ jsxs("div", { className: cn("border-t p-3 text-center text-sm text-muted-foreground", className), role: "status", "data-testid": "genesis-chat-composer-disabled", children: [
2461
+ /* @__PURE__ */ jsx(Lock, { className: "mr-1 inline size-3.5 align-[-2px]", "aria-hidden": true }),
2462
+ disabledReason
2463
+ ] });
2464
+ }
1399
2465
  if (!state.canPost) {
1400
2466
  const adminsOnly = state.conversation?.settings?.who_posts === "admins";
1401
2467
  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: [
@@ -1423,6 +2489,13 @@ function ChatComposer({
1423
2489
  ] }),
1424
2490
  /* @__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
2491
  ] }) : null,
2492
+ scheduling && scheduled.items.length ? /* @__PURE__ */ jsx("div", { className: "flex items-center px-3 pt-1.5", children: /* @__PURE__ */ jsxs(Popover, { children: [
2493
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsxs(Button, { type: "button", variant: "ghost", size: "sm", className: "h-6 gap-1 px-2 text-xs text-muted-foreground", "data-testid": "genesis-chat-scheduled-count", children: [
2494
+ /* @__PURE__ */ jsx(CalendarClock, { className: "size-3.5", "aria-hidden": true }),
2495
+ scheduled.items.length === 1 ? "1 scheduled message" : `${scheduled.items.length} scheduled messages`
2496
+ ] }) }),
2497
+ /* @__PURE__ */ jsx(PopoverContent, { align: "start", className: "w-80 p-0", children: /* @__PURE__ */ jsx(ChatScheduledList, { conversationId }) })
2498
+ ] }) }) : null,
1426
2499
  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
2500
  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
2501
  /* @__PURE__ */ jsx(FileText, { className: "size-4 shrink-0", "aria-hidden": true }),
@@ -1485,6 +2558,7 @@ function ChatComposer({
1485
2558
  }
1486
2559
  ),
1487
2560
  /* @__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, {}) }) }),
2561
+ canRecordVideo && conversationId ? /* @__PURE__ */ jsx(Tooltip, { content: "Record a video", children: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "shrink-0 rounded-full text-muted-foreground", onClick: () => setRecordingVideo(true), "aria-label": "Record a video", "data-testid": "genesis-chat-video-button", children: /* @__PURE__ */ jsx(Video, {}) }) }) : null,
1488
2562
  accessory,
1489
2563
  state.role === "observer" ? /* @__PURE__ */ jsx(Tooltip, { content: internal ? "Internal note: only observers see it" : "Write an internal note", children: /* @__PURE__ */ jsx(
1490
2564
  Toggle,
@@ -1518,7 +2592,26 @@ function ChatComposer({
1518
2592
  "data-testid": "genesis-chat-input"
1519
2593
  }
1520
2594
  ),
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(
2595
+ emoji && canPickEmoji(emojibaseUrl) ? /* @__PURE__ */ jsxs(Popover, { open: emojiOpen, onOpenChange: setEmojiOpen, children: [
2596
+ /* @__PURE__ */ jsx(PopoverTrigger, { asChild: true, children: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "shrink-0 rounded-full text-muted-foreground", "aria-label": "Add emoji", "data-testid": "genesis-chat-emoji-button", children: /* @__PURE__ */ jsx(Smile, {}) }) }),
2597
+ /* @__PURE__ */ jsx(PopoverContent, { side: "top", align: "end", className: "w-auto overflow-hidden p-0", onCloseAutoFocus: (e) => {
2598
+ e.preventDefault();
2599
+ textarea.current?.focus();
2600
+ }, children: /* @__PURE__ */ jsx(
2601
+ ChatEmojiPicker,
2602
+ {
2603
+ emojibaseUrl,
2604
+ onSelect: (e) => {
2605
+ insertText(e);
2606
+ setEmojiOpen(false);
2607
+ }
2608
+ }
2609
+ ) })
2610
+ ] }) : null,
2611
+ hasContent ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
2612
+ /* @__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, {}) }),
2613
+ scheduling && conversationId && !internal ? /* @__PURE__ */ jsx(ChatScheduleMenu, { onSchedule: (at) => void scheduleAt(at), disabled: schedulingNow }) : null
2614
+ ] }) : /* @__PURE__ */ jsx(Tooltip, { content: "Record a voice message", children: /* @__PURE__ */ jsx(
1522
2615
  Button,
1523
2616
  {
1524
2617
  type: "button",
@@ -1531,7 +2624,127 @@ function ChatComposer({
1531
2624
  children: /* @__PURE__ */ jsx(Mic, {})
1532
2625
  }
1533
2626
  ) })
1534
- ] })
2627
+ ] }),
2628
+ canRecordVideo && conversationId ? /* @__PURE__ */ jsx(
2629
+ ChatVideoRecorder,
2630
+ {
2631
+ open: recordingVideo,
2632
+ onOpenChange: setRecordingVideo,
2633
+ conversationId,
2634
+ maxMs: recordingLimitMs(state.role),
2635
+ send: (input, files) => state.send({ ...input, ...common() }, files),
2636
+ videoUpload,
2637
+ onTick: state.signalRecordingVideo ?? state.signalRecording,
2638
+ onSent: () => {
2639
+ onCancelReply?.();
2640
+ onSent?.();
2641
+ }
2642
+ }
2643
+ ) : null
2644
+ ] });
2645
+ }
2646
+ var ALL = "__all__";
2647
+ function ChatTopicBar({ topics, value, onValueChange, canManage = false, onSetTopic, className }) {
2648
+ const [creating, setCreating] = useState(false);
2649
+ const [title, setTitle] = useState("");
2650
+ const [busy, setBusy] = useState(false);
2651
+ const current = value ? topics.find((t) => t.topic === value) : void 0;
2652
+ const create = async () => {
2653
+ const name = title.trim();
2654
+ if (!name || !onSetTopic) return;
2655
+ const key = topicKey(name);
2656
+ if (topics.some((t) => t.topic === key)) {
2657
+ toast.error("There's already a topic with that name");
2658
+ return;
2659
+ }
2660
+ setBusy(true);
2661
+ try {
2662
+ await onSetTopic(key, { title: name, position: topics.length });
2663
+ setCreating(false);
2664
+ setTitle("");
2665
+ onValueChange(key);
2666
+ } catch (e) {
2667
+ toast.error(e instanceof Error ? e.message : "Couldn't add the topic");
2668
+ } finally {
2669
+ setBusy(false);
2670
+ }
2671
+ };
2672
+ const toggleClosed = async (t) => {
2673
+ try {
2674
+ await onSetTopic?.(t.topic, { closed: !t.closedAt });
2675
+ toast.success(t.closedAt ? `Reopened ${t.title}` : `Closed ${t.title}`);
2676
+ } catch (e) {
2677
+ toast.error(e instanceof Error ? e.message : "Couldn't change the topic");
2678
+ }
2679
+ };
2680
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex items-center gap-1 border-b px-2 py-1.5", className), "data-testid": "genesis-chat-topic-bar", children: [
2681
+ /* @__PURE__ */ jsxs(
2682
+ ToggleGroup,
2683
+ {
2684
+ type: "single",
2685
+ value: value ?? ALL,
2686
+ onValueChange: (v) => {
2687
+ if (v) onValueChange(v === ALL ? null : v);
2688
+ },
2689
+ "aria-label": "Topics",
2690
+ className: "flex min-w-0 flex-1 justify-start gap-1 overflow-x-auto [scrollbar-width:none]",
2691
+ children: [
2692
+ /* @__PURE__ */ jsx(ToggleGroupItem, { value: ALL, size: "sm", className: "h-7 shrink-0 rounded-full px-3 text-xs", "data-testid": "genesis-chat-topic-all", children: "All" }),
2693
+ topics.map((t) => /* @__PURE__ */ jsxs(
2694
+ ToggleGroupItem,
2695
+ {
2696
+ value: t.topic,
2697
+ size: "sm",
2698
+ className: "h-7 shrink-0 gap-1 rounded-full px-3 text-xs",
2699
+ "aria-label": t.closedAt ? `${t.title} (closed)` : t.title,
2700
+ "data-testid": `genesis-chat-topic-${t.topic}`,
2701
+ children: [
2702
+ t.closedAt ? /* @__PURE__ */ jsx(Lock, { className: "size-3", "aria-hidden": true }) : /* @__PURE__ */ jsx(Hash, { className: "size-3 opacity-60", "aria-hidden": true }),
2703
+ /* @__PURE__ */ jsx("span", { className: "max-w-[10rem] truncate", children: t.title })
2704
+ ]
2705
+ },
2706
+ t.topic
2707
+ ))
2708
+ ]
2709
+ }
2710
+ ),
2711
+ canManage && onSetTopic ? /* @__PURE__ */ jsxs(DropdownMenu, { children: [
2712
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "size-7 shrink-0 rounded-full", "aria-label": "Topic options", "data-testid": "genesis-chat-topic-menu", children: /* @__PURE__ */ jsx(MoreHorizontal, {}) }) }),
2713
+ /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "end", className: "w-48", children: [
2714
+ /* @__PURE__ */ jsxs(DropdownMenuItem, { className: "gap-2", onSelect: () => setCreating(true), "data-testid": "genesis-chat-topic-new", children: [
2715
+ /* @__PURE__ */ jsx(Plus, { className: "size-4", "aria-hidden": true }),
2716
+ " New topic"
2717
+ ] }),
2718
+ current ? /* @__PURE__ */ jsxs(DropdownMenuItem, { className: "gap-2", onSelect: () => void toggleClosed(current), "data-testid": "genesis-chat-topic-close", children: [
2719
+ current.closedAt ? /* @__PURE__ */ jsx(LockOpen, { className: "size-4", "aria-hidden": true }) : /* @__PURE__ */ jsx(Lock, { className: "size-4", "aria-hidden": true }),
2720
+ current.closedAt ? "Reopen topic" : "Close topic"
2721
+ ] }) : null
2722
+ ] })
2723
+ ] }) : null,
2724
+ /* @__PURE__ */ jsx(Dialog, { open: creating, onOpenChange: (o) => !busy && setCreating(o), children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-sm", children: [
2725
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [
2726
+ /* @__PURE__ */ jsx(DialogTitle, { children: "New topic" }),
2727
+ /* @__PURE__ */ jsx(DialogDescription, { children: "Messages posted in a topic stay together, under its name." })
2728
+ ] }),
2729
+ /* @__PURE__ */ jsxs(
2730
+ "form",
2731
+ {
2732
+ className: "flex flex-col gap-2",
2733
+ onSubmit: (e) => {
2734
+ e.preventDefault();
2735
+ void create();
2736
+ },
2737
+ children: [
2738
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-topic-title", children: "Name" }),
2739
+ /* @__PURE__ */ jsx(Input, { id: "genesis-chat-topic-title", value: title, onChange: (e) => setTitle(e.target.value), maxLength: 160, autoFocus: true, placeholder: "Week 1: Habits" }),
2740
+ /* @__PURE__ */ jsxs(DialogFooter, { className: "mt-2", children: [
2741
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => setCreating(false), children: "Cancel" }),
2742
+ /* @__PURE__ */ jsx(Button, { type: "submit", loading: busy, disabled: !title.trim(), "data-testid": "genesis-chat-topic-create", children: "Add topic" })
2743
+ ] })
2744
+ ]
2745
+ }
2746
+ )
2747
+ ] }) })
1535
2748
  ] });
1536
2749
  }
1537
2750
  var REPORT_REASONS = [
@@ -1549,7 +2762,7 @@ function errorText(e) {
1549
2762
  }
1550
2763
  return e instanceof Error ? e.message : "Something went wrong";
1551
2764
  }
1552
- function DefaultHeader({ title, subtitle, otherUserId, resolveUser, onBack, backClassName }) {
2765
+ function DefaultHeader({ title, subtitle, otherUserId, resolveUser, onBack, backClassName, onSearch }) {
1553
2766
  const other = otherUserId ? resolveUser(otherUserId) : null;
1554
2767
  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
2768
  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,
@@ -1557,7 +2770,8 @@ function DefaultHeader({ title, subtitle, otherUserId, resolveUser, onBack, back
1557
2770
  /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
1558
2771
  /* @__PURE__ */ jsx("h2", { className: "truncate text-sm font-semibold leading-tight", children: title }),
1559
2772
  subtitle ? /* @__PURE__ */ jsx("p", { className: "truncate text-xs text-muted-foreground", children: subtitle }) : null
1560
- ] })
2773
+ ] }),
2774
+ onSearch ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "ghost", size: "icon", className: "shrink-0", onClick: onSearch, "aria-label": "Search this conversation", "data-testid": "genesis-chat-thread-search", children: /* @__PURE__ */ jsx(Search, {}) }) : null
1561
2775
  ] });
1562
2776
  }
1563
2777
  function DefaultEmpty2({ canPost }) {
@@ -1570,14 +2784,25 @@ function ChatThread({
1570
2784
  onBack,
1571
2785
  backClassName,
1572
2786
  threadRootId,
1573
- topic,
2787
+ topic: topicProp,
1574
2788
  onOpenThread,
1575
2789
  quickReactions = QUICK_REACTIONS,
1576
2790
  placeholder,
2791
+ showTopics,
2792
+ jumpTo: jumpToProp,
2793
+ search = true,
2794
+ scheduling = true,
2795
+ availability = true,
2796
+ emojibaseUrl,
2797
+ linkPreviewImages = "off",
2798
+ linkPreviewProxyUrl,
1577
2799
  slots,
1578
2800
  className
1579
2801
  }) {
1580
- const { userId: me } = useChat();
2802
+ const { userId: me, attachmentKinds } = useChat();
2803
+ const topicsState = useTopics(threadRootId ? null : conversationId);
2804
+ const [chosenTopic, setChosenTopic] = useState(null);
2805
+ const topic = topicProp ?? chosenTopic ?? void 0;
1581
2806
  const state = useConversation(conversationId, { threadRootId, topic });
1582
2807
  const actions = useMessageActions();
1583
2808
  const pinIds = usePins(conversationId);
@@ -1602,7 +2827,20 @@ function ChatThread({
1602
2827
  setReplyTo(null);
1603
2828
  setEditingId(null);
1604
2829
  setNewCount(0);
2830
+ setChosenTopic(null);
1605
2831
  }, [conversationId]);
2832
+ const topics = topicsState.topics;
2833
+ const currentTopic = topic ? topics.find((t) => t.topic === topic) : void 0;
2834
+ const canManageTopics = role === "owner" || role === "admin";
2835
+ const showTopicBar = !threadRootId && topicProp === void 0 && (topics.length > 0 || showTopics && canManageTopics);
2836
+ const topicClosed = Boolean(currentTopic?.closedAt) && role !== "owner" && role !== "admin" && role !== "observer";
2837
+ const linkMessageIds = useMemo(
2838
+ () => messages.filter((m) => !m.deletedAt && m.moderation !== "hidden" && extractLinks(m.body).length > 0).map((m) => m.id),
2839
+ [messages]
2840
+ );
2841
+ const previews = useLinkPreviews(linkMessageIds);
2842
+ const avail = useAvailability(availability ? conversationId : null);
2843
+ const [searching, setSearching] = useState(false);
1606
2844
  useCallback((id) => id === me ? "You" : resolveUser(id).name, [me, resolveUser]);
1607
2845
  const others = members.filter((m) => m.userId !== me);
1608
2846
  const otherUserId = members.length === 2 && others.length === 1 ? others[0].userId : null;
@@ -1614,6 +2852,15 @@ function ChatThread({
1614
2852
  (id) => resolveUser(id).name
1615
2853
  );
1616
2854
  const subtitle = members.length > 2 ? `${members.length} members` : null;
2855
+ const awayPeople = useMemo(() => {
2856
+ if (!availability) return null;
2857
+ const ids = otherUserId ? [otherUserId] : members.filter((m) => m.userId !== me).map((m) => m.userId);
2858
+ const people = ids.flatMap((id) => {
2859
+ const a = avail.forUser(id);
2860
+ return a ? [{ availability: a, name: resolveUser(id).name }] : [];
2861
+ });
2862
+ return awaySummary(people) ? people : null;
2863
+ }, [availability, otherUserId, members, me, avail, resolveUser]);
1617
2864
  const scroller = useRef(null);
1618
2865
  const content = useRef(null);
1619
2866
  const atBottom = useRef(true);
@@ -1693,6 +2940,40 @@ function ChatThread({
1693
2940
  setHighlightedId(id);
1694
2941
  setTimeout(() => setHighlightedId((h) => h === id ? null : h), 1600);
1695
2942
  }, []);
2943
+ const [pendingJump, setPendingJump] = useState(jumpToProp ? { ...jumpToProp, phase: "new" } : null);
2944
+ useEffect(() => {
2945
+ if (jumpToProp) setPendingJump({ ...jumpToProp, phase: "new" });
2946
+ }, [jumpToProp?.messageId, jumpToProp?.seq]);
2947
+ const { loadUntil } = state;
2948
+ useEffect(() => {
2949
+ if (!pendingJump || state.isLoading) return;
2950
+ const { messageId, seq, phase } = pendingJump;
2951
+ if (byId.has(messageId)) {
2952
+ setPendingJump(null);
2953
+ requestAnimationFrame(() => jumpTo(messageId));
2954
+ return;
2955
+ }
2956
+ if (phase === "loading") return;
2957
+ if (phase === "loaded" || seq == null) {
2958
+ setPendingJump(null);
2959
+ toast.info("That message isn't here any more");
2960
+ return;
2961
+ }
2962
+ setPendingJump({ messageId, seq, phase: "loading" });
2963
+ const settle = () => setPendingJump((p) => p && p.messageId === messageId ? { ...p, phase: "loaded" } : p);
2964
+ void loadUntil(seq).then(settle, (e) => {
2965
+ toast.error(errorText(e));
2966
+ settle();
2967
+ });
2968
+ }, [pendingJump, byId, state.isLoading, loadUntil, jumpTo]);
2969
+ const openHit = useCallback(
2970
+ (hit) => {
2971
+ setSearching(false);
2972
+ if (topic && topicProp === void 0) setChosenTopic(null);
2973
+ setPendingJump({ messageId: hit.messageId, seq: hit.seq, phase: "new" });
2974
+ },
2975
+ [topic, topicProp]
2976
+ );
1696
2977
  const run = useCallback(async (p, done) => {
1697
2978
  try {
1698
2979
  await p;
@@ -1714,6 +2995,11 @@ function ChatThread({
1714
2995
  byId,
1715
2996
  quickReactions,
1716
2997
  attachmentSlot: slots?.attachment,
2998
+ attachmentKinds,
2999
+ previews,
3000
+ emojibaseUrl,
3001
+ linkPreviewImages,
3002
+ linkPreviewProxyUrl,
1717
3003
  highlightedId,
1718
3004
  editingId,
1719
3005
  reply: (m) => {
@@ -1790,7 +3076,13 @@ function ChatThread({
1790
3076
  /* @__PURE__ */ jsx("span", { className: "text-xs font-semibold text-primary", children: "New" }),
1791
3077
  /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-primary/40" })
1792
3078
  ] }) : null,
1793
- /* @__PURE__ */ jsx(MessageRow, { item, startsGroup: startsGroup(items, i) })
3079
+ /* @__PURE__ */ jsx(
3080
+ ChatErrorBoundary,
3081
+ {
3082
+ fallback: /* @__PURE__ */ jsx("p", { className: "px-4 py-1 text-xs italic text-muted-foreground", role: "note", "data-testid": "genesis-chat-row-error", children: "This message couldn't be shown." }),
3083
+ children: /* @__PURE__ */ jsx(MessageRow, { item, startsGroup: startsGroup(items, i) })
3084
+ }
3085
+ )
1794
3086
  ] }, itemKey(item));
1795
3087
  })
1796
3088
  ] });
@@ -1815,8 +3107,20 @@ function ChatThread({
1815
3107
  role,
1816
3108
  resolveUser,
1817
3109
  onBack,
1818
- backClassName
3110
+ backClassName,
3111
+ onSearch: search && !threadRootId ? () => setSearching(true) : void 0
1819
3112
  }),
3113
+ showTopicBar ? /* @__PURE__ */ jsx(
3114
+ ChatTopicBar,
3115
+ {
3116
+ topics,
3117
+ value: topic ?? null,
3118
+ onValueChange: setChosenTopic,
3119
+ canManage: canManageTopics,
3120
+ onSetTopic: topicsState.setTopic
3121
+ }
3122
+ ) : null,
3123
+ awayPeople ? /* @__PURE__ */ jsx(ChatAwayBanner, { people: awayPeople }) : null,
1820
3124
  pinnedMessage ? /* @__PURE__ */ jsxs(
1821
3125
  "button",
1822
3126
  {
@@ -1872,7 +3176,11 @@ function ChatThread({
1872
3176
  replyTo,
1873
3177
  onCancelReply: () => setReplyTo(null),
1874
3178
  resolveUser,
1875
- placeholder,
3179
+ placeholder: currentTopic ? `Message #${currentTopic.title}` : placeholder,
3180
+ topic: topic ?? null,
3181
+ disabledReason: topicClosed ? "This topic is closed" : null,
3182
+ scheduling,
3183
+ emojibaseUrl,
1876
3184
  accessory: renderSlot(EmptyAccessory, slots?.composerAccessory, {
1877
3185
  conversationId,
1878
3186
  insertText: (t) => composer.current?.insertText(t),
@@ -1883,6 +3191,13 @@ function ChatThread({
1883
3191
  ]
1884
3192
  }
1885
3193
  ),
3194
+ search && !threadRootId ? /* @__PURE__ */ jsx(Sheet, { open: searching, onOpenChange: setSearching, children: /* @__PURE__ */ jsxs(SheetContent, { side: "right", className: "flex w-full flex-col gap-0 p-0 sm:max-w-md", children: [
3195
+ /* @__PURE__ */ jsxs(SheetHeader, { className: "border-b px-4 py-3", children: [
3196
+ /* @__PURE__ */ jsx(SheetTitle, { children: "Search" }),
3197
+ /* @__PURE__ */ jsx(SheetDescription, { className: "sr-only", children: "Search this conversation and jump to a message." })
3198
+ ] }),
3199
+ /* @__PURE__ */ jsx(ChatSearch, { conversationId, resolveUser, onOpen: openHit, autoFocus: true, className: "min-h-0 flex-1" })
3200
+ ] }) }) : null,
1886
3201
  /* @__PURE__ */ jsx(AlertDialog, { open: confirmDelete !== null, onOpenChange: (o) => !o && setConfirmDelete(null), children: /* @__PURE__ */ jsxs(AlertDialogContent, { children: [
1887
3202
  /* @__PURE__ */ jsxs(AlertDialogHeader, { children: [
1888
3203
  /* @__PURE__ */ jsx(AlertDialogTitle, { children: "Delete this message?" }),
@@ -1980,9 +3295,17 @@ function ChatPanel({
1980
3295
  inboxProps,
1981
3296
  threadProps,
1982
3297
  slots,
3298
+ search = true,
1983
3299
  className
1984
3300
  }) {
1985
3301
  const [own, setOwn] = useState(defaultSelectedId);
3302
+ const [searching, setSearching] = useState(false);
3303
+ const [jump, setJump] = useState(null);
3304
+ const { items } = useInbox({ kind: inboxProps?.kind });
3305
+ const titleOf = (id) => {
3306
+ const item = items.find((i) => i.conversationId === id);
3307
+ return item ? inboxTitle(item, resolveUser, inboxProps?.fallbackTitle) : "a conversation";
3308
+ };
1986
3309
  const selected = controlled !== void 0 ? controlled : own;
1987
3310
  const select = (id) => {
1988
3311
  if (controlled === void 0) setOwn(id);
@@ -2002,10 +3325,41 @@ function ChatPanel({
2002
3325
  className
2003
3326
  ),
2004
3327
  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) })
3328
+ inboxHeader || search ? /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-2 border-b px-3 py-2", children: [
3329
+ /* @__PURE__ */ jsx("div", { className: "min-w-0 flex-1", children: inboxHeader }),
3330
+ search ? /* @__PURE__ */ jsx(
3331
+ Button,
3332
+ {
3333
+ type: "button",
3334
+ variant: "ghost",
3335
+ size: "icon",
3336
+ className: "size-8 shrink-0",
3337
+ onClick: () => setSearching((s) => !s),
3338
+ "aria-label": searching ? "Close search" : "Search messages",
3339
+ "aria-pressed": searching,
3340
+ "data-testid": "genesis-chat-panel-search",
3341
+ children: searching ? /* @__PURE__ */ jsx(X, {}) : /* @__PURE__ */ jsx(Search, {})
3342
+ }
3343
+ ) : null
3344
+ ] }) : null,
3345
+ searching ? /* @__PURE__ */ jsx(
3346
+ ChatSearch,
3347
+ {
3348
+ resolveUser,
3349
+ autoFocus: true,
3350
+ conversationTitle: titleOf,
3351
+ onOpen: (hit) => {
3352
+ setJump({ messageId: hit.messageId, seq: hit.seq });
3353
+ select(hit.conversationId);
3354
+ },
3355
+ className: "min-h-0 flex-1"
3356
+ }
3357
+ ) : /* @__PURE__ */ jsx(ChatInbox, { ...inboxProps, resolveUser, selectedId: selected, onSelect: (id) => {
3358
+ setJump(null);
3359
+ select(id);
3360
+ }, className: cn("min-h-0 flex-1 overflow-y-auto", inboxProps?.className) })
2007
3361
  ] }),
2008
- end: selected ? /* @__PURE__ */ jsx(ChatThread, { ...threadProps, conversationId: selected, resolveUser, onBack: () => select(null), backClassName: "md:hidden" }, selected) : renderSlot(NoSelection, slots?.noSelection, {})
3362
+ end: selected ? /* @__PURE__ */ jsx(ChatThread, { ...threadProps, jumpTo: jump, conversationId: selected, resolveUser, onBack: () => select(null), backClassName: "md:hidden" }, selected) : renderSlot(NoSelection, slots?.noSelection, {})
2009
3363
  }
2010
3364
  );
2011
3365
  }
@@ -2075,6 +3429,7 @@ function createMemoryChatClient(options) {
2075
3429
  createdAt,
2076
3430
  editedAt: m.editedAt ?? null,
2077
3431
  deletedAt: m.deletedAt ?? null,
3432
+ moderation: m.moderation ?? null,
2078
3433
  attachments: (m.attachments ?? []).map((a, i) => attachment(id, a, i)),
2079
3434
  reactions: m.reactions ?? []
2080
3435
  };
@@ -2107,7 +3462,16 @@ function createMemoryChatClient(options) {
2107
3462
  muted: seed.muted ?? false,
2108
3463
  pinned: seed.pinned ?? false,
2109
3464
  messages: [],
2110
- pins: seed.pins ?? []
3465
+ pins: seed.pins ?? [],
3466
+ topics: (seed.topics ?? []).map((t, i) => ({
3467
+ conversationId: seed.id,
3468
+ topic: t.topic,
3469
+ title: t.title ?? t.topic,
3470
+ position: i,
3471
+ closedAt: t.closed ? (/* @__PURE__ */ new Date()).toISOString() : null,
3472
+ createdBy: null,
3473
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3474
+ }))
2111
3475
  };
2112
3476
  for (const m of seed.messages ?? []) c.messages.push(makeMessage(c, m));
2113
3477
  convs.set(seed.id, c);
@@ -2124,7 +3488,20 @@ function createMemoryChatClient(options) {
2124
3488
  }
2125
3489
  throw new ChatError("Message not found", "42501", "message");
2126
3490
  };
2127
- const visible = (c, m) => m.visibility !== "observers" || c.myRole === "observer";
3491
+ const visible = (c, m) => (m.visibility !== "observers" || c.myRole === "observer") && (m.moderation !== "hidden" || m.senderId === me || c.myRole === "observer");
3492
+ const scheduledList = [];
3493
+ const availability = new Map(
3494
+ Object.entries(options.availability ?? {}).map(([userId, a]) => [
3495
+ userId,
3496
+ { userId, awayMessage: a.awayMessage ?? null, awayUntil: a.awayUntil ?? null, timeZone: a.timeZone ?? null, hours: a.hours ?? null }
3497
+ ])
3498
+ );
3499
+ const previews = new Map(
3500
+ (options.linkPreviews ?? []).filter((p) => p.url).map((p) => [
3501
+ p.url,
3502
+ { url: p.url, status: p.status ?? "ok", title: p.title ?? null, description: p.description ?? null, imageUrl: p.imageUrl ?? null, siteName: p.siteName ?? null, fetchedAt: p.fetchedAt ?? (/* @__PURE__ */ new Date()).toISOString() }
3503
+ ])
3504
+ );
2128
3505
  const clone = (m) => ({ ...m, attachments: m.attachments.map((a) => ({ ...a })), reactions: [...m.reactions] });
2129
3506
  const emit = (conversationId, s) => {
2130
3507
  for (const l of convListeners.get(conversationId) ?? []) l.onSignal(s);
@@ -2175,13 +3552,13 @@ function createMemoryChatClient(options) {
2175
3552
  log("inbox", opts);
2176
3553
  await wait();
2177
3554
  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 ?? ""));
3555
+ return [...convs.values()].filter((c) => keepKind(c.conversation.kind, opts)).map(inboxItem).sort((a, b) => Number(b.pinned) - Number(a.pinned) || (b.lastMessageAt ?? "").localeCompare(a.lastMessageAt ?? ""));
2179
3556
  },
2180
3557
  async observed() {
2181
3558
  return [];
2182
3559
  },
2183
- async unreadTotal() {
2184
- return [...convs.values()].reduce((n, c) => n + inboxItem(c).unread, 0);
3560
+ async unreadTotal(opts = {}) {
3561
+ return [...convs.values()].filter((c) => keepKind(c.conversation.kind, opts)).reduce((n, c) => n + inboxItem(c).unread, 0);
2185
3562
  },
2186
3563
  async conversation(id) {
2187
3564
  await wait();
@@ -2242,7 +3619,8 @@ function createMemoryChatClient(options) {
2242
3619
  muted: false,
2243
3620
  pinned: false,
2244
3621
  messages: [],
2245
- pins: []
3622
+ pins: [],
3623
+ topics: []
2246
3624
  });
2247
3625
  for (const l of inboxListeners) l({ type: "inbox", conversationId: id });
2248
3626
  return id;
@@ -2261,6 +3639,10 @@ function createMemoryChatClient(options) {
2261
3639
  }
2262
3640
  const c = get(input.conversationId);
2263
3641
  if (!c.canPost) throw new ChatError("You can't post here", "42501", "send_message");
3642
+ const topic = input.topic ? c.topics.find((t) => t.topic === input.topic) : void 0;
3643
+ if (topic?.closedAt && !["owner", "admin", "observer"].includes(c.myRole ?? "member")) {
3644
+ throw new ChatError("This topic is closed", "42501", "send_message");
3645
+ }
2264
3646
  const existing = c.messages.find((m2) => m2.clientId === input.clientId);
2265
3647
  if (existing) return clone(existing);
2266
3648
  const m = makeMessage(c, {
@@ -2371,6 +3753,9 @@ function createMemoryChatClient(options) {
2371
3753
  convListeners.set(conversationId, set);
2372
3754
  const entry = { onSignal: handlers.onSignal, onActivity: handlers.onActivity };
2373
3755
  set.add(entry);
3756
+ setTimeout(() => {
3757
+ if (set.has(entry)) handlers.onJoin?.();
3758
+ }, 0);
2374
3759
  return {
2375
3760
  signal(activity) {
2376
3761
  log("signal", conversationId, activity);
@@ -2378,6 +3763,172 @@ function createMemoryChatClient(options) {
2378
3763
  unsubscribe: () => void set.delete(entry)
2379
3764
  };
2380
3765
  },
3766
+ // ── 0002 ──
3767
+ async topics(conversationId) {
3768
+ log("topics", conversationId);
3769
+ await wait();
3770
+ return [...convs.get(conversationId)?.topics ?? []].sort((a, b) => a.position - b.position || a.topic.localeCompare(b.topic)).map((t) => ({ ...t }));
3771
+ },
3772
+ async setTopic(conversationId, topic, input = {}) {
3773
+ log("setTopic", conversationId, topic, input);
3774
+ const c = get(conversationId);
3775
+ if (c.myRole !== "owner" && c.myRole !== "admin") throw new ChatError("Only an owner or admin manages topics", "42501", "set_topic");
3776
+ const key = topic.trim();
3777
+ let t = c.topics.find((x) => x.topic === key);
3778
+ if (!t) {
3779
+ t = { conversationId, topic: key, title: input.title?.trim() || key, position: input.position ?? c.topics.length, closedAt: null, createdBy: me, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
3780
+ c.topics.push(t);
3781
+ } else {
3782
+ if (input.title?.trim()) t.title = input.title.trim();
3783
+ if (input.position != null) t.position = input.position;
3784
+ }
3785
+ if (input.closed === true) t.closedAt ??= (/* @__PURE__ */ new Date()).toISOString();
3786
+ if (input.closed === false) t.closedAt = null;
3787
+ emit(conversationId, { type: "conversation", conversationId });
3788
+ },
3789
+ async schedule(input) {
3790
+ log("schedule", input);
3791
+ await wait();
3792
+ const c = get(input.conversationId);
3793
+ if (!c.canPost) throw new ChatError("You can't post in this conversation", "42501", "schedule_message");
3794
+ const sendAt = typeof input.sendAt === "string" ? input.sendAt : input.sendAt.toISOString();
3795
+ const t = Date.parse(sendAt);
3796
+ if (!(t >= Date.now() + 6e4 && t <= Date.now() + 365 * 864e5)) throw new ChatError("Schedule between a minute and a year from now", "22023", "schedule_message");
3797
+ if (!(input.body ?? "").trim() && !(input.attachments ?? []).length) throw new ChatError("A message needs text or an attachment", "22023", "schedule_message");
3798
+ const existing = scheduledList.find((s2) => s2.clientId === input.clientId);
3799
+ if (existing) {
3800
+ existing.sendAt = sendAt;
3801
+ return existing.id;
3802
+ }
3803
+ const s = {
3804
+ id: uuid(),
3805
+ conversationId: input.conversationId,
3806
+ clientId: input.clientId,
3807
+ body: input.body ?? "",
3808
+ attachments: input.attachments ?? [],
3809
+ replyToId: input.replyToId ?? null,
3810
+ topic: input.topic ?? null,
3811
+ mentions: input.mentions ?? [],
3812
+ silent: input.silent ?? false,
3813
+ sendAt,
3814
+ sentMessageId: null,
3815
+ cancelledAt: null,
3816
+ error: null,
3817
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3818
+ status: "scheduled"
3819
+ };
3820
+ scheduledList.push(s);
3821
+ return s.id;
3822
+ },
3823
+ async scheduled(conversationId) {
3824
+ log("scheduled", conversationId);
3825
+ await wait();
3826
+ return scheduledList.filter((s) => (s.status === "scheduled" || s.status === "failed") && (!conversationId || s.conversationId === conversationId)).sort((a, b) => a.sendAt.localeCompare(b.sendAt)).map((s) => ({ ...s }));
3827
+ },
3828
+ async cancelScheduled(id) {
3829
+ log("cancelScheduled", id);
3830
+ const s = scheduledList.find((x) => x.id === id && x.status === "scheduled");
3831
+ if (!s) throw new ChatError("Nothing to cancel", "22023", "cancel_scheduled");
3832
+ s.cancelledAt = (/* @__PURE__ */ new Date()).toISOString();
3833
+ s.status = "cancelled";
3834
+ },
3835
+ async availabilityFor(conversationId) {
3836
+ log("availabilityFor", conversationId);
3837
+ const c = get(conversationId);
3838
+ return c.members.filter((m) => m.role !== "observer" && availability.has(m.userId)).map((m) => ({ ...availability.get(m.userId) }));
3839
+ },
3840
+ async myAvailability() {
3841
+ const a = availability.get(me);
3842
+ return a ? { ...a } : null;
3843
+ },
3844
+ async setAvailability(input) {
3845
+ log("setAvailability", input);
3846
+ availability.set(me, {
3847
+ userId: me,
3848
+ awayMessage: input.awayMessage?.trim() || null,
3849
+ awayUntil: input.awayUntil ?? null,
3850
+ timeZone: input.timeZone ?? null,
3851
+ hours: input.hours ?? null
3852
+ });
3853
+ },
3854
+ async broadcast(conversationIds, clientId, body, opts = {}) {
3855
+ log("broadcast", conversationIds, clientId, body);
3856
+ await wait();
3857
+ const ids = [];
3858
+ for (const id of new Set(conversationIds)) {
3859
+ const c = convs.get(id);
3860
+ if (!c?.canPost) continue;
3861
+ const m = await client.send({ conversationId: id, clientId: `${clientId}:${id}`, body, silent: opts.silent });
3862
+ ids.push(m.id);
3863
+ }
3864
+ return ids;
3865
+ },
3866
+ async search(query, conversationId) {
3867
+ log("search", query, conversationId);
3868
+ await wait();
3869
+ const q = query.trim().toLowerCase();
3870
+ if (q.length < 2) return [];
3871
+ const hits = [];
3872
+ const mark = (raw) => {
3873
+ const text = raw.replace(/«/g, "\u2039").replace(/»/g, "\u203A");
3874
+ const i = text.toLowerCase().indexOf(q);
3875
+ const start = Math.max(0, i - 40);
3876
+ return `${start > 0 ? "\u2026" : ""}${text.slice(start, i)}\xAB${text.slice(i, i + q.length)}\xBB${text.slice(i + q.length, i + q.length + 60)}`;
3877
+ };
3878
+ for (const c of convs.values()) {
3879
+ if (conversationId && c.conversation.id !== conversationId) continue;
3880
+ for (const m of c.messages) {
3881
+ if (m.deletedAt || !visible(c, m)) continue;
3882
+ const base = { messageId: m.id, conversationId: c.conversation.id, seq: m.seq, senderId: m.senderId, createdAt: m.createdAt };
3883
+ if (m.body.toLowerCase().includes(q)) hits.push({ ...base, snippet: mark(m.body), inTranscript: false });
3884
+ else {
3885
+ const t = m.attachments.find((a) => a.transcript?.toLowerCase().includes(q))?.transcript;
3886
+ if (t) hits.push({ ...base, snippet: mark(t), inTranscript: true });
3887
+ }
3888
+ }
3889
+ }
3890
+ return hits.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 30);
3891
+ },
3892
+ async linkPreviewsFor(messageIds) {
3893
+ log("linkPreviewsFor", messageIds);
3894
+ const out = {};
3895
+ for (const id of new Set(messageIds)) {
3896
+ let found = null;
3897
+ try {
3898
+ found = findMessage(id);
3899
+ } catch {
3900
+ found = null;
3901
+ }
3902
+ if (!found || found.m.deletedAt || !visible(found.c, found.m)) {
3903
+ out[id] = [];
3904
+ continue;
3905
+ }
3906
+ out[id] = extractLinks(found.m.body).map((u) => previews.get(u)).filter((p) => Boolean(p && p.status === "ok")).map((p) => ({ ...p }));
3907
+ }
3908
+ return out;
3909
+ },
3910
+ async sendDue(now = /* @__PURE__ */ new Date()) {
3911
+ let n = 0;
3912
+ for (const s of scheduledList) {
3913
+ if (s.status !== "scheduled" || Date.parse(s.sendAt) > now.getTime()) continue;
3914
+ try {
3915
+ const m = await client.send({ conversationId: s.conversationId, clientId: s.clientId, body: s.body, attachments: s.attachments, replyToId: s.replyToId, topic: s.topic, mentions: s.mentions, silent: s.silent });
3916
+ s.sentMessageId = m.id;
3917
+ s.status = "sent";
3918
+ n += 1;
3919
+ } catch (e) {
3920
+ s.error = e instanceof Error ? e.message : String(e);
3921
+ s.status = "failed";
3922
+ }
3923
+ }
3924
+ return n;
3925
+ },
3926
+ moderate(messageId, result) {
3927
+ if (result === "clean") return;
3928
+ const { c, m } = findMessage(messageId);
3929
+ m.moderation = result;
3930
+ emit(c.conversation.id, { type: "message", conversationId: c.conversation.id, id: m.id, seq: m.seq, op: "update" });
3931
+ },
2381
3932
  receive(conversationId, senderId, body, extra = {}) {
2382
3933
  const c = get(conversationId);
2383
3934
  const m = makeMessage(c, { senderId, body, ...extra });
@@ -2399,7 +3950,127 @@ function createMemoryChatClient(options) {
2399
3950
  };
2400
3951
  return client;
2401
3952
  }
3953
+ function keepKind(kind, opts) {
3954
+ if (opts.kind && kind !== opts.kind) return false;
3955
+ if (opts.kinds?.length && !opts.kinds.includes(kind)) return false;
3956
+ return !opts.excludeKinds?.includes(kind);
3957
+ }
3958
+ function ChatBroadcast({ resolveUser, kind, filter, onSent, className }) {
3959
+ const { items, isLoading } = useInbox({ kind, limit: 200 });
3960
+ const { send, sending } = useBroadcast();
3961
+ const [picked, setPicked] = useState(/* @__PURE__ */ new Set());
3962
+ const [text, setText] = useState("");
3963
+ const [q, setQ] = useState("");
3964
+ const draftId = useRef(null);
3965
+ const busy = useRef(false);
3966
+ const rows = useMemo(
3967
+ () => items.filter((i) => filter ? filter(i) : true).map((i) => ({ item: i, title: inboxTitle(i, resolveUser) })).sort((a, b) => a.title.localeCompare(b.title)),
3968
+ [items, filter, resolveUser]
3969
+ );
3970
+ const shown = rows.filter((r) => !q.trim() || r.title.toLowerCase().includes(q.trim().toLowerCase()));
3971
+ const allShown = shown.length > 0 && shown.every((r) => picked.has(r.item.conversationId));
3972
+ const toggle = (id, on) => setPicked((p) => {
3973
+ const next = new Set(p);
3974
+ if (on) next.add(id);
3975
+ else next.delete(id);
3976
+ return next;
3977
+ });
3978
+ const submit = async () => {
3979
+ const body = text.trim();
3980
+ if (!body || picked.size === 0 || busy.current) return;
3981
+ busy.current = true;
3982
+ draftId.current ??= randomId();
3983
+ try {
3984
+ const ids = await send([...picked], body, { clientId: draftId.current });
3985
+ draftId.current = null;
3986
+ const n = ids.length;
3987
+ if (n === picked.size) toast.success(n === 1 ? "Sent to 1 conversation" : `Sent to ${n} conversations`);
3988
+ else toast.warning(`Sent to ${n} of ${picked.size}`, { description: "You can't post in the others." });
3989
+ onSent?.(n, picked.size);
3990
+ setText("");
3991
+ setPicked(/* @__PURE__ */ new Set());
3992
+ } catch (e) {
3993
+ toast.error(e instanceof Error ? e.message : "Couldn't send");
3994
+ } finally {
3995
+ busy.current = false;
3996
+ }
3997
+ };
3998
+ return /* @__PURE__ */ jsxs(
3999
+ "form",
4000
+ {
4001
+ className: cn("flex flex-col gap-3", className),
4002
+ onSubmit: (e) => {
4003
+ e.preventDefault();
4004
+ void submit();
4005
+ },
4006
+ "data-testid": "genesis-chat-broadcast",
4007
+ children: [
4008
+ /* @__PURE__ */ jsxs(Alert, { children: [
4009
+ /* @__PURE__ */ jsx(Megaphone, { className: "size-4", "aria-hidden": true }),
4010
+ /* @__PURE__ */ jsx(AlertDescription, { children: "Each person gets this in their own conversation with you, so replies come back to you privately. No one sees who else got it." })
4011
+ ] }),
4012
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
4013
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-broadcast-text", children: "Message" }),
4014
+ /* @__PURE__ */ jsx(
4015
+ Textarea,
4016
+ {
4017
+ id: "genesis-chat-broadcast-text",
4018
+ value: text,
4019
+ onChange: (e) => setText(e.target.value),
4020
+ maxLength: CHAT_LIMITS.bodyChars,
4021
+ className: "min-h-24",
4022
+ placeholder: "Write once, send to everyone you pick"
4023
+ }
4024
+ )
4025
+ ] }),
4026
+ /* @__PURE__ */ jsxs("fieldset", { className: "flex min-h-0 flex-col gap-2", children: [
4027
+ /* @__PURE__ */ jsx("legend", { className: "mb-1 text-sm font-medium", children: "Send to" }),
4028
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
4029
+ /* @__PURE__ */ jsxs("div", { className: "relative flex-1", children: [
4030
+ /* @__PURE__ */ jsx(Search, { className: "pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground", "aria-hidden": true }),
4031
+ /* @__PURE__ */ jsx(Input, { value: q, onChange: (e) => setQ(e.target.value), placeholder: "Filter", "aria-label": "Filter conversations", className: "pl-8" })
4032
+ ] }),
4033
+ /* @__PURE__ */ jsx(
4034
+ Button,
4035
+ {
4036
+ type: "button",
4037
+ variant: "ghost",
4038
+ size: "sm",
4039
+ disabled: shown.length === 0,
4040
+ onClick: () => setPicked((p) => {
4041
+ const next = new Set(p);
4042
+ for (const r of shown) {
4043
+ if (allShown) next.delete(r.item.conversationId);
4044
+ else next.add(r.item.conversationId);
4045
+ }
4046
+ return next;
4047
+ }),
4048
+ children: allShown ? "Clear" : "Select all"
4049
+ }
4050
+ )
4051
+ ] }),
4052
+ isLoading ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", "aria-busy": true, "aria-label": "Loading conversations", children: [
4053
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-10 w-full" }),
4054
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-10 w-full" })
4055
+ ] }) : shown.length === 0 ? /* @__PURE__ */ jsx(EmptyState, { icon: Megaphone, title: rows.length ? "No matches" : "No conversations yet" }) : /* @__PURE__ */ jsx("ul", { className: "max-h-72 overflow-y-auto rounded-md border", "aria-label": "Conversations", children: shown.map(({ item, title }) => {
4056
+ const id = `genesis-chat-broadcast-${item.conversationId}`;
4057
+ const avatar = item.otherUserId ? resolveUser(item.otherUserId).avatarUrl : null;
4058
+ return /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 border-b px-3 py-2 last:border-b-0", children: [
4059
+ /* @__PURE__ */ jsx(Checkbox, { id, checked: picked.has(item.conversationId), onCheckedChange: (v) => toggle(item.conversationId, v === true) }),
4060
+ /* @__PURE__ */ jsx(UserAvatar, { name: title, src: avatar, size: "sm" }),
4061
+ /* @__PURE__ */ jsx(Label, { htmlFor: id, className: "min-w-0 flex-1 cursor-pointer truncate font-normal", children: title })
4062
+ ] }, item.conversationId);
4063
+ }) })
4064
+ ] }),
4065
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
4066
+ /* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", "aria-live": "polite", children: picked.size === 0 ? "Pick who gets it" : picked.size === 1 ? "1 conversation" : `${picked.size} conversations` }),
4067
+ /* @__PURE__ */ jsx(Button, { type: "submit", loading: sending, disabled: !text.trim() || picked.size === 0, "data-testid": "genesis-chat-broadcast-send", children: picked.size > 1 ? `Send to ${picked.size}` : "Send" })
4068
+ ] })
4069
+ ]
4070
+ }
4071
+ );
4072
+ }
2402
4073
 
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 };
4074
+ export { ChatAttachmentCard, ChatAvailabilitySettings, ChatAwayBanner, ChatBroadcast, ChatComposer, ChatEmojiPicker, ChatErrorBoundary, ChatInbox, ChatLinkPreview, ChatMarkdown, ChatPanel, ChatScheduleMenu, ChatScheduledList, ChatSearch, ChatThread, ChatTopicBar, ChatVideoPlayer, ChatVideoRecorder, FileChip, GenericAttachment, ImageGrid, Lightbox, MessageAttachments, MessageRow, QUICK_REACTIONS, SearchSnippet, VideoAttachment, VoiceNote, canPickEmoji, clockTime, createMemoryChatClient, formatBytes, inboxTitle, isSafeHref, linkPreviewImageSrc, messageLabel, muxPosterUrl, muxStreamUrl, parseMarkdown, pickVideoMimeType, pickVoiceMimeType, plainText, relativeTime, toAttachmentView, toLocalInput, uploadVideo, useVideoRecorder, useVoiceRecorder, videoFile, voiceFile };
2404
4075
  //# sourceMappingURL=web.js.map
2405
4076
  //# sourceMappingURL=web.js.map