@marktiderman/genesis-chat 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.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-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.js.map +0 -1
package/dist/web.js CHANGED
@@ -1,11 +1,14 @@
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 { attachmentPreview } from './chunk-AZS3HNLM.js';
2
+ import { snippetParts, isHttpsUrl, scheduleQuickPicks, isSchedulable, scheduleLabel, extractLinks, topicKey } from './chunk-BJCE5O7V.js';
3
+ import { useChat, useInbox, useSearch, useScheduled, useAvailability, useTopics, useConversation, useMessageActions, usePins, useFileUrls, useLinkPreviews, useBroadcast } from './chunk-JU2FAPBI.js';
4
+ import { previewText, summarizeReactions, activityLine, itemSender, startsDay, dayLabel, itemTime, startsGroup, parseMentions } from './chunk-A4YJX7KT.js';
5
+ import { fileExtension, formatDuration, nextPlaybackRate, awaySummary, awayLine, WEEKDAYS, CHAT_LIMITS, waveformPeaks, recordingLimitMs, isAllowedExtension, attachmentKindFor, randomId, ChatError } from './chunk-GBJ3YI4E.js';
6
+ import { createContext, useRef, useState, Fragment, Component, useCallback, useEffect, useMemo, useImperativeHandle, useLayoutEffect, useContext } from 'react';
7
+ 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';
8
+ import { Skeleton, cn, EmptyState, Button, UserAvatar, Badge, Input, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, Alert, AlertDescription, Spinner, Collapsible, CollapsibleTrigger, CollapsibleContent, VisuallyHidden, Progress, 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
9
  import { renderSlot } from '@marktiderman/genesis-core/slots';
8
10
  import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
11
+ import { EmojiPicker } from 'frimousse';
9
12
 
10
13
  // src/web/format.ts
11
14
  function clockTime(iso, locale) {
@@ -56,7 +59,7 @@ function inboxTitle(item, resolveUser, fallback) {
56
59
  return fallback?.(item) ?? "Conversation";
57
60
  }
58
61
  function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived = false, fallbackTitle, slots, className, ...rest }) {
59
- const { userId: me } = useChat();
62
+ const { userId: me, attachmentKinds } = useChat();
60
63
  const { items, isLoading, error, refetch } = useInbox({ kind, archived });
61
64
  const list = useRef(null);
62
65
  const onKeyDown = (e) => {
@@ -90,14 +93,14 @@ function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived = false,
90
93
  if (items.length === 0) {
91
94
  return /* @__PURE__ */ jsx("div", { className, children: renderSlot(DefaultEmpty, slots?.empty, { archived }) });
92
95
  }
93
- return /* @__PURE__ */ jsx("nav", { "aria-label": rest["aria-label"] ?? "Conversations", className: cn("min-h-0", className), "data-testid": "genesis-chat-inbox", children: /* @__PURE__ */ jsx("ul", { ref: list, className: "flex flex-col", onKeyDown, children: items.map((item) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(InboxRow, { item, me, resolveUser, fallbackTitle, selected: item.conversationId === selectedId, onSelect }) }, item.conversationId)) }) });
96
+ return /* @__PURE__ */ jsx("nav", { "aria-label": rest["aria-label"] ?? "Conversations", className: cn("min-h-0", className), "data-testid": "genesis-chat-inbox", children: /* @__PURE__ */ jsx("ul", { ref: list, className: "flex flex-col", onKeyDown, children: items.map((item) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(InboxRow, { item, me, kinds: attachmentKinds, resolveUser, fallbackTitle, selected: item.conversationId === selectedId, onSelect }) }, item.conversationId)) }) });
94
97
  }
95
- function InboxRow({ item, me, resolveUser, fallbackTitle, selected, onSelect }) {
98
+ function InboxRow({ item, me, kinds, resolveUser, fallbackTitle, selected, onSelect }) {
96
99
  const title = inboxTitle(item, resolveUser, fallbackTitle);
97
100
  const other = item.otherUserId ? resolveUser(item.otherUserId) : null;
98
101
  const last = item.lastMessage;
99
102
  const unread = item.unread > 0;
100
- let preview = last ? previewText(last.body, last.attachment, last.deleted) : "";
103
+ let preview = last ? attachmentPreview(last.body, last.attachment, kinds, last.deleted) : "";
101
104
  if (last && last.kind !== "system" && !last.deleted && last.senderId) {
102
105
  if (last.senderId === me) preview = `You: ${preview}`;
103
106
  else if (!item.otherUserId && item.memberCount > 2) preview = `${resolveUser(last.senderId).name}: ${preview}`;
@@ -145,6 +148,80 @@ function InboxRow({ item, me, resolveUser, fallbackTitle, selected, onSelect })
145
148
  }
146
149
  );
147
150
  }
151
+ function SearchSnippet({ snippet, className }) {
152
+ return /* @__PURE__ */ jsx("span", { className, children: snippetParts(snippet).map(
153
+ (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)
154
+ ) });
155
+ }
156
+ function ChatSearch({ resolveUser, conversationId, onOpen, conversationTitle, autoFocus, debounceMs, className }) {
157
+ const [value, setValue] = useState("");
158
+ const { hits, query, isSearching } = useSearch(value, { conversationId, debounceMs });
159
+ const list = useRef(null);
160
+ const onKeyDown = (e) => {
161
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
162
+ const buttons = Array.from(list.current?.querySelectorAll("button[data-search-hit]") ?? []);
163
+ if (!buttons.length) return;
164
+ e.preventDefault();
165
+ const at = buttons.indexOf(document.activeElement);
166
+ const to = e.key === "ArrowDown" ? Math.min(buttons.length - 1, at + 1) : at <= 0 ? -1 : at - 1;
167
+ if (to < 0) e.currentTarget.querySelector("input")?.focus();
168
+ else buttons[to]?.focus();
169
+ };
170
+ const short = value.trim().length < 2;
171
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex min-h-0 flex-col", className), onKeyDown, "data-testid": "genesis-chat-search", role: "search", children: [
172
+ /* @__PURE__ */ jsxs("div", { className: "relative p-2", children: [
173
+ /* @__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 }),
174
+ /* @__PURE__ */ jsx(
175
+ Input,
176
+ {
177
+ type: "search",
178
+ value,
179
+ onChange: (e) => setValue(e.target.value),
180
+ placeholder: conversationId ? "Search this conversation" : "Search messages",
181
+ "aria-label": conversationId ? "Search this conversation" : "Search messages",
182
+ autoFocus,
183
+ className: "pl-8 pr-8",
184
+ "data-testid": "genesis-chat-search-input"
185
+ }
186
+ ),
187
+ 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
188
+ ] }),
189
+ /* @__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` }),
190
+ short ? null : isSearching && hits.length === 0 ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2 p-3", "aria-busy": true, "aria-label": "Searching", children: [
191
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-12 w-full" }),
192
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-12 w-full" })
193
+ ] }) : 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) => {
194
+ const who = h.senderId ? resolveUser(h.senderId).name : "Someone";
195
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
196
+ "button",
197
+ {
198
+ type: "button",
199
+ "data-search-hit": true,
200
+ onClick: () => onOpen(h),
201
+ 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",
202
+ "data-testid": "genesis-chat-search-hit",
203
+ children: [
204
+ /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
205
+ /* @__PURE__ */ jsx("span", { className: "truncate font-medium text-foreground", children: who }),
206
+ !conversationId && conversationTitle ? /* @__PURE__ */ jsxs("span", { className: "truncate", children: [
207
+ "in ",
208
+ conversationTitle(h.conversationId)
209
+ ] }) : null,
210
+ /* @__PURE__ */ jsx("span", { className: "ml-auto shrink-0", children: relativeTime(h.createdAt) })
211
+ ] }),
212
+ /* @__PURE__ */ jsxs("span", { className: "flex items-start gap-1.5 text-sm", children: [
213
+ 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: [
214
+ /* @__PURE__ */ jsx(Mic, { className: "size-3", "aria-hidden": true }),
215
+ " Transcript"
216
+ ] }) : null,
217
+ /* @__PURE__ */ jsx(SearchSnippet, { snippet: h.snippet, className: "line-clamp-2" })
218
+ ] })
219
+ ]
220
+ }
221
+ ) }, `${h.messageId}:${h.inTranscript}`);
222
+ }) })
223
+ ] });
224
+ }
148
225
  function isSafeHref(href) {
149
226
  try {
150
227
  const url = new URL(href);
@@ -330,6 +407,441 @@ function renderNodes(nodes, opts) {
330
407
  }
331
408
  });
332
409
  }
410
+ function linkPreviewImageSrc(imageUrl, images = "off", proxyUrl) {
411
+ if (!isHttpsUrl(imageUrl)) return null;
412
+ if (images === "direct") return imageUrl;
413
+ if (images !== "proxy" || !proxyUrl) return null;
414
+ try {
415
+ return proxyUrl(imageUrl) || null;
416
+ } catch {
417
+ return null;
418
+ }
419
+ }
420
+ function ChatLinkPreview({ preview, images = "off", proxyUrl, tone = "default", className }) {
421
+ const [imageFailed, setImageFailed] = useState(false);
422
+ if (preview.status !== "ok" || !isSafeHref(preview.url) || !isHttpsUrl(preview.url)) return null;
423
+ let host = preview.url;
424
+ try {
425
+ host = new URL(preview.url).hostname.replace(/^www\./, "");
426
+ } catch {
427
+ }
428
+ const image = imageFailed ? null : linkPreviewImageSrc(preview.imageUrl, images, proxyUrl);
429
+ const title = preview.title?.trim() || host;
430
+ const primary = tone === "primary";
431
+ return /* @__PURE__ */ jsxs(
432
+ "a",
433
+ {
434
+ href: preview.url,
435
+ target: "_blank",
436
+ rel: "noopener noreferrer nofollow",
437
+ referrerPolicy: "no-referrer",
438
+ className: cn(
439
+ "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",
440
+ primary ? "border-primary-foreground/60 bg-primary-foreground/15" : "border-primary bg-background hover:bg-accent",
441
+ className
442
+ ),
443
+ "aria-label": `${title}, ${preview.siteName ?? host}. Open link`,
444
+ "data-testid": "genesis-chat-link-preview",
445
+ children: [
446
+ image ? /* @__PURE__ */ jsx(
447
+ "img",
448
+ {
449
+ src: image,
450
+ alt: "",
451
+ loading: "lazy",
452
+ referrerPolicy: "no-referrer",
453
+ onError: () => setImageFailed(true),
454
+ className: "aspect-[1.91/1] w-full bg-muted object-cover",
455
+ "data-testid": "genesis-chat-link-preview-image"
456
+ }
457
+ ) : null,
458
+ /* @__PURE__ */ jsxs("span", { className: "flex flex-col gap-0.5 px-2.5 py-2", children: [
459
+ /* @__PURE__ */ jsx("span", { className: cn("truncate text-[11px] uppercase tracking-wide", primary ? "text-primary-foreground/75" : "text-muted-foreground"), children: preview.siteName ?? host }),
460
+ /* @__PURE__ */ jsx("span", { className: "line-clamp-2 text-sm font-semibold leading-snug", children: title }),
461
+ preview.description ? /* @__PURE__ */ jsx("span", { className: cn("line-clamp-2 text-xs", primary ? "text-primary-foreground/85" : "text-muted-foreground"), children: preview.description }) : null
462
+ ] })
463
+ ]
464
+ }
465
+ );
466
+ }
467
+ function ChatAttachmentCard({ title, subtitle, icon: Icon = ClipboardList, action, tone = "default", className }) {
468
+ const primary = tone === "primary";
469
+ const href = action?.href && isSafeHref(action.href) ? action.href : null;
470
+ return /* @__PURE__ */ jsxs(
471
+ "div",
472
+ {
473
+ 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),
474
+ "data-testid": "genesis-chat-attachment-card",
475
+ children: [
476
+ /* @__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 }) }),
477
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1", children: [
478
+ /* @__PURE__ */ jsx("span", { className: "block truncate text-sm font-semibold", children: title }),
479
+ subtitle ? /* @__PURE__ */ jsx("span", { className: cn("block truncate text-xs", primary ? "text-primary-foreground/80" : "text-muted-foreground"), children: subtitle }) : null
480
+ ] }),
481
+ 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: [
482
+ action.label,
483
+ /^https?:/i.test(href) ? /* @__PURE__ */ jsx(ExternalLink, { "aria-hidden": true }) : null
484
+ ] }) }) : /* @__PURE__ */ jsx(Button, { type: "button", size: "sm", variant: primary ? "secondary" : "default", className: "shrink-0", onClick: action.onPress, children: action.label }) : null
485
+ ]
486
+ }
487
+ );
488
+ }
489
+ var ChatErrorBoundary = class extends Component {
490
+ state = { failed: false };
491
+ static getDerivedStateFromError() {
492
+ return { failed: true };
493
+ }
494
+ componentDidCatch(error, info) {
495
+ if (this.props.onError) this.props.onError(error, info);
496
+ else console.error("genesis-chat: a message couldn't be drawn", error);
497
+ }
498
+ render() {
499
+ return this.state.failed ? this.props.fallback : this.props.children;
500
+ }
501
+ };
502
+ function pickVideoMimeType() {
503
+ if (typeof MediaRecorder === "undefined") return null;
504
+ for (const type of ["video/mp4;codecs=avc1,mp4a", "video/mp4", "video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", "video/webm"]) {
505
+ try {
506
+ if (MediaRecorder.isTypeSupported(type)) return type;
507
+ } catch {
508
+ }
509
+ }
510
+ return "";
511
+ }
512
+ function useVideoRecorder({ maxMs, onTick }) {
513
+ const [state, setState] = useState("idle");
514
+ const [error, setError] = useState(null);
515
+ const [elapsedMs, setElapsedMs] = useState(0);
516
+ const [stream, setStream] = useState(null);
517
+ const [recording, setRecording] = useState(null);
518
+ const recorder = useRef(null);
519
+ const chunks = useRef([]);
520
+ const startedAt = useRef(0);
521
+ const timer = useRef(null);
522
+ const streamRef = useRef(null);
523
+ const urlRef = useRef(null);
524
+ const session = useRef(0);
525
+ const tick = useRef(onTick);
526
+ tick.current = onTick;
527
+ const stopTimer = () => {
528
+ if (timer.current) clearInterval(timer.current);
529
+ timer.current = null;
530
+ };
531
+ const releaseUrl = () => {
532
+ if (urlRef.current && typeof URL !== "undefined" && URL.revokeObjectURL) URL.revokeObjectURL(urlRef.current);
533
+ urlRef.current = null;
534
+ };
535
+ const stopStream = useCallback(() => {
536
+ streamRef.current?.getTracks().forEach((t) => t.stop());
537
+ streamRef.current = null;
538
+ setStream(null);
539
+ }, []);
540
+ const open = useCallback(async () => {
541
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia || pickVideoMimeType() === null) {
542
+ setState("unsupported");
543
+ setError("This browser can't record video.");
544
+ return false;
545
+ }
546
+ const token = session.current;
547
+ setState("requesting");
548
+ setError(null);
549
+ try {
550
+ const s = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user", width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true });
551
+ if (token !== session.current) {
552
+ s.getTracks().forEach((t) => t.stop());
553
+ return false;
554
+ }
555
+ if (streamRef.current && streamRef.current !== s) streamRef.current.getTracks().forEach((t) => t.stop());
556
+ streamRef.current = s;
557
+ setStream(s);
558
+ setState("previewing");
559
+ return true;
560
+ } catch (e) {
561
+ if (token !== session.current) return false;
562
+ const denied = e instanceof Error && (e.name === "NotAllowedError" || e.name === "SecurityError");
563
+ setState(denied ? "denied" : "error");
564
+ setError(denied ? "Allow the camera and microphone in your browser's settings to record a video." : "The camera couldn't start.");
565
+ return false;
566
+ }
567
+ }, []);
568
+ const stop = useCallback(() => {
569
+ const r = recorder.current;
570
+ if (r && r.state !== "inactive") r.stop();
571
+ }, []);
572
+ const start = useCallback(async () => {
573
+ if (!streamRef.current && !await open()) return;
574
+ const s = streamRef.current;
575
+ const mime = pickVideoMimeType() ?? "";
576
+ let r;
577
+ try {
578
+ r = mime ? new MediaRecorder(s, { mimeType: mime, videoBitsPerSecond: 25e5 }) : new MediaRecorder(s);
579
+ } catch {
580
+ stopStream();
581
+ setState("error");
582
+ setError("This browser can't record video.");
583
+ return;
584
+ }
585
+ r.onerror = () => {
586
+ stopTimer();
587
+ r.onstop = null;
588
+ recorder.current = null;
589
+ stopStream();
590
+ setState("error");
591
+ setError("The recording stopped. Try again.");
592
+ };
593
+ releaseUrl();
594
+ setRecording(null);
595
+ chunks.current = [];
596
+ r.ondataavailable = (ev) => {
597
+ if (ev.data && ev.data.size > 0) chunks.current.push(ev.data);
598
+ };
599
+ r.onstop = () => {
600
+ stopTimer();
601
+ const durationMs = Date.now() - startedAt.current;
602
+ const contentType = (r.mimeType || mime || "video/webm").split(";")[0].trim() || "video/webm";
603
+ const blob = new Blob(chunks.current, { type: contentType });
604
+ const url = typeof URL !== "undefined" && URL.createObjectURL ? URL.createObjectURL(blob) : null;
605
+ urlRef.current = url;
606
+ setRecording({ blob, contentType, name: `video-${Date.now()}.${fileExtension("", contentType) || "webm"}`, durationMs, url });
607
+ setState("recorded");
608
+ stopStream();
609
+ };
610
+ recorder.current = r;
611
+ startedAt.current = Date.now();
612
+ setElapsedMs(0);
613
+ r.start(1e3);
614
+ setState("recording");
615
+ timer.current = setInterval(() => {
616
+ const ms = Date.now() - startedAt.current;
617
+ setElapsedMs(ms);
618
+ tick.current?.();
619
+ if (ms >= maxMs) stop();
620
+ }, 250);
621
+ }, [maxMs, open, stop, stopStream]);
622
+ const retake = useCallback(async () => {
623
+ releaseUrl();
624
+ setRecording(null);
625
+ setElapsedMs(0);
626
+ await open();
627
+ }, [open]);
628
+ const handOff = useCallback(() => {
629
+ urlRef.current = null;
630
+ }, []);
631
+ const reset = useCallback(() => {
632
+ session.current += 1;
633
+ stopTimer();
634
+ const r = recorder.current;
635
+ if (r && r.state !== "inactive") {
636
+ r.onstop = null;
637
+ r.stop();
638
+ }
639
+ recorder.current = null;
640
+ stopStream();
641
+ releaseUrl();
642
+ setRecording(null);
643
+ setElapsedMs(0);
644
+ setError(null);
645
+ setState("idle");
646
+ }, [stopStream]);
647
+ useEffect(() => () => reset(), [reset]);
648
+ return { state, error, elapsedMs, maxMs, stream, recording, open, start, stop, retake, reset, handOff };
649
+ }
650
+ function videoFile(rec, opts = {}) {
651
+ return {
652
+ data: rec.blob,
653
+ name: rec.name,
654
+ contentType: rec.contentType,
655
+ previewUrl: rec.url ?? void 0,
656
+ meta: { kind: "video", durationMs: rec.durationMs, bytes: rec.blob.size },
657
+ ...opts.hosted ? { hosted: true } : {}
658
+ };
659
+ }
660
+ async function uploadVideo(videoUpload, conversationId, rec, fetchImpl = fetch, signal) {
661
+ const upload = await videoUpload.create(conversationId);
662
+ signal?.throwIfAborted?.();
663
+ const res = await fetchImpl(upload.url, { method: "PUT", body: rec.blob, headers: { "Content-Type": rec.contentType }, signal });
664
+ if (!res.ok) throw new Error(`The video didn't upload (${res.status})`);
665
+ return {
666
+ kind: "video",
667
+ provider: upload.provider,
668
+ providerId: upload.providerId,
669
+ status: "pending",
670
+ name: rec.name,
671
+ mime: rec.contentType,
672
+ bytes: rec.blob.size,
673
+ durationMs: rec.durationMs
674
+ };
675
+ }
676
+ function Preview({ stream }) {
677
+ const ref = useRef(null);
678
+ useEffect(() => {
679
+ const el = ref.current;
680
+ if (!el) return;
681
+ try {
682
+ el.srcObject = stream;
683
+ } catch {
684
+ }
685
+ }, [stream]);
686
+ 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" });
687
+ }
688
+ function ChatVideoRecorder({ open, onOpenChange, maxMs, send, extra, videoUpload, onTick, onSent }) {
689
+ const rec = useVideoRecorder({ maxMs, onTick });
690
+ const [sending, setSending] = useState(false);
691
+ const [sendError, setSendError] = useState(null);
692
+ const sendingNow = useRef(false);
693
+ const { open: openCamera, reset } = rec;
694
+ useEffect(() => {
695
+ if (open) void openCamera();
696
+ else reset();
697
+ }, [open, openCamera, reset]);
698
+ const submit = async () => {
699
+ const take = rec.recording;
700
+ if (!take || sendingNow.current) return;
701
+ sendingNow.current = true;
702
+ setSending(true);
703
+ setSendError(null);
704
+ try {
705
+ await send({ ...extra, body: "" }, [videoFile(take, { hosted: Boolean(videoUpload) })]);
706
+ rec.handOff();
707
+ onSent?.();
708
+ onOpenChange(false);
709
+ } catch (e) {
710
+ setSendError(e instanceof Error ? e.message : "The video didn't send");
711
+ } finally {
712
+ sendingNow.current = false;
713
+ setSending(false);
714
+ }
715
+ };
716
+ const recording = rec.state === "recording";
717
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange: (o) => (o || !sendingNow.current) && onOpenChange(o), children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-lg", "data-testid": "genesis-chat-video-recorder", children: [
718
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [
719
+ /* @__PURE__ */ jsx(DialogTitle, { children: "Record a video" }),
720
+ /* @__PURE__ */ jsxs(DialogDescription, { children: [
721
+ "Up to ",
722
+ formatDuration(maxMs),
723
+ ". You can watch it before you send it."
724
+ ] })
725
+ ] }),
726
+ rec.error ? /* @__PURE__ */ jsxs(Alert, { variant: "destructive", children: [
727
+ /* @__PURE__ */ jsx(VideoOff, { className: "size-4", "aria-hidden": true }),
728
+ /* @__PURE__ */ jsx(AlertDescription, { children: rec.error })
729
+ ] }) : null,
730
+ /* @__PURE__ */ jsxs("div", { className: "relative aspect-video w-full overflow-hidden rounded-lg bg-muted", children: [
731
+ rec.state === "recorded" && rec.recording?.url ? (
732
+ // eslint-disable-next-line jsx-a11y/media-has-caption -- your own recording, reviewed before sending
733
+ /* @__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" })
734
+ ) : 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 }) }),
735
+ 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: [
736
+ /* @__PURE__ */ jsx("span", { className: "size-2 animate-pulse rounded-full bg-destructive", "aria-hidden": true }),
737
+ formatDuration(rec.elapsedMs),
738
+ " / ",
739
+ formatDuration(maxMs)
740
+ ] }) : null,
741
+ 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: [
742
+ /* @__PURE__ */ jsx(Spinner, {}),
743
+ " Sending\u2026"
744
+ ] }) : null
745
+ ] }),
746
+ /* @__PURE__ */ jsx("div", { className: "sr-only", role: "status", children: recording ? "Recording" : rec.state === "recorded" ? "Recorded. Watch it, retake it or send it." : "" }),
747
+ sendError ? /* @__PURE__ */ jsx("p", { className: "text-sm text-destructive", role: "alert", children: sendError }) : null,
748
+ /* @__PURE__ */ jsx("div", { className: "flex items-center justify-center gap-3", children: rec.state === "recorded" ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
749
+ /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", onClick: () => void rec.retake(), disabled: sending, "data-testid": "genesis-chat-video-retake", children: [
750
+ /* @__PURE__ */ jsx(RotateCcw, {}),
751
+ " Retake"
752
+ ] }),
753
+ /* @__PURE__ */ jsxs(Button, { type: "button", onClick: () => void submit(), loading: sending, "data-testid": "genesis-chat-video-send", children: [
754
+ /* @__PURE__ */ jsx(SendHorizontal, {}),
755
+ " Send video"
756
+ ] })
757
+ ] }) : 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: [
758
+ /* @__PURE__ */ jsx(Square, { className: "fill-current" }),
759
+ " Stop"
760
+ ] }) : /* @__PURE__ */ jsxs(
761
+ Button,
762
+ {
763
+ type: "button",
764
+ className: "rounded-full",
765
+ onClick: () => void rec.start(),
766
+ disabled: rec.state === "requesting" || rec.state === "unsupported",
767
+ "aria-label": "Start recording",
768
+ "data-testid": "genesis-chat-video-record",
769
+ children: [
770
+ /* @__PURE__ */ jsx(Circle, { className: "fill-destructive text-destructive" }),
771
+ " Record"
772
+ ]
773
+ }
774
+ ) })
775
+ ] }) });
776
+ }
777
+ var muxStreamUrl = (playbackId) => `https://stream.mux.com/${encodeURIComponent(playbackId)}.m3u8`;
778
+ var muxPosterUrl = (playbackId) => `https://image.mux.com/${encodeURIComponent(playbackId)}/thumbnail.jpg`;
779
+ function ChatVideoPlayer({ src, playbackId, poster, processing, failed, uploading, label, className }) {
780
+ const ref = useRef(null);
781
+ const [hlsFailed, setHlsFailed] = useState(false);
782
+ const stream = playbackId ? muxStreamUrl(playbackId) : null;
783
+ useEffect(() => {
784
+ const el = ref.current;
785
+ if (!el || !stream) return;
786
+ if (typeof el.canPlayType === "function" && el.canPlayType("application/vnd.apple.mpegurl")) {
787
+ el.src = stream;
788
+ return;
789
+ }
790
+ let hls = null;
791
+ let cancelled = false;
792
+ import('hls.js').then((mod) => {
793
+ const Hls = mod.default;
794
+ if (cancelled) return;
795
+ if (!Hls?.isSupported?.()) {
796
+ setHlsFailed(true);
797
+ return;
798
+ }
799
+ hls = new Hls();
800
+ hls.loadSource(stream);
801
+ hls.attachMedia(el);
802
+ }).catch(() => {
803
+ if (!cancelled) setHlsFailed(true);
804
+ });
805
+ return () => {
806
+ cancelled = true;
807
+ hls?.destroy();
808
+ };
809
+ }, [stream]);
810
+ const posterUrl = poster ?? (playbackId ? muxPosterUrl(playbackId) : null);
811
+ if (failed) {
812
+ 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: [
813
+ /* @__PURE__ */ jsx(VideoOff, { className: "size-4", "aria-hidden": true }),
814
+ " This video couldn't be processed"
815
+ ] });
816
+ }
817
+ if (processing && !playbackId) {
818
+ 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: [
819
+ /* @__PURE__ */ jsx(Spinner, { size: "sm" }),
820
+ " Processing\u2026"
821
+ ] });
822
+ }
823
+ return /* @__PURE__ */ jsxs("div", { className: cn("relative w-[min(20rem,100%)] overflow-hidden rounded-xl bg-muted", className), "data-testid": "genesis-chat-video", children: [
824
+ stream || src ? (
825
+ // eslint-disable-next-line jsx-a11y/media-has-caption -- transcripts come from the provider's captions
826
+ /* @__PURE__ */ jsx(
827
+ "video",
828
+ {
829
+ ref,
830
+ controls: true,
831
+ playsInline: true,
832
+ preload: "metadata",
833
+ poster: posterUrl ?? void 0,
834
+ src: stream ? void 0 : src ?? void 0,
835
+ className: "block max-h-80 w-full",
836
+ "aria-label": label ?? "Video",
837
+ "data-testid": stream ? "genesis-chat-video-hls" : void 0
838
+ }
839
+ )
840
+ ) : /* @__PURE__ */ jsx("span", { className: "block aspect-video w-full animate-pulse bg-muted" }),
841
+ 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,
842
+ 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
843
+ ] });
844
+ }
333
845
  var playing = null;
334
846
  var BARS = 48;
335
847
  function resample(peaks, n = BARS) {
@@ -602,13 +1114,20 @@ function Lightbox({ images, index, onIndexChange }) {
602
1114
  ] }) });
603
1115
  }
604
1116
  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
- ] });
1117
+ const a = view.attachment;
1118
+ const hosted = a != null && a.provider !== "storage" && a.provider !== "external" && a.provider !== "none";
1119
+ return /* @__PURE__ */ jsx(
1120
+ ChatVideoPlayer,
1121
+ {
1122
+ src: hosted ? null : view.url,
1123
+ playbackId: hosted ? a.playbackId : null,
1124
+ poster: view.posterUrl,
1125
+ processing: hosted && a.status === "pending",
1126
+ failed: a?.status === "failed",
1127
+ uploading: !a && view.pending,
1128
+ label: view.name ?? "Video"
1129
+ }
1130
+ );
612
1131
  }
613
1132
  function FileChip({ view, tone = "default" }) {
614
1133
  const primary = tone === "primary";
@@ -665,7 +1184,25 @@ function LinkAttachment({ view, tone }) {
665
1184
  }
666
1185
  );
667
1186
  }
668
- function MessageAttachments({ views, message, mine, tone, slot }) {
1187
+ function attachmentKindOf(kinds, kind) {
1188
+ if (!kinds || !Object.prototype.hasOwnProperty.call(kinds, kind)) return null;
1189
+ const k = kinds[kind];
1190
+ return k && typeof k.title === "function" ? k : null;
1191
+ }
1192
+ function attachmentCardOf(k, a, ctx = { mine: false }) {
1193
+ try {
1194
+ const title = k.title(a, ctx);
1195
+ if (typeof title !== "string") return null;
1196
+ const sub = typeof k.subtitle === "function" ? k.subtitle(a, ctx) : null;
1197
+ const act = typeof k.action === "function" ? k.action(a, ctx) : null;
1198
+ 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;
1199
+ return { title, subtitle: typeof sub === "string" ? sub : null, action };
1200
+ } catch (e) {
1201
+ console.error(`genesis-chat: the "${a.kind}" card couldn't be drawn`, e);
1202
+ return null;
1203
+ }
1204
+ }
1205
+ function MessageAttachments({ views, message, mine, tone, slot, kinds }) {
669
1206
  if (views.length === 0) return null;
670
1207
  const images = views.filter((v) => v.kind === "image");
671
1208
  const others = views.filter((v) => v.kind !== "image");
@@ -695,11 +1232,69 @@ function MessageAttachments({ views, message, mine, tone, slot }) {
695
1232
  return /* @__PURE__ */ jsx(LinkAttachment, { view: v, tone }, v.key);
696
1233
  default:
697
1234
  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;
1235
+ const generic = /* @__PURE__ */ jsx(GenericAttachment, { attachment: v.attachment, message, url: v.url, mine });
1236
+ const k = attachmentKindOf(kinds, v.kind);
1237
+ if (k) {
1238
+ const card = attachmentCardOf(k, v.attachment, { mine });
1239
+ return card ? /* @__PURE__ */ jsx(ChatAttachmentCard, { ...card, tone }, v.key) : /* @__PURE__ */ jsx(GenericAttachment, { attachment: v.attachment, message, url: v.url, mine }, v.key);
1240
+ }
1241
+ return Custom ? /* @__PURE__ */ jsx(ChatErrorBoundary, { fallback: generic, children: /* @__PURE__ */ jsx(Custom, { attachment: v.attachment, message, url: v.url, mine }) }, v.key) : null;
699
1242
  }
700
1243
  })
701
1244
  ] });
702
1245
  }
1246
+ var canPickEmoji = (emojibaseUrl) => Boolean(emojibaseUrl?.trim());
1247
+ function ChatEmojiPicker({ onSelect, emojibaseUrl, locale, columns = 8, className }) {
1248
+ if (!canPickEmoji(emojibaseUrl)) {
1249
+ if (typeof console !== "undefined") console.warn("genesis-chat: ChatEmojiPicker needs `emojibaseUrl` (self-hosted emojibase-data); it loads nothing without it.");
1250
+ 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." });
1251
+ }
1252
+ return /* @__PURE__ */ jsxs(
1253
+ EmojiPicker.Root,
1254
+ {
1255
+ onEmojiSelect: (e) => onSelect(e.emoji),
1256
+ emojibaseUrl,
1257
+ locale,
1258
+ columns,
1259
+ className: cn("isolate flex h-80 w-[18.5rem] flex-col bg-popover text-popover-foreground", className),
1260
+ "data-testid": "genesis-chat-emoji-picker",
1261
+ children: [
1262
+ /* @__PURE__ */ jsx(
1263
+ EmojiPicker.Search,
1264
+ {
1265
+ autoFocus: true,
1266
+ "aria-label": "Search emoji",
1267
+ placeholder: "Search emoji",
1268
+ 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"
1269
+ }
1270
+ ),
1271
+ /* @__PURE__ */ jsxs(EmojiPicker.Viewport, { className: "relative flex-1 outline-none", children: [
1272
+ /* @__PURE__ */ jsx(EmojiPicker.Loading, { className: "absolute inset-0 flex items-center justify-center", children: /* @__PURE__ */ jsx(Spinner, { size: "sm", "aria-label": "Loading emoji" }) }),
1273
+ /* @__PURE__ */ jsx(EmojiPicker.Empty, { className: "absolute inset-0 flex items-center justify-center text-sm text-muted-foreground", children: "No emoji found" }),
1274
+ /* @__PURE__ */ jsx(
1275
+ EmojiPicker.List,
1276
+ {
1277
+ className: "select-none pb-1.5",
1278
+ components: {
1279
+ 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 }),
1280
+ Row: ({ children, ...props }) => /* @__PURE__ */ jsx("div", { className: "scroll-my-1.5 px-1.5", ...props, children }),
1281
+ Emoji: ({ emoji, ...props }) => /* @__PURE__ */ jsx(
1282
+ "button",
1283
+ {
1284
+ type: "button",
1285
+ className: cn("flex size-8 items-center justify-center rounded-md text-xl", emoji.isActive && "bg-accent"),
1286
+ ...props,
1287
+ children: emoji.emoji
1288
+ }
1289
+ )
1290
+ }
1291
+ }
1292
+ )
1293
+ ] })
1294
+ ]
1295
+ }
1296
+ );
1297
+ }
703
1298
  var ThreadContext = createContext(null);
704
1299
  function useThreadContext() {
705
1300
  const ctx = useContext(ThreadContext);
@@ -729,6 +1324,7 @@ function pendingAsMessage(p) {
729
1324
  createdAt: p.createdAt,
730
1325
  editedAt: null,
731
1326
  deletedAt: null,
1327
+ moderation: null,
732
1328
  attachments: [],
733
1329
  reactions: []
734
1330
  };
@@ -752,6 +1348,12 @@ function pendingViews(p) {
752
1348
  pending: p.status === "sending"
753
1349
  }));
754
1350
  }
1351
+ function pendingProgress(p) {
1352
+ if (p.status !== "sending") return null;
1353
+ const files = p.files ?? [];
1354
+ if (!files.some((f) => typeof f.progress === "number")) return null;
1355
+ return Math.round(files.reduce((sum, f) => sum + (f.progress ?? 0), 0) / files.length * 100);
1356
+ }
755
1357
  function returnFocus(e, keepFocus) {
756
1358
  const next = keepFocus.current;
757
1359
  if (next) {
@@ -778,10 +1380,15 @@ function MessageRow({ item, startsGroup: startsGroup2, className }) {
778
1380
  const showHeader = ctx.isGroup && !mine && startsGroup2;
779
1381
  const tone = mine && !note && !deleted ? "primary" : "default";
780
1382
  const views = pending ? pendingViews(pending) : message.attachments.map((a) => toAttachmentView(a, ctx.urls));
1383
+ const progress = pending ? pendingProgress(pending) : null;
781
1384
  const editing = ctx.editingId === message.id;
782
1385
  const reactions = pending ? [] : summarizeReactions(message, ctx.me);
783
1386
  const highlighted = ctx.highlightedId === message.id;
784
1387
  const actionable = !pending && !deleted;
1388
+ const hidden = !deleted && message.moderation === "hidden";
1389
+ const flagged = !deleted && message.moderation === "flagged";
1390
+ const observer = ctx.role === "observer";
1391
+ const preview = !pending && !deleted && !hidden && message.body ? ctx.previews[message.id]?.[0] : void 0;
785
1392
  const bubble = /* @__PURE__ */ jsxs(
786
1393
  "div",
787
1394
  {
@@ -792,7 +1399,8 @@ function MessageRow({ item, startsGroup: startsGroup2, className }) {
792
1399
  note && "border border-warning/50 bg-warning/15 text-foreground",
793
1400
  deleted && "border border-dashed bg-transparent text-muted-foreground shadow-none",
794
1401
  highlighted && "ring-2 ring-ring ring-offset-2 ring-offset-background",
795
- pending?.status === "failed" && "opacity-80"
1402
+ pending?.status === "failed" && "opacity-80",
1403
+ hidden && "opacity-70"
796
1404
  ),
797
1405
  "data-testid": deleted ? "genesis-chat-deleted" : note ? "genesis-chat-note" : mine ? "genesis-chat-mine" : "genesis-chat-theirs",
798
1406
  children: [
@@ -805,8 +1413,28 @@ function MessageRow({ item, startsGroup: startsGroup2, className }) {
805
1413
  " Message deleted"
806
1414
  ] }) : editing ? /* @__PURE__ */ jsx(EditBox, { message }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [
807
1415
  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,
1416
+ views.length ? /* @__PURE__ */ jsxs("div", { className: cn(message.body ? "mb-1.5" : ""), children: [
1417
+ /* @__PURE__ */ jsx(MessageAttachments, { views, message: pending ? void 0 : message, mine, tone, slot: ctx.attachmentSlot, kinds: ctx.attachmentKinds }),
1418
+ progress !== null ? /* @__PURE__ */ jsx(
1419
+ Progress,
1420
+ {
1421
+ value: progress,
1422
+ className: cn("mt-1.5 h-1", tone === "primary" && "bg-primary-foreground/30 [&>div]:bg-primary-foreground"),
1423
+ role: "progressbar",
1424
+ "aria-label": "Uploading",
1425
+ "aria-valuemin": 0,
1426
+ "aria-valuemax": 100,
1427
+ "aria-valuenow": progress,
1428
+ "data-testid": "genesis-chat-upload-progress"
1429
+ }
1430
+ ) : null
1431
+ ] }) : null,
809
1432
  message.body ? /* @__PURE__ */ jsx(ChatMarkdown, { text: message.body, me: ctx.me, tone }) : null,
1433
+ preview ? /* @__PURE__ */ jsx(ChatLinkPreview, { preview, images: ctx.linkPreviewImages, proxyUrl: ctx.linkPreviewProxyUrl, tone }) : null,
1434
+ 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: [
1435
+ /* @__PURE__ */ jsx(EyeOff, { className: "size-3", "aria-hidden": true }),
1436
+ " Hidden by moderation. Only you can see it."
1437
+ ] }) : null,
810
1438
  /* @__PURE__ */ jsx(Meta, { message, pending, tone })
811
1439
  ] })
812
1440
  ]
@@ -817,13 +1445,25 @@ function MessageRow({ item, startsGroup: startsGroup2, className }) {
817
1445
  {
818
1446
  id: `genesis-chat-msg-${message.id}`,
819
1447
  "data-message-id": pending ? void 0 : message.id,
820
- "aria-label": messageLabel(message, mine ? "You" : sender.name),
1448
+ "aria-label": `${messageLabel(message, mine ? "You" : sender.name)}${hidden ? " (hidden by moderation)" : flagged && observer ? " (flagged)" : ""}`,
821
1449
  className: cn("group/msg flex gap-2 px-3", mine ? "flex-row-reverse" : "flex-row", startsGroup2 ? "mt-2" : "mt-0.5", className),
822
1450
  "data-testid": pending ? `genesis-chat-pending-${pending.status}` : "genesis-chat-message",
823
1451
  children: [
824
1452
  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
1453
  /* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0 max-w-[min(78%,36rem)] flex-col", mine ? "items-end" : "items-start"), children: [
826
1454
  showHeader ? /* @__PURE__ */ jsx("span", { className: "mb-0.5 px-3 text-xs font-medium text-muted-foreground", children: sender.name }) : null,
1455
+ observer && (hidden || flagged) ? /* @__PURE__ */ jsxs(
1456
+ Badge,
1457
+ {
1458
+ variant: hidden ? "destructive" : "outline",
1459
+ className: "mb-0.5 gap-1 px-1.5 py-0 text-[10px]",
1460
+ "data-testid": hidden ? "genesis-chat-moderation-hidden" : "genesis-chat-moderation-flagged",
1461
+ children: [
1462
+ hidden ? /* @__PURE__ */ jsx(EyeOff, { className: "size-3", "aria-hidden": true }) : /* @__PURE__ */ jsx(ShieldAlert, { className: "size-3", "aria-hidden": true }),
1463
+ hidden ? "Hidden by moderation" : "Flagged"
1464
+ ]
1465
+ }
1466
+ ) : null,
827
1467
  /* @__PURE__ */ jsxs("div", { className: cn("flex max-w-full items-center gap-1", mine ? "flex-row-reverse" : "flex-row"), children: [
828
1468
  actionable ? /* @__PURE__ */ jsxs(ContextMenu, { children: [
829
1469
  /* @__PURE__ */ jsx(ContextMenuTrigger, { asChild: true, children: bubble }),
@@ -1016,6 +1656,7 @@ function HoverBar({ message, mine }) {
1016
1656
  const ctx = useThreadContext();
1017
1657
  const keepFocus = useRef(null);
1018
1658
  const [open, setOpen] = useState(null);
1659
+ const [full, setFull] = useState(false);
1019
1660
  return /* @__PURE__ */ jsxs(
1020
1661
  "div",
1021
1662
  {
@@ -1025,27 +1666,61 @@ function HoverBar({ message, mine }) {
1025
1666
  mine ? "flex-row-reverse" : "flex-row"
1026
1667
  ),
1027
1668
  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,
1669
+ ctx.canPost ? /* @__PURE__ */ jsxs(
1670
+ Popover,
1671
+ {
1672
+ open: open === "react",
1673
+ onOpenChange: (o) => {
1674
+ setOpen(o ? "react" : null);
1675
+ if (!o) setFull(false);
1676
+ },
1677
+ children: [
1678
+ /* @__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, {}) }) }),
1679
+ full ? /* @__PURE__ */ jsx(PopoverContent, { side: "top", align: mine ? "end" : "start", className: "w-auto overflow-hidden p-0", children: /* @__PURE__ */ jsx(
1680
+ ChatEmojiPicker,
1681
+ {
1682
+ emojibaseUrl: ctx.emojibaseUrl,
1683
+ onSelect: (emoji) => {
1684
+ const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
1685
+ ctx.react(message, emoji, !on);
1686
+ setOpen(null);
1687
+ setFull(false);
1688
+ }
1689
+ }
1690
+ ) }) : /* @__PURE__ */ jsxs(PopoverContent, { side: "top", align: mine ? "end" : "start", className: "flex w-auto gap-0.5 rounded-full p-1", children: [
1691
+ ctx.quickReactions.map((emoji) => {
1692
+ const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
1693
+ return /* @__PURE__ */ jsx(
1694
+ "button",
1695
+ {
1696
+ type: "button",
1697
+ 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"),
1698
+ "aria-label": `${on ? "Remove" : "React with"} ${emoji}`,
1699
+ "aria-pressed": on,
1700
+ onClick: () => {
1701
+ ctx.react(message, emoji, !on);
1702
+ setOpen(null);
1703
+ },
1704
+ children: emoji
1705
+ },
1706
+ emoji
1707
+ );
1708
+ }),
1709
+ canPickEmoji(ctx.emojibaseUrl) ? /* @__PURE__ */ jsx(
1710
+ "button",
1711
+ {
1712
+ type: "button",
1713
+ 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",
1714
+ "aria-label": "More reactions",
1715
+ onClick: () => setFull(true),
1716
+ "data-testid": "genesis-chat-more-reactions",
1717
+ children: /* @__PURE__ */ jsx(Plus, { className: "size-4" })
1718
+ }
1719
+ ) : null
1720
+ ] })
1721
+ ]
1722
+ }
1723
+ ) : null,
1049
1724
  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
1725
  /* @__PURE__ */ jsxs(DropdownMenu, { open: open === "more", onOpenChange: (o) => setOpen(o ? "more" : null), children: [
1051
1726
  /* @__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 +1730,360 @@ function HoverBar({ message, mine }) {
1055
1730
  }
1056
1731
  );
1057
1732
  }
1733
+ function toLocalInput(d) {
1734
+ const p = (n) => String(n).padStart(2, "0");
1735
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
1736
+ }
1737
+ function ChatScheduleMenu({ onSchedule, disabled, now }) {
1738
+ const [picking, setPicking] = useState(false);
1739
+ const picks = useMemo(() => scheduleQuickPicks(now ?? /* @__PURE__ */ new Date()), [now]);
1740
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
1741
+ /* @__PURE__ */ jsxs(DropdownMenu, { children: [
1742
+ /* @__PURE__ */ jsx(DropdownMenuTrigger, { asChild: true, children: /* @__PURE__ */ jsx(
1743
+ Button,
1744
+ {
1745
+ type: "button",
1746
+ variant: "ghost",
1747
+ size: "icon",
1748
+ className: "-ml-1 size-8 shrink-0 self-center rounded-full text-muted-foreground",
1749
+ disabled,
1750
+ "aria-label": "Send options",
1751
+ "data-testid": "genesis-chat-send-menu",
1752
+ children: /* @__PURE__ */ jsx(ChevronUp, {})
1753
+ }
1754
+ ) }),
1755
+ /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "end", side: "top", className: "w-60", children: [
1756
+ /* @__PURE__ */ jsxs(DropdownMenuLabel, { className: "flex items-center gap-2 text-xs text-muted-foreground", children: [
1757
+ /* @__PURE__ */ jsx(CalendarClock, { className: "size-3.5", "aria-hidden": true }),
1758
+ " Schedule message"
1759
+ ] }),
1760
+ picks.map((p) => /* @__PURE__ */ jsxs(DropdownMenuItem, { className: "gap-2", onSelect: () => onSchedule(p.at), "data-testid": "genesis-chat-schedule-pick", children: [
1761
+ /* @__PURE__ */ jsx(Clock, { className: "size-4", "aria-hidden": true }),
1762
+ " ",
1763
+ p.label
1764
+ ] }, p.label)),
1765
+ /* @__PURE__ */ jsx(DropdownMenuSeparator, {}),
1766
+ /* @__PURE__ */ jsxs(DropdownMenuItem, { className: "gap-2", onSelect: () => setPicking(true), "data-testid": "genesis-chat-schedule-custom", children: [
1767
+ /* @__PURE__ */ jsx(CalendarClock, { className: "size-4", "aria-hidden": true }),
1768
+ " Pick a date and time\u2026"
1769
+ ] })
1770
+ ] })
1771
+ ] }),
1772
+ /* @__PURE__ */ jsx(ScheduleDialog, { open: picking, onOpenChange: setPicking, onSchedule, now })
1773
+ ] });
1774
+ }
1775
+ function ScheduleDialog({ open, onOpenChange, onSchedule, now }) {
1776
+ const base = now ?? /* @__PURE__ */ new Date();
1777
+ const [value, setValue] = useState(() => toLocalInput(new Date(base.getTime() + 60 * 6e4)));
1778
+ useEffect(() => {
1779
+ if (open) setValue(toLocalInput(new Date((now ?? /* @__PURE__ */ new Date()).getTime() + 60 * 6e4)));
1780
+ }, [open]);
1781
+ const at = new Date(value);
1782
+ const ok = isSchedulable(at, now ?? /* @__PURE__ */ new Date());
1783
+ return /* @__PURE__ */ jsx(Dialog, { open, onOpenChange, children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-sm", children: [
1784
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [
1785
+ /* @__PURE__ */ jsx(DialogTitle, { children: "Schedule message" }),
1786
+ /* @__PURE__ */ jsx(DialogDescription, { children: "It sends at this time, in your time zone, as if you'd sent it then." })
1787
+ ] }),
1788
+ /* @__PURE__ */ jsxs(
1789
+ "form",
1790
+ {
1791
+ className: "flex flex-col gap-2",
1792
+ onSubmit: (e) => {
1793
+ e.preventDefault();
1794
+ if (!ok) return;
1795
+ onSchedule(at);
1796
+ onOpenChange(false);
1797
+ },
1798
+ children: [
1799
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-schedule-at", children: "Date and time" }),
1800
+ /* @__PURE__ */ jsx(
1801
+ Input,
1802
+ {
1803
+ id: "genesis-chat-schedule-at",
1804
+ type: "datetime-local",
1805
+ value,
1806
+ min: toLocalInput(new Date(base.getTime() + 6e4)),
1807
+ onChange: (e) => setValue(e.target.value),
1808
+ "aria-invalid": !ok,
1809
+ "aria-describedby": "genesis-chat-schedule-hint",
1810
+ "data-testid": "genesis-chat-schedule-input"
1811
+ }
1812
+ ),
1813
+ /* @__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." }),
1814
+ /* @__PURE__ */ jsxs(DialogFooter, { className: "mt-2", children: [
1815
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => onOpenChange(false), children: "Cancel" }),
1816
+ /* @__PURE__ */ jsx(Button, { type: "submit", disabled: !ok, "data-testid": "genesis-chat-schedule-confirm", children: "Schedule" })
1817
+ ] })
1818
+ ]
1819
+ }
1820
+ )
1821
+ ] }) });
1822
+ }
1823
+ function ChatScheduledList({ conversationId, conversationTitle, className }) {
1824
+ const { items, isLoading, cancel } = useScheduled(conversationId);
1825
+ if (isLoading) {
1826
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col gap-2 p-3", className), "aria-busy": true, "aria-label": "Loading scheduled messages", children: [
1827
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-12 w-full" }),
1828
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-12 w-full" })
1829
+ ] });
1830
+ }
1831
+ if (items.length === 0) {
1832
+ return /* @__PURE__ */ jsx(EmptyState, { icon: CalendarClock, title: "Nothing scheduled", description: "Use the arrow beside Send to schedule a message.", className });
1833
+ }
1834
+ 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: [
1835
+ 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 }),
1836
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
1837
+ /* @__PURE__ */ jsxs("div", { className: "text-xs font-medium text-muted-foreground", children: [
1838
+ s.status === "failed" ? "Couldn't send" : scheduleLabel(s.sendAt),
1839
+ !conversationId && conversationTitle ? ` \xB7 ${conversationTitle(s.conversationId)}` : null
1840
+ ] }),
1841
+ /* @__PURE__ */ jsx("div", { className: "truncate text-sm", children: previewText(s.body, s.attachments[0]?.kind ?? null) }),
1842
+ s.error ? /* @__PURE__ */ jsx("div", { className: "text-xs text-destructive", children: s.error }) : null
1843
+ ] }),
1844
+ s.status === "failed" ? /* @__PURE__ */ jsxs(
1845
+ Button,
1846
+ {
1847
+ type: "button",
1848
+ variant: "ghost",
1849
+ size: "sm",
1850
+ className: "h-7 shrink-0 gap-1 text-xs",
1851
+ onClick: () => void cancel(s.id).then(() => toast.success("Removed"), () => toast.error("Couldn't remove it")),
1852
+ "aria-label": "Remove the message that couldn't send",
1853
+ "data-testid": "genesis-chat-scheduled-remove",
1854
+ children: [
1855
+ /* @__PURE__ */ jsx(X, { className: "size-3.5", "aria-hidden": true }),
1856
+ " Remove"
1857
+ ]
1858
+ }
1859
+ ) : s.status === "scheduled" ? /* @__PURE__ */ jsxs(
1860
+ Button,
1861
+ {
1862
+ type: "button",
1863
+ variant: "ghost",
1864
+ size: "sm",
1865
+ className: "h-7 shrink-0 gap-1 text-xs",
1866
+ onClick: () => void cancel(s.id).then(() => toast.success("Scheduled message cancelled"), () => toast.error("Couldn't cancel it")),
1867
+ "aria-label": `Cancel the message scheduled for ${scheduleLabel(s.sendAt)}`,
1868
+ "data-testid": "genesis-chat-scheduled-cancel",
1869
+ children: [
1870
+ /* @__PURE__ */ jsx(X, { className: "size-3.5", "aria-hidden": true }),
1871
+ " Cancel"
1872
+ ]
1873
+ }
1874
+ ) : null
1875
+ ] }, s.id)) });
1876
+ }
1877
+ function ChatAwayBanner({ availability, name, people, now, className }) {
1878
+ const line = people ? awaySummary(people, { now }) : awayLine(availability, name ?? "They", { now });
1879
+ if (!line) return null;
1880
+ 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: [
1881
+ /* @__PURE__ */ jsx(Moon, { className: "size-4", "aria-hidden": true }),
1882
+ /* @__PURE__ */ jsx(AlertDescription, { className: "text-xs", children: line })
1883
+ ] });
1884
+ }
1885
+ var DAY_NAMES = {
1886
+ mon: "Monday",
1887
+ tue: "Tuesday",
1888
+ wed: "Wednesday",
1889
+ thu: "Thursday",
1890
+ fri: "Friday",
1891
+ sat: "Saturday",
1892
+ sun: "Sunday"
1893
+ };
1894
+ var FALLBACK_ZONES = [
1895
+ "UTC",
1896
+ "America/New_York",
1897
+ "America/Chicago",
1898
+ "America/Denver",
1899
+ "America/Los_Angeles",
1900
+ "America/Toronto",
1901
+ "America/Sao_Paulo",
1902
+ "Europe/London",
1903
+ "Europe/Paris",
1904
+ "Europe/Berlin",
1905
+ "Africa/Johannesburg",
1906
+ "Asia/Dubai",
1907
+ "Asia/Kolkata",
1908
+ "Asia/Singapore",
1909
+ "Asia/Tokyo",
1910
+ "Australia/Sydney",
1911
+ "Pacific/Auckland"
1912
+ ];
1913
+ function timeZones() {
1914
+ const intl = Intl;
1915
+ try {
1916
+ const all = intl.supportedValuesOf?.("timeZone");
1917
+ if (all?.length) return all.includes("UTC") ? all : ["UTC", ...all];
1918
+ } catch {
1919
+ }
1920
+ return FALLBACK_ZONES;
1921
+ }
1922
+ var deviceZone = () => {
1923
+ try {
1924
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
1925
+ } catch {
1926
+ return "UTC";
1927
+ }
1928
+ };
1929
+ var defaultDays = () => Object.fromEntries(WEEKDAYS.map((d) => [d, { on: d !== "sat" && d !== "sun", from: "09:00", to: "17:00" }]));
1930
+ function daysFrom(hours) {
1931
+ if (!hours) return defaultDays();
1932
+ return Object.fromEntries(
1933
+ WEEKDAYS.map((d) => {
1934
+ const first = hours[d]?.[0];
1935
+ return [d, first ? { on: true, from: first[0], to: first[1] } : { on: false, from: "09:00", to: "17:00" }];
1936
+ })
1937
+ );
1938
+ }
1939
+ function ChatAvailabilitySettings({ onSaved, className }) {
1940
+ const { mine, mineLoading, setMine } = useAvailability();
1941
+ const zones = useMemo(timeZones, []);
1942
+ const [message, setMessage] = useState("");
1943
+ const [until, setUntil] = useState("");
1944
+ const [zone, setZone] = useState(deviceZone);
1945
+ const [useHours, setUseHours] = useState(false);
1946
+ const [days, setDays] = useState(defaultDays);
1947
+ const [busy, setBusy] = useState(false);
1948
+ useEffect(() => {
1949
+ if (mineLoading) return;
1950
+ setMessage(mine?.awayMessage ?? "");
1951
+ setUntil(mine?.awayUntil ? toLocalInput(new Date(mine.awayUntil)) : "");
1952
+ setZone(mine?.timeZone ?? deviceZone());
1953
+ setUseHours(Boolean(mine?.hours && Object.keys(mine.hours).length));
1954
+ setDays(daysFrom(mine?.hours));
1955
+ }, [mine, mineLoading]);
1956
+ if (mineLoading) {
1957
+ return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col gap-3", className), "aria-busy": true, "aria-label": "Loading availability", children: [
1958
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-20 w-full" }),
1959
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-40 w-full" })
1960
+ ] });
1961
+ }
1962
+ const badRange = useHours && WEEKDAYS.some((d) => days[d].on && days[d].from === days[d].to);
1963
+ const save = async () => {
1964
+ if (badRange) return;
1965
+ const hours = useHours ? Object.fromEntries(
1966
+ WEEKDAYS.filter((d) => days[d].on).map((d) => {
1967
+ const kept = (mine?.hours?.[d] ?? []).slice(1);
1968
+ return [d, [[days[d].from, days[d].to], ...kept]];
1969
+ })
1970
+ ) : null;
1971
+ setBusy(true);
1972
+ try {
1973
+ await setMine({
1974
+ awayMessage: message.trim() || null,
1975
+ awayUntil: until ? new Date(until).toISOString() : null,
1976
+ timeZone: zone,
1977
+ hours
1978
+ });
1979
+ toast.success("Availability saved");
1980
+ onSaved?.();
1981
+ } catch (e) {
1982
+ toast.error(e instanceof Error ? e.message : "Couldn't save");
1983
+ } finally {
1984
+ setBusy(false);
1985
+ }
1986
+ };
1987
+ return /* @__PURE__ */ jsxs(
1988
+ "form",
1989
+ {
1990
+ className: cn("flex flex-col gap-6", className),
1991
+ onSubmit: (e) => {
1992
+ e.preventDefault();
1993
+ void save();
1994
+ },
1995
+ "data-testid": "genesis-chat-availability-settings",
1996
+ children: [
1997
+ /* @__PURE__ */ jsxs("fieldset", { className: "flex flex-col gap-2", children: [
1998
+ /* @__PURE__ */ jsx("legend", { className: "mb-1 text-sm font-semibold", children: "Away message" }),
1999
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-away-message", className: "sr-only", children: "Away message" }),
2000
+ /* @__PURE__ */ jsx(
2001
+ Textarea,
2002
+ {
2003
+ id: "genesis-chat-away-message",
2004
+ value: message,
2005
+ onChange: (e) => setMessage(e.target.value),
2006
+ maxLength: 280,
2007
+ placeholder: "Back Monday. For anything urgent, call the office.",
2008
+ className: "min-h-20"
2009
+ }
2010
+ ),
2011
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-end gap-2", children: [
2012
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
2013
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-away-until", className: "text-xs text-muted-foreground", children: "Away until (optional)" }),
2014
+ /* @__PURE__ */ jsx(Input, { id: "genesis-chat-away-until", type: "datetime-local", value: until, onChange: (e) => setUntil(e.target.value), className: "w-56" })
2015
+ ] }),
2016
+ until || message ? /* @__PURE__ */ jsx(
2017
+ Button,
2018
+ {
2019
+ type: "button",
2020
+ variant: "ghost",
2021
+ size: "sm",
2022
+ onClick: () => {
2023
+ setUntil("");
2024
+ setMessage("");
2025
+ },
2026
+ children: "I'm back"
2027
+ }
2028
+ ) : null
2029
+ ] })
2030
+ ] }),
2031
+ /* @__PURE__ */ jsxs("fieldset", { className: "flex flex-col gap-3", children: [
2032
+ /* @__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" }) }),
2033
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-3", children: [
2034
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-use-hours", className: "text-sm font-normal text-muted-foreground", children: "Tell people when you usually reply" }),
2035
+ /* @__PURE__ */ jsx(Switch, { id: "genesis-chat-use-hours", checked: useHours, onCheckedChange: setUseHours })
2036
+ ] }),
2037
+ useHours ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", role: "group", "aria-label": "Hours per day", children: [
2038
+ WEEKDAYS.map((d) => /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2 text-sm", "data-testid": `genesis-chat-hours-${d}`, children: [
2039
+ /* @__PURE__ */ jsx(
2040
+ Switch,
2041
+ {
2042
+ checked: days[d].on,
2043
+ onCheckedChange: (on) => setDays((x) => ({ ...x, [d]: { ...x[d], on } })),
2044
+ "aria-label": `Working on ${DAY_NAMES[d]}`
2045
+ }
2046
+ ),
2047
+ /* @__PURE__ */ jsx("span", { className: cn("w-24", !days[d].on && "text-muted-foreground"), children: DAY_NAMES[d] }),
2048
+ days[d].on ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
2049
+ /* @__PURE__ */ jsx(
2050
+ Input,
2051
+ {
2052
+ type: "time",
2053
+ value: days[d].from,
2054
+ onChange: (e) => setDays((x) => ({ ...x, [d]: { ...x[d], from: e.target.value } })),
2055
+ "aria-label": `${DAY_NAMES[d]} from`,
2056
+ className: "h-8 w-28"
2057
+ }
2058
+ ),
2059
+ /* @__PURE__ */ jsx("span", { "aria-hidden": true, children: "\u2013" }),
2060
+ /* @__PURE__ */ jsx(
2061
+ Input,
2062
+ {
2063
+ type: "time",
2064
+ value: days[d].to,
2065
+ onChange: (e) => setDays((x) => ({ ...x, [d]: { ...x[d], to: e.target.value } })),
2066
+ "aria-label": `${DAY_NAMES[d]} to`,
2067
+ className: "h-8 w-28"
2068
+ }
2069
+ )
2070
+ ] }) : /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: "Not working" })
2071
+ ] }, d)),
2072
+ 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." })
2073
+ ] }) : null,
2074
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1", children: [
2075
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-time-zone", className: "text-xs text-muted-foreground", children: "Time zone" }),
2076
+ /* @__PURE__ */ jsxs(Select, { value: zone, onValueChange: setZone, children: [
2077
+ /* @__PURE__ */ jsx(SelectTrigger, { id: "genesis-chat-time-zone", className: "w-72", "aria-label": "Time zone", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
2078
+ /* @__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)) })
2079
+ ] })
2080
+ ] })
2081
+ ] }),
2082
+ /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, { type: "submit", loading: busy, disabled: badRange, "data-testid": "genesis-chat-availability-save", children: "Save" }) })
2083
+ ]
2084
+ }
2085
+ );
2086
+ }
1058
2087
  var SAMPLE_MS = 100;
1059
2088
  function pickVoiceMimeType() {
1060
2089
  if (typeof MediaRecorder === "undefined") return null;
@@ -1266,10 +2295,21 @@ function ChatComposer({
1266
2295
  accessory,
1267
2296
  onSent,
1268
2297
  autoFocus,
2298
+ disabledReason,
2299
+ topic,
2300
+ scheduling = true,
2301
+ video = true,
2302
+ emoji = true,
2303
+ emojibaseUrl,
1269
2304
  className,
1270
2305
  ref
1271
2306
  }) {
1272
- const { userId: me } = useChat();
2307
+ const { userId: me, videoUpload } = useChat();
2308
+ const conversationId = state.conversation?.id ?? null;
2309
+ const scheduled = useScheduled(conversationId, { enabled: scheduling && Boolean(conversationId) });
2310
+ const [recordingVideo, setRecordingVideo] = useState(false);
2311
+ const [emojiOpen, setEmojiOpen] = useState(false);
2312
+ const canRecordVideo = video && typeof MediaRecorder !== "undefined" && typeof navigator !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia);
1273
2313
  const [text, setText] = useState("");
1274
2314
  const [drafts, setDrafts] = useState([]);
1275
2315
  const [internal, setInternal] = useState(false);
@@ -1277,6 +2317,9 @@ function ChatComposer({
1277
2317
  const picker = useRef(null);
1278
2318
  const draftsRef = useRef(drafts);
1279
2319
  draftsRef.current = drafts;
2320
+ const scheduleId = useRef(null);
2321
+ const [schedulingNow, setSchedulingNow] = useState(false);
2322
+ const schedulingRef = useRef(false);
1280
2323
  const recorder = useVoiceRecorder({
1281
2324
  maxMs: recordingLimitMs(state.role),
1282
2325
  onTick: state.signalRecording,
@@ -1341,8 +2384,39 @@ function ChatComposer({
1341
2384
  });
1342
2385
  const common = () => ({
1343
2386
  replyToId: replyTo?.id ?? null,
1344
- visibility: internal ? "observers" : "everyone"
2387
+ visibility: internal ? "observers" : "everyone",
2388
+ ...topic ? { topic } : {}
1345
2389
  });
2390
+ const scheduleAt = async (at) => {
2391
+ const body = text.trim();
2392
+ if (!body && drafts.length === 0 || !conversationId || schedulingRef.current) return;
2393
+ schedulingRef.current = true;
2394
+ setSchedulingNow(true);
2395
+ scheduleId.current ??= randomId();
2396
+ const files = drafts.map((d) => ({
2397
+ data: d.file,
2398
+ name: d.file.name,
2399
+ contentType: d.file.type || "application/octet-stream",
2400
+ meta: { kind: d.kind, bytes: d.file.size, name: d.file.name }
2401
+ }));
2402
+ try {
2403
+ await scheduled.schedule(
2404
+ { conversationId, clientId: scheduleId.current, body, mentions: parseMentions(body), replyToId: replyTo?.id ?? null, topic: topic ?? null, sendAt: at },
2405
+ files
2406
+ );
2407
+ scheduleId.current = null;
2408
+ setText("");
2409
+ drafts.forEach((d) => revoke(d.previewUrl));
2410
+ setDrafts([]);
2411
+ onCancelReply?.();
2412
+ toast.success(`Scheduled for ${scheduleLabel(at)}`);
2413
+ } catch (e) {
2414
+ toast.error(e instanceof Error ? e.message : "Couldn't schedule it");
2415
+ } finally {
2416
+ schedulingRef.current = false;
2417
+ setSchedulingNow(false);
2418
+ }
2419
+ };
1346
2420
  const submit = () => {
1347
2421
  const body = text.trim();
1348
2422
  if (!body && drafts.length === 0) return;
@@ -1396,6 +2470,24 @@ function ChatComposer({
1396
2470
  if (state.accessLoading) {
1397
2471
  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
2472
  }
2473
+ if (state.accessError) {
2474
+ 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: [
2475
+ /* @__PURE__ */ jsx("span", { children: "Couldn't check whether you can post here." }),
2476
+ /* @__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: [
2477
+ /* @__PURE__ */ jsx(RefreshCw, { className: "size-3.5", "aria-hidden": true }),
2478
+ " Try again"
2479
+ ] })
2480
+ ] });
2481
+ }
2482
+ if (!state.canPost && state.conversationLoading) {
2483
+ 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" }) });
2484
+ }
2485
+ if (state.canPost && disabledReason) {
2486
+ 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: [
2487
+ /* @__PURE__ */ jsx(Lock, { className: "mr-1 inline size-3.5 align-[-2px]", "aria-hidden": true }),
2488
+ disabledReason
2489
+ ] });
2490
+ }
1399
2491
  if (!state.canPost) {
1400
2492
  const adminsOnly = state.conversation?.settings?.who_posts === "admins";
1401
2493
  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 +2515,13 @@ function ChatComposer({
1423
2515
  ] }),
1424
2516
  /* @__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
2517
  ] }) : null,
2518
+ scheduling && scheduled.items.length ? /* @__PURE__ */ jsx("div", { className: "flex items-center px-3 pt-1.5", children: /* @__PURE__ */ jsxs(Popover, { children: [
2519
+ /* @__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: [
2520
+ /* @__PURE__ */ jsx(CalendarClock, { className: "size-3.5", "aria-hidden": true }),
2521
+ scheduled.items.length === 1 ? "1 scheduled message" : `${scheduled.items.length} scheduled messages`
2522
+ ] }) }),
2523
+ /* @__PURE__ */ jsx(PopoverContent, { align: "start", className: "w-80 p-0", children: /* @__PURE__ */ jsx(ChatScheduledList, { conversationId }) })
2524
+ ] }) }) : null,
1426
2525
  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
2526
  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
2527
  /* @__PURE__ */ jsx(FileText, { className: "size-4 shrink-0", "aria-hidden": true }),
@@ -1485,6 +2584,7 @@ function ChatComposer({
1485
2584
  }
1486
2585
  ),
1487
2586
  /* @__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, {}) }) }),
2587
+ 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
2588
  accessory,
1489
2589
  state.role === "observer" ? /* @__PURE__ */ jsx(Tooltip, { content: internal ? "Internal note: only observers see it" : "Write an internal note", children: /* @__PURE__ */ jsx(
1490
2590
  Toggle,
@@ -1518,7 +2618,26 @@ function ChatComposer({
1518
2618
  "data-testid": "genesis-chat-input"
1519
2619
  }
1520
2620
  ),
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(
2621
+ emoji && canPickEmoji(emojibaseUrl) ? /* @__PURE__ */ jsxs(Popover, { open: emojiOpen, onOpenChange: setEmojiOpen, children: [
2622
+ /* @__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, {}) }) }),
2623
+ /* @__PURE__ */ jsx(PopoverContent, { side: "top", align: "end", className: "w-auto overflow-hidden p-0", onCloseAutoFocus: (e) => {
2624
+ e.preventDefault();
2625
+ textarea.current?.focus();
2626
+ }, children: /* @__PURE__ */ jsx(
2627
+ ChatEmojiPicker,
2628
+ {
2629
+ emojibaseUrl,
2630
+ onSelect: (e) => {
2631
+ insertText(e);
2632
+ setEmojiOpen(false);
2633
+ }
2634
+ }
2635
+ ) })
2636
+ ] }) : null,
2637
+ hasContent ? /* @__PURE__ */ jsxs(Fragment$1, { children: [
2638
+ /* @__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, {}) }),
2639
+ scheduling && conversationId && !internal ? /* @__PURE__ */ jsx(ChatScheduleMenu, { onSchedule: (at) => void scheduleAt(at), disabled: schedulingNow }) : null
2640
+ ] }) : /* @__PURE__ */ jsx(Tooltip, { content: "Record a voice message", children: /* @__PURE__ */ jsx(
1522
2641
  Button,
1523
2642
  {
1524
2643
  type: "button",
@@ -1531,7 +2650,127 @@ function ChatComposer({
1531
2650
  children: /* @__PURE__ */ jsx(Mic, {})
1532
2651
  }
1533
2652
  ) })
1534
- ] })
2653
+ ] }),
2654
+ canRecordVideo && conversationId ? /* @__PURE__ */ jsx(
2655
+ ChatVideoRecorder,
2656
+ {
2657
+ open: recordingVideo,
2658
+ onOpenChange: setRecordingVideo,
2659
+ conversationId,
2660
+ maxMs: recordingLimitMs(state.role),
2661
+ send: (input, files) => state.send({ ...input, ...common() }, files),
2662
+ videoUpload,
2663
+ onTick: state.signalRecordingVideo ?? state.signalRecording,
2664
+ onSent: () => {
2665
+ onCancelReply?.();
2666
+ onSent?.();
2667
+ }
2668
+ }
2669
+ ) : null
2670
+ ] });
2671
+ }
2672
+ var ALL = "__all__";
2673
+ function ChatTopicBar({ topics, value, onValueChange, canManage = false, onSetTopic, className }) {
2674
+ const [creating, setCreating] = useState(false);
2675
+ const [title, setTitle] = useState("");
2676
+ const [busy, setBusy] = useState(false);
2677
+ const current = value ? topics.find((t) => t.topic === value) : void 0;
2678
+ const create = async () => {
2679
+ const name = title.trim();
2680
+ if (!name || !onSetTopic) return;
2681
+ const key = topicKey(name);
2682
+ if (topics.some((t) => t.topic === key)) {
2683
+ toast.error("There's already a topic with that name");
2684
+ return;
2685
+ }
2686
+ setBusy(true);
2687
+ try {
2688
+ await onSetTopic(key, { title: name, position: topics.length });
2689
+ setCreating(false);
2690
+ setTitle("");
2691
+ onValueChange(key);
2692
+ } catch (e) {
2693
+ toast.error(e instanceof Error ? e.message : "Couldn't add the topic");
2694
+ } finally {
2695
+ setBusy(false);
2696
+ }
2697
+ };
2698
+ const toggleClosed = async (t) => {
2699
+ try {
2700
+ await onSetTopic?.(t.topic, { closed: !t.closedAt });
2701
+ toast.success(t.closedAt ? `Reopened ${t.title}` : `Closed ${t.title}`);
2702
+ } catch (e) {
2703
+ toast.error(e instanceof Error ? e.message : "Couldn't change the topic");
2704
+ }
2705
+ };
2706
+ 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: [
2707
+ /* @__PURE__ */ jsxs(
2708
+ ToggleGroup,
2709
+ {
2710
+ type: "single",
2711
+ value: value ?? ALL,
2712
+ onValueChange: (v) => {
2713
+ if (v) onValueChange(v === ALL ? null : v);
2714
+ },
2715
+ "aria-label": "Topics",
2716
+ className: "flex min-w-0 flex-1 justify-start gap-1 overflow-x-auto [scrollbar-width:none]",
2717
+ children: [
2718
+ /* @__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" }),
2719
+ topics.map((t) => /* @__PURE__ */ jsxs(
2720
+ ToggleGroupItem,
2721
+ {
2722
+ value: t.topic,
2723
+ size: "sm",
2724
+ className: "h-7 shrink-0 gap-1 rounded-full px-3 text-xs",
2725
+ "aria-label": t.closedAt ? `${t.title} (closed)` : t.title,
2726
+ "data-testid": `genesis-chat-topic-${t.topic}`,
2727
+ children: [
2728
+ t.closedAt ? /* @__PURE__ */ jsx(Lock, { className: "size-3", "aria-hidden": true }) : /* @__PURE__ */ jsx(Hash, { className: "size-3 opacity-60", "aria-hidden": true }),
2729
+ /* @__PURE__ */ jsx("span", { className: "max-w-[10rem] truncate", children: t.title })
2730
+ ]
2731
+ },
2732
+ t.topic
2733
+ ))
2734
+ ]
2735
+ }
2736
+ ),
2737
+ canManage && onSetTopic ? /* @__PURE__ */ jsxs(DropdownMenu, { children: [
2738
+ /* @__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, {}) }) }),
2739
+ /* @__PURE__ */ jsxs(DropdownMenuContent, { align: "end", className: "w-48", children: [
2740
+ /* @__PURE__ */ jsxs(DropdownMenuItem, { className: "gap-2", onSelect: () => setCreating(true), "data-testid": "genesis-chat-topic-new", children: [
2741
+ /* @__PURE__ */ jsx(Plus, { className: "size-4", "aria-hidden": true }),
2742
+ " New topic"
2743
+ ] }),
2744
+ current ? /* @__PURE__ */ jsxs(DropdownMenuItem, { className: "gap-2", onSelect: () => void toggleClosed(current), "data-testid": "genesis-chat-topic-close", children: [
2745
+ current.closedAt ? /* @__PURE__ */ jsx(LockOpen, { className: "size-4", "aria-hidden": true }) : /* @__PURE__ */ jsx(Lock, { className: "size-4", "aria-hidden": true }),
2746
+ current.closedAt ? "Reopen topic" : "Close topic"
2747
+ ] }) : null
2748
+ ] })
2749
+ ] }) : null,
2750
+ /* @__PURE__ */ jsx(Dialog, { open: creating, onOpenChange: (o) => !busy && setCreating(o), children: /* @__PURE__ */ jsxs(DialogContent, { className: "sm:max-w-sm", children: [
2751
+ /* @__PURE__ */ jsxs(DialogHeader, { children: [
2752
+ /* @__PURE__ */ jsx(DialogTitle, { children: "New topic" }),
2753
+ /* @__PURE__ */ jsx(DialogDescription, { children: "Messages posted in a topic stay together, under its name." })
2754
+ ] }),
2755
+ /* @__PURE__ */ jsxs(
2756
+ "form",
2757
+ {
2758
+ className: "flex flex-col gap-2",
2759
+ onSubmit: (e) => {
2760
+ e.preventDefault();
2761
+ void create();
2762
+ },
2763
+ children: [
2764
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-topic-title", children: "Name" }),
2765
+ /* @__PURE__ */ jsx(Input, { id: "genesis-chat-topic-title", value: title, onChange: (e) => setTitle(e.target.value), maxLength: 160, autoFocus: true, placeholder: "Week 1: Habits" }),
2766
+ /* @__PURE__ */ jsxs(DialogFooter, { className: "mt-2", children: [
2767
+ /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", onClick: () => setCreating(false), children: "Cancel" }),
2768
+ /* @__PURE__ */ jsx(Button, { type: "submit", loading: busy, disabled: !title.trim(), "data-testid": "genesis-chat-topic-create", children: "Add topic" })
2769
+ ] })
2770
+ ]
2771
+ }
2772
+ )
2773
+ ] }) })
1535
2774
  ] });
1536
2775
  }
1537
2776
  var REPORT_REASONS = [
@@ -1549,7 +2788,7 @@ function errorText(e) {
1549
2788
  }
1550
2789
  return e instanceof Error ? e.message : "Something went wrong";
1551
2790
  }
1552
- function DefaultHeader({ title, subtitle, otherUserId, resolveUser, onBack, backClassName }) {
2791
+ function DefaultHeader({ title, subtitle, otherUserId, resolveUser, onBack, backClassName, onSearch }) {
1553
2792
  const other = otherUserId ? resolveUser(otherUserId) : null;
1554
2793
  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
2794
  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 +2796,8 @@ function DefaultHeader({ title, subtitle, otherUserId, resolveUser, onBack, back
1557
2796
  /* @__PURE__ */ jsxs("div", { className: "min-w-0 flex-1", children: [
1558
2797
  /* @__PURE__ */ jsx("h2", { className: "truncate text-sm font-semibold leading-tight", children: title }),
1559
2798
  subtitle ? /* @__PURE__ */ jsx("p", { className: "truncate text-xs text-muted-foreground", children: subtitle }) : null
1560
- ] })
2799
+ ] }),
2800
+ 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
2801
  ] });
1562
2802
  }
1563
2803
  function DefaultEmpty2({ canPost }) {
@@ -1570,14 +2810,25 @@ function ChatThread({
1570
2810
  onBack,
1571
2811
  backClassName,
1572
2812
  threadRootId,
1573
- topic,
2813
+ topic: topicProp,
1574
2814
  onOpenThread,
1575
2815
  quickReactions = QUICK_REACTIONS,
1576
2816
  placeholder,
2817
+ showTopics,
2818
+ jumpTo: jumpToProp,
2819
+ search = true,
2820
+ scheduling = true,
2821
+ availability = true,
2822
+ emojibaseUrl,
2823
+ linkPreviewImages = "off",
2824
+ linkPreviewProxyUrl,
1577
2825
  slots,
1578
2826
  className
1579
2827
  }) {
1580
- const { userId: me } = useChat();
2828
+ const { userId: me, attachmentKinds, client: chatClient } = useChat();
2829
+ const topicsState = useTopics(threadRootId ? null : conversationId);
2830
+ const [chosenTopic, setChosenTopic] = useState(null);
2831
+ const topic = topicProp ?? chosenTopic ?? void 0;
1581
2832
  const state = useConversation(conversationId, { threadRootId, topic });
1582
2833
  const actions = useMessageActions();
1583
2834
  const pinIds = usePins(conversationId);
@@ -1602,7 +2853,20 @@ function ChatThread({
1602
2853
  setReplyTo(null);
1603
2854
  setEditingId(null);
1604
2855
  setNewCount(0);
2856
+ setChosenTopic(null);
1605
2857
  }, [conversationId]);
2858
+ const topics = topicsState.topics;
2859
+ const currentTopic = topic ? topics.find((t) => t.topic === topic) : void 0;
2860
+ const canManageTopics = role === "owner" || role === "admin";
2861
+ const showTopicBar = !threadRootId && topicProp === void 0 && (topics.length > 0 || showTopics && canManageTopics);
2862
+ const topicClosed = Boolean(currentTopic?.closedAt) && role !== "owner" && role !== "admin" && role !== "observer";
2863
+ const linkMessageIds = useMemo(
2864
+ () => messages.filter((m) => !m.deletedAt && m.moderation !== "hidden" && extractLinks(m.body).length > 0).map((m) => m.id),
2865
+ [messages]
2866
+ );
2867
+ const previews = useLinkPreviews(linkMessageIds);
2868
+ const avail = useAvailability(availability ? conversationId : null);
2869
+ const [searching, setSearching] = useState(false);
1606
2870
  useCallback((id) => id === me ? "You" : resolveUser(id).name, [me, resolveUser]);
1607
2871
  const others = members.filter((m) => m.userId !== me);
1608
2872
  const otherUserId = members.length === 2 && others.length === 1 ? others[0].userId : null;
@@ -1614,6 +2878,15 @@ function ChatThread({
1614
2878
  (id) => resolveUser(id).name
1615
2879
  );
1616
2880
  const subtitle = members.length > 2 ? `${members.length} members` : null;
2881
+ const awayPeople = useMemo(() => {
2882
+ if (!availability) return null;
2883
+ const ids = otherUserId ? [otherUserId] : members.filter((m) => m.userId !== me).map((m) => m.userId);
2884
+ const people = ids.flatMap((id) => {
2885
+ const a = avail.forUser(id);
2886
+ return a ? [{ availability: a, name: resolveUser(id).name }] : [];
2887
+ });
2888
+ return awaySummary(people) ? people : null;
2889
+ }, [availability, otherUserId, members, me, avail, resolveUser]);
1617
2890
  const scroller = useRef(null);
1618
2891
  const content = useRef(null);
1619
2892
  const atBottom = useRef(true);
@@ -1693,6 +2966,61 @@ function ChatThread({
1693
2966
  setHighlightedId(id);
1694
2967
  setTimeout(() => setHighlightedId((h) => h === id ? null : h), 1600);
1695
2968
  }, []);
2969
+ const reach = useCallback(
2970
+ (target) => {
2971
+ const root = target.threadRootId;
2972
+ if (!root || root === threadRootId || root === target.messageId) {
2973
+ setPendingJump({ messageId: target.messageId, seq: target.seq, phase: "new" });
2974
+ return;
2975
+ }
2976
+ void chatClient.message(root).then(
2977
+ (m) => {
2978
+ if (!m) {
2979
+ toast.info("That message isn't here any more");
2980
+ return;
2981
+ }
2982
+ onOpenThread?.(m, target.seq != null ? { jumpTo: { messageId: target.messageId, seq: target.seq } } : void 0);
2983
+ setPendingJump({ messageId: m.id, seq: m.seq, phase: "new" });
2984
+ },
2985
+ (e) => toast.error(errorText(e))
2986
+ );
2987
+ },
2988
+ [chatClient, onOpenThread, threadRootId]
2989
+ );
2990
+ const [pendingJump, setPendingJump] = useState(jumpToProp && !jumpToProp.threadRootId ? { ...jumpToProp, phase: "new" } : null);
2991
+ useEffect(() => {
2992
+ if (jumpToProp) reach(jumpToProp);
2993
+ }, [jumpToProp?.messageId, jumpToProp?.seq]);
2994
+ const { loadUntil } = state;
2995
+ useEffect(() => {
2996
+ if (!pendingJump || state.isLoading) return;
2997
+ const { messageId, seq, phase } = pendingJump;
2998
+ if (byId.has(messageId)) {
2999
+ setPendingJump(null);
3000
+ requestAnimationFrame(() => jumpTo(messageId));
3001
+ return;
3002
+ }
3003
+ if (phase === "loading") return;
3004
+ if (phase === "loaded" || seq == null) {
3005
+ setPendingJump(null);
3006
+ toast.info("That message isn't here any more");
3007
+ return;
3008
+ }
3009
+ setPendingJump({ messageId, seq, phase: "loading" });
3010
+ const settle = () => setPendingJump((p) => p && p.messageId === messageId ? { ...p, phase: "loaded" } : p);
3011
+ void loadUntil(seq).then(settle, (e) => {
3012
+ toast.error(errorText(e));
3013
+ settle();
3014
+ });
3015
+ }, [pendingJump, byId, state.isLoading, loadUntil, jumpTo]);
3016
+ const openHit = useCallback(
3017
+ (hit) => {
3018
+ setSearching(false);
3019
+ if (topic && topicProp === void 0) setChosenTopic(null);
3020
+ reach(hit);
3021
+ },
3022
+ [topic, topicProp, reach]
3023
+ );
1696
3024
  const run = useCallback(async (p, done) => {
1697
3025
  try {
1698
3026
  await p;
@@ -1714,6 +3042,11 @@ function ChatThread({
1714
3042
  byId,
1715
3043
  quickReactions,
1716
3044
  attachmentSlot: slots?.attachment,
3045
+ attachmentKinds,
3046
+ previews,
3047
+ emojibaseUrl,
3048
+ linkPreviewImages,
3049
+ linkPreviewProxyUrl,
1717
3050
  highlightedId,
1718
3051
  editingId,
1719
3052
  reply: (m) => {
@@ -1790,7 +3123,13 @@ function ChatThread({
1790
3123
  /* @__PURE__ */ jsx("span", { className: "text-xs font-semibold text-primary", children: "New" }),
1791
3124
  /* @__PURE__ */ jsx("span", { className: "h-px flex-1 bg-primary/40" })
1792
3125
  ] }) : null,
1793
- /* @__PURE__ */ jsx(MessageRow, { item, startsGroup: startsGroup(items, i) })
3126
+ /* @__PURE__ */ jsx(
3127
+ ChatErrorBoundary,
3128
+ {
3129
+ 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." }),
3130
+ children: /* @__PURE__ */ jsx(MessageRow, { item, startsGroup: startsGroup(items, i) })
3131
+ }
3132
+ )
1794
3133
  ] }, itemKey(item));
1795
3134
  })
1796
3135
  ] });
@@ -1815,8 +3154,20 @@ function ChatThread({
1815
3154
  role,
1816
3155
  resolveUser,
1817
3156
  onBack,
1818
- backClassName
3157
+ backClassName,
3158
+ onSearch: search && !threadRootId ? () => setSearching(true) : void 0
1819
3159
  }),
3160
+ showTopicBar ? /* @__PURE__ */ jsx(
3161
+ ChatTopicBar,
3162
+ {
3163
+ topics,
3164
+ value: topic ?? null,
3165
+ onValueChange: setChosenTopic,
3166
+ canManage: canManageTopics,
3167
+ onSetTopic: topicsState.setTopic
3168
+ }
3169
+ ) : null,
3170
+ awayPeople ? /* @__PURE__ */ jsx(ChatAwayBanner, { people: awayPeople }) : null,
1820
3171
  pinnedMessage ? /* @__PURE__ */ jsxs(
1821
3172
  "button",
1822
3173
  {
@@ -1872,7 +3223,11 @@ function ChatThread({
1872
3223
  replyTo,
1873
3224
  onCancelReply: () => setReplyTo(null),
1874
3225
  resolveUser,
1875
- placeholder,
3226
+ placeholder: currentTopic ? `Message #${currentTopic.title}` : placeholder,
3227
+ topic: topic ?? null,
3228
+ disabledReason: topicClosed ? "This topic is closed" : null,
3229
+ scheduling,
3230
+ emojibaseUrl,
1876
3231
  accessory: renderSlot(EmptyAccessory, slots?.composerAccessory, {
1877
3232
  conversationId,
1878
3233
  insertText: (t) => composer.current?.insertText(t),
@@ -1883,6 +3238,13 @@ function ChatThread({
1883
3238
  ]
1884
3239
  }
1885
3240
  ),
3241
+ 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: [
3242
+ /* @__PURE__ */ jsxs(SheetHeader, { className: "border-b px-4 py-3", children: [
3243
+ /* @__PURE__ */ jsx(SheetTitle, { children: "Search" }),
3244
+ /* @__PURE__ */ jsx(SheetDescription, { className: "sr-only", children: "Search this conversation and jump to a message." })
3245
+ ] }),
3246
+ /* @__PURE__ */ jsx(ChatSearch, { conversationId, resolveUser, onOpen: openHit, autoFocus: true, className: "min-h-0 flex-1" })
3247
+ ] }) }) : null,
1886
3248
  /* @__PURE__ */ jsx(AlertDialog, { open: confirmDelete !== null, onOpenChange: (o) => !o && setConfirmDelete(null), children: /* @__PURE__ */ jsxs(AlertDialogContent, { children: [
1887
3249
  /* @__PURE__ */ jsxs(AlertDialogHeader, { children: [
1888
3250
  /* @__PURE__ */ jsx(AlertDialogTitle, { children: "Delete this message?" }),
@@ -1980,9 +3342,17 @@ function ChatPanel({
1980
3342
  inboxProps,
1981
3343
  threadProps,
1982
3344
  slots,
3345
+ search = true,
1983
3346
  className
1984
3347
  }) {
1985
3348
  const [own, setOwn] = useState(defaultSelectedId);
3349
+ const [searching, setSearching] = useState(false);
3350
+ const [jump, setJump] = useState(null);
3351
+ const { items } = useInbox({ kind: inboxProps?.kind });
3352
+ const titleOf = (id) => {
3353
+ const item = items.find((i) => i.conversationId === id);
3354
+ return item ? inboxTitle(item, resolveUser, inboxProps?.fallbackTitle) : "a conversation";
3355
+ };
1986
3356
  const selected = controlled !== void 0 ? controlled : own;
1987
3357
  const select = (id) => {
1988
3358
  if (controlled === void 0) setOwn(id);
@@ -2002,10 +3372,41 @@ function ChatPanel({
2002
3372
  className
2003
3373
  ),
2004
3374
  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) })
3375
+ inboxHeader || search ? /* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-2 border-b px-3 py-2", children: [
3376
+ /* @__PURE__ */ jsx("div", { className: "min-w-0 flex-1", children: inboxHeader }),
3377
+ search ? /* @__PURE__ */ jsx(
3378
+ Button,
3379
+ {
3380
+ type: "button",
3381
+ variant: "ghost",
3382
+ size: "icon",
3383
+ className: "size-8 shrink-0",
3384
+ onClick: () => setSearching((s) => !s),
3385
+ "aria-label": searching ? "Close search" : "Search messages",
3386
+ "aria-pressed": searching,
3387
+ "data-testid": "genesis-chat-panel-search",
3388
+ children: searching ? /* @__PURE__ */ jsx(X, {}) : /* @__PURE__ */ jsx(Search, {})
3389
+ }
3390
+ ) : null
3391
+ ] }) : null,
3392
+ searching ? /* @__PURE__ */ jsx(
3393
+ ChatSearch,
3394
+ {
3395
+ resolveUser,
3396
+ autoFocus: true,
3397
+ conversationTitle: titleOf,
3398
+ onOpen: (hit) => {
3399
+ setJump({ messageId: hit.messageId, seq: hit.seq, threadRootId: hit.threadRootId });
3400
+ select(hit.conversationId);
3401
+ },
3402
+ className: "min-h-0 flex-1"
3403
+ }
3404
+ ) : /* @__PURE__ */ jsx(ChatInbox, { ...inboxProps, resolveUser, selectedId: selected, onSelect: (id) => {
3405
+ setJump(null);
3406
+ select(id);
3407
+ }, className: cn("min-h-0 flex-1 overflow-y-auto", inboxProps?.className) })
2007
3408
  ] }),
2008
- end: selected ? /* @__PURE__ */ jsx(ChatThread, { ...threadProps, conversationId: selected, resolveUser, onBack: () => select(null), backClassName: "md:hidden" }, selected) : renderSlot(NoSelection, slots?.noSelection, {})
3409
+ end: selected ? /* @__PURE__ */ jsx(ChatThread, { ...threadProps, jumpTo: jump, conversationId: selected, resolveUser, onBack: () => select(null), backClassName: "md:hidden" }, selected) : renderSlot(NoSelection, slots?.noSelection, {})
2009
3410
  }
2010
3411
  );
2011
3412
  }
@@ -2075,6 +3476,7 @@ function createMemoryChatClient(options) {
2075
3476
  createdAt,
2076
3477
  editedAt: m.editedAt ?? null,
2077
3478
  deletedAt: m.deletedAt ?? null,
3479
+ moderation: m.moderation ?? null,
2078
3480
  attachments: (m.attachments ?? []).map((a, i) => attachment(id, a, i)),
2079
3481
  reactions: m.reactions ?? []
2080
3482
  };
@@ -2107,7 +3509,16 @@ function createMemoryChatClient(options) {
2107
3509
  muted: seed.muted ?? false,
2108
3510
  pinned: seed.pinned ?? false,
2109
3511
  messages: [],
2110
- pins: seed.pins ?? []
3512
+ pins: seed.pins ?? [],
3513
+ topics: (seed.topics ?? []).map((t, i) => ({
3514
+ conversationId: seed.id,
3515
+ topic: t.topic,
3516
+ title: t.title ?? t.topic,
3517
+ position: i,
3518
+ closedAt: t.closed ? (/* @__PURE__ */ new Date()).toISOString() : null,
3519
+ createdBy: null,
3520
+ createdAt: (/* @__PURE__ */ new Date()).toISOString()
3521
+ }))
2111
3522
  };
2112
3523
  for (const m of seed.messages ?? []) c.messages.push(makeMessage(c, m));
2113
3524
  convs.set(seed.id, c);
@@ -2124,7 +3535,20 @@ function createMemoryChatClient(options) {
2124
3535
  }
2125
3536
  throw new ChatError("Message not found", "42501", "message");
2126
3537
  };
2127
- const visible = (c, m) => m.visibility !== "observers" || c.myRole === "observer";
3538
+ const visible = (c, m) => (m.visibility !== "observers" || c.myRole === "observer") && (m.moderation !== "hidden" || m.senderId === me || c.myRole === "observer");
3539
+ const scheduledList = [];
3540
+ const availability = new Map(
3541
+ Object.entries(options.availability ?? {}).map(([userId, a]) => [
3542
+ userId,
3543
+ { userId, awayMessage: a.awayMessage ?? null, awayUntil: a.awayUntil ?? null, timeZone: a.timeZone ?? null, hours: a.hours ?? null }
3544
+ ])
3545
+ );
3546
+ const previews = new Map(
3547
+ (options.linkPreviews ?? []).filter((p) => p.url).map((p) => [
3548
+ p.url,
3549
+ { 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() }
3550
+ ])
3551
+ );
2128
3552
  const clone = (m) => ({ ...m, attachments: m.attachments.map((a) => ({ ...a })), reactions: [...m.reactions] });
2129
3553
  const emit = (conversationId, s) => {
2130
3554
  for (const l of convListeners.get(conversationId) ?? []) l.onSignal(s);
@@ -2175,13 +3599,13 @@ function createMemoryChatClient(options) {
2175
3599
  log("inbox", opts);
2176
3600
  await wait();
2177
3601
  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 ?? ""));
3602
+ 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
3603
  },
2180
3604
  async observed() {
2181
3605
  return [];
2182
3606
  },
2183
- async unreadTotal() {
2184
- return [...convs.values()].reduce((n, c) => n + inboxItem(c).unread, 0);
3607
+ async unreadTotal(opts = {}) {
3608
+ return [...convs.values()].filter((c) => keepKind(c.conversation.kind, opts)).reduce((n, c) => n + inboxItem(c).unread, 0);
2185
3609
  },
2186
3610
  async conversation(id) {
2187
3611
  await wait();
@@ -2242,7 +3666,8 @@ function createMemoryChatClient(options) {
2242
3666
  muted: false,
2243
3667
  pinned: false,
2244
3668
  messages: [],
2245
- pins: []
3669
+ pins: [],
3670
+ topics: []
2246
3671
  });
2247
3672
  for (const l of inboxListeners) l({ type: "inbox", conversationId: id });
2248
3673
  return id;
@@ -2261,6 +3686,10 @@ function createMemoryChatClient(options) {
2261
3686
  }
2262
3687
  const c = get(input.conversationId);
2263
3688
  if (!c.canPost) throw new ChatError("You can't post here", "42501", "send_message");
3689
+ const topic = input.topic ? c.topics.find((t) => t.topic === input.topic) : void 0;
3690
+ if (topic?.closedAt && !["owner", "admin", "observer"].includes(c.myRole ?? "member")) {
3691
+ throw new ChatError("This topic is closed", "42501", "send_message");
3692
+ }
2264
3693
  const existing = c.messages.find((m2) => m2.clientId === input.clientId);
2265
3694
  if (existing) return clone(existing);
2266
3695
  const m = makeMessage(c, {
@@ -2352,6 +3781,8 @@ function createMemoryChatClient(options) {
2352
3781
  async upload(conversationId, userId, file, opts) {
2353
3782
  log("upload", conversationId, opts.name);
2354
3783
  await wait();
3784
+ const total = file instanceof Blob ? file.size : file.byteLength;
3785
+ opts.onProgress?.(total, total);
2355
3786
  const path = `${conversationId}/${userId}/${opts.id ?? uuid()}-${opts.name}`;
2356
3787
  const blob = file instanceof Blob ? file : new Blob([file], { type: opts.contentType });
2357
3788
  files[path] = typeof URL !== "undefined" && typeof URL.createObjectURL === "function" ? URL.createObjectURL(blob) : `memory://${path}`;
@@ -2371,6 +3802,9 @@ function createMemoryChatClient(options) {
2371
3802
  convListeners.set(conversationId, set);
2372
3803
  const entry = { onSignal: handlers.onSignal, onActivity: handlers.onActivity };
2373
3804
  set.add(entry);
3805
+ setTimeout(() => {
3806
+ if (set.has(entry)) handlers.onJoin?.();
3807
+ }, 0);
2374
3808
  return {
2375
3809
  signal(activity) {
2376
3810
  log("signal", conversationId, activity);
@@ -2378,6 +3812,172 @@ function createMemoryChatClient(options) {
2378
3812
  unsubscribe: () => void set.delete(entry)
2379
3813
  };
2380
3814
  },
3815
+ // ── 0002 ──
3816
+ async topics(conversationId) {
3817
+ log("topics", conversationId);
3818
+ await wait();
3819
+ return [...convs.get(conversationId)?.topics ?? []].sort((a, b) => a.position - b.position || a.topic.localeCompare(b.topic)).map((t) => ({ ...t }));
3820
+ },
3821
+ async setTopic(conversationId, topic, input = {}) {
3822
+ log("setTopic", conversationId, topic, input);
3823
+ const c = get(conversationId);
3824
+ if (c.myRole !== "owner" && c.myRole !== "admin") throw new ChatError("Only an owner or admin manages topics", "42501", "set_topic");
3825
+ const key = topic.trim();
3826
+ let t = c.topics.find((x) => x.topic === key);
3827
+ if (!t) {
3828
+ t = { conversationId, topic: key, title: input.title?.trim() || key, position: input.position ?? c.topics.length, closedAt: null, createdBy: me, createdAt: (/* @__PURE__ */ new Date()).toISOString() };
3829
+ c.topics.push(t);
3830
+ } else {
3831
+ if (input.title?.trim()) t.title = input.title.trim();
3832
+ if (input.position != null) t.position = input.position;
3833
+ }
3834
+ if (input.closed === true) t.closedAt ??= (/* @__PURE__ */ new Date()).toISOString();
3835
+ if (input.closed === false) t.closedAt = null;
3836
+ emit(conversationId, { type: "conversation", conversationId });
3837
+ },
3838
+ async schedule(input) {
3839
+ log("schedule", input);
3840
+ await wait();
3841
+ const c = get(input.conversationId);
3842
+ if (!c.canPost) throw new ChatError("You can't post in this conversation", "42501", "schedule_message");
3843
+ const sendAt = typeof input.sendAt === "string" ? input.sendAt : input.sendAt.toISOString();
3844
+ const t = Date.parse(sendAt);
3845
+ 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");
3846
+ if (!(input.body ?? "").trim() && !(input.attachments ?? []).length) throw new ChatError("A message needs text or an attachment", "22023", "schedule_message");
3847
+ const existing = scheduledList.find((s2) => s2.clientId === input.clientId);
3848
+ if (existing) {
3849
+ existing.sendAt = sendAt;
3850
+ return existing.id;
3851
+ }
3852
+ const s = {
3853
+ id: uuid(),
3854
+ conversationId: input.conversationId,
3855
+ clientId: input.clientId,
3856
+ body: input.body ?? "",
3857
+ attachments: input.attachments ?? [],
3858
+ replyToId: input.replyToId ?? null,
3859
+ topic: input.topic ?? null,
3860
+ mentions: input.mentions ?? [],
3861
+ silent: input.silent ?? false,
3862
+ sendAt,
3863
+ sentMessageId: null,
3864
+ cancelledAt: null,
3865
+ error: null,
3866
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
3867
+ status: "scheduled"
3868
+ };
3869
+ scheduledList.push(s);
3870
+ return s.id;
3871
+ },
3872
+ async scheduled(conversationId) {
3873
+ log("scheduled", conversationId);
3874
+ await wait();
3875
+ 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 }));
3876
+ },
3877
+ async cancelScheduled(id) {
3878
+ log("cancelScheduled", id);
3879
+ const s = scheduledList.find((x) => x.id === id && x.status !== "sent" && x.status !== "cancelled");
3880
+ if (!s) throw new ChatError("Nothing to cancel", "22023", "cancel_scheduled");
3881
+ s.cancelledAt = (/* @__PURE__ */ new Date()).toISOString();
3882
+ s.status = "cancelled";
3883
+ },
3884
+ async availabilityFor(conversationId) {
3885
+ log("availabilityFor", conversationId);
3886
+ const c = get(conversationId);
3887
+ return c.members.filter((m) => m.role !== "observer" && availability.has(m.userId)).map((m) => ({ ...availability.get(m.userId) }));
3888
+ },
3889
+ async myAvailability() {
3890
+ const a = availability.get(me);
3891
+ return a ? { ...a } : null;
3892
+ },
3893
+ async setAvailability(input) {
3894
+ log("setAvailability", input);
3895
+ availability.set(me, {
3896
+ userId: me,
3897
+ awayMessage: input.awayMessage?.trim() || null,
3898
+ awayUntil: input.awayUntil ?? null,
3899
+ timeZone: input.timeZone ?? null,
3900
+ hours: input.hours ?? null
3901
+ });
3902
+ },
3903
+ async broadcast(conversationIds, clientId, body, opts = {}) {
3904
+ log("broadcast", conversationIds, clientId, body);
3905
+ await wait();
3906
+ const ids = [];
3907
+ for (const id of new Set(conversationIds)) {
3908
+ const c = convs.get(id);
3909
+ if (!c?.canPost) continue;
3910
+ const m = await client.send({ conversationId: id, clientId: `${clientId}:${id}`, body, silent: opts.silent });
3911
+ ids.push(m.id);
3912
+ }
3913
+ return ids;
3914
+ },
3915
+ async search(query, conversationId) {
3916
+ log("search", query, conversationId);
3917
+ await wait();
3918
+ const q = query.trim().toLowerCase();
3919
+ if (q.length < 2) return [];
3920
+ const hits = [];
3921
+ const mark = (raw) => {
3922
+ const text = raw.replace(/«/g, "\u2039").replace(/»/g, "\u203A");
3923
+ const i = text.toLowerCase().indexOf(q);
3924
+ const start = Math.max(0, i - 40);
3925
+ 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)}`;
3926
+ };
3927
+ for (const c of convs.values()) {
3928
+ if (conversationId && c.conversation.id !== conversationId) continue;
3929
+ for (const m of c.messages) {
3930
+ if (m.deletedAt || !visible(c, m)) continue;
3931
+ const base = { messageId: m.id, conversationId: c.conversation.id, seq: m.seq, senderId: m.senderId, createdAt: m.createdAt, threadRootId: m.threadRootId ?? null };
3932
+ if (m.body.toLowerCase().includes(q)) hits.push({ ...base, snippet: mark(m.body), inTranscript: false });
3933
+ else {
3934
+ const t = m.attachments.find((a) => a.transcript?.toLowerCase().includes(q))?.transcript;
3935
+ if (t) hits.push({ ...base, snippet: mark(t), inTranscript: true });
3936
+ }
3937
+ }
3938
+ }
3939
+ return hits.sort((a, b) => b.createdAt.localeCompare(a.createdAt)).slice(0, 30);
3940
+ },
3941
+ async linkPreviewsFor(messageIds) {
3942
+ log("linkPreviewsFor", messageIds);
3943
+ const out = {};
3944
+ for (const id of new Set(messageIds)) {
3945
+ let found = null;
3946
+ try {
3947
+ found = findMessage(id);
3948
+ } catch {
3949
+ found = null;
3950
+ }
3951
+ if (!found || found.m.deletedAt || !visible(found.c, found.m)) {
3952
+ out[id] = [];
3953
+ continue;
3954
+ }
3955
+ out[id] = extractLinks(found.m.body).map((u) => previews.get(u)).filter((p) => Boolean(p && p.status === "ok")).map((p) => ({ ...p }));
3956
+ }
3957
+ return out;
3958
+ },
3959
+ async sendDue(now = /* @__PURE__ */ new Date()) {
3960
+ let n = 0;
3961
+ for (const s of scheduledList) {
3962
+ if (s.status !== "scheduled" || Date.parse(s.sendAt) > now.getTime()) continue;
3963
+ try {
3964
+ 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 });
3965
+ s.sentMessageId = m.id;
3966
+ s.status = "sent";
3967
+ n += 1;
3968
+ } catch (e) {
3969
+ s.error = e instanceof Error ? e.message : String(e);
3970
+ s.status = "failed";
3971
+ }
3972
+ }
3973
+ return n;
3974
+ },
3975
+ moderate(messageId, result) {
3976
+ if (result === "clean") return;
3977
+ const { c, m } = findMessage(messageId);
3978
+ m.moderation = result;
3979
+ emit(c.conversation.id, { type: "message", conversationId: c.conversation.id, id: m.id, seq: m.seq, op: "update" });
3980
+ },
2381
3981
  receive(conversationId, senderId, body, extra = {}) {
2382
3982
  const c = get(conversationId);
2383
3983
  const m = makeMessage(c, { senderId, body, ...extra });
@@ -2399,7 +3999,127 @@ function createMemoryChatClient(options) {
2399
3999
  };
2400
4000
  return client;
2401
4001
  }
4002
+ function keepKind(kind, opts) {
4003
+ if (opts.kind && kind !== opts.kind) return false;
4004
+ if (opts.kinds?.length && !opts.kinds.includes(kind)) return false;
4005
+ return !opts.excludeKinds?.includes(kind);
4006
+ }
4007
+ function ChatBroadcast({ resolveUser, kind, filter, onSent, className }) {
4008
+ const { items, isLoading } = useInbox({ kind, limit: 200 });
4009
+ const { send, sending } = useBroadcast();
4010
+ const [picked, setPicked] = useState(/* @__PURE__ */ new Set());
4011
+ const [text, setText] = useState("");
4012
+ const [q, setQ] = useState("");
4013
+ const draftId = useRef(null);
4014
+ const busy = useRef(false);
4015
+ const rows = useMemo(
4016
+ () => items.filter((i) => filter ? filter(i) : true).map((i) => ({ item: i, title: inboxTitle(i, resolveUser) })).sort((a, b) => a.title.localeCompare(b.title)),
4017
+ [items, filter, resolveUser]
4018
+ );
4019
+ const shown = rows.filter((r) => !q.trim() || r.title.toLowerCase().includes(q.trim().toLowerCase()));
4020
+ const allShown = shown.length > 0 && shown.every((r) => picked.has(r.item.conversationId));
4021
+ const toggle = (id, on) => setPicked((p) => {
4022
+ const next = new Set(p);
4023
+ if (on) next.add(id);
4024
+ else next.delete(id);
4025
+ return next;
4026
+ });
4027
+ const submit = async () => {
4028
+ const body = text.trim();
4029
+ if (!body || picked.size === 0 || busy.current) return;
4030
+ busy.current = true;
4031
+ draftId.current ??= randomId();
4032
+ try {
4033
+ const ids = await send([...picked], body, { clientId: draftId.current });
4034
+ draftId.current = null;
4035
+ const n = ids.length;
4036
+ if (n === picked.size) toast.success(n === 1 ? "Sent to 1 conversation" : `Sent to ${n} conversations`);
4037
+ else toast.warning(`Sent to ${n} of ${picked.size}`, { description: "You can't post in the others." });
4038
+ onSent?.(n, picked.size);
4039
+ setText("");
4040
+ setPicked(/* @__PURE__ */ new Set());
4041
+ } catch (e) {
4042
+ toast.error(e instanceof Error ? e.message : "Couldn't send");
4043
+ } finally {
4044
+ busy.current = false;
4045
+ }
4046
+ };
4047
+ return /* @__PURE__ */ jsxs(
4048
+ "form",
4049
+ {
4050
+ className: cn("flex flex-col gap-3", className),
4051
+ onSubmit: (e) => {
4052
+ e.preventDefault();
4053
+ void submit();
4054
+ },
4055
+ "data-testid": "genesis-chat-broadcast",
4056
+ children: [
4057
+ /* @__PURE__ */ jsxs(Alert, { children: [
4058
+ /* @__PURE__ */ jsx(Megaphone, { className: "size-4", "aria-hidden": true }),
4059
+ /* @__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." })
4060
+ ] }),
4061
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-1.5", children: [
4062
+ /* @__PURE__ */ jsx(Label, { htmlFor: "genesis-chat-broadcast-text", children: "Message" }),
4063
+ /* @__PURE__ */ jsx(
4064
+ Textarea,
4065
+ {
4066
+ id: "genesis-chat-broadcast-text",
4067
+ value: text,
4068
+ onChange: (e) => setText(e.target.value),
4069
+ maxLength: CHAT_LIMITS.bodyChars,
4070
+ className: "min-h-24",
4071
+ placeholder: "Write once, send to everyone you pick"
4072
+ }
4073
+ )
4074
+ ] }),
4075
+ /* @__PURE__ */ jsxs("fieldset", { className: "flex min-h-0 flex-col gap-2", children: [
4076
+ /* @__PURE__ */ jsx("legend", { className: "mb-1 text-sm font-medium", children: "Send to" }),
4077
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
4078
+ /* @__PURE__ */ jsxs("div", { className: "relative flex-1", children: [
4079
+ /* @__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 }),
4080
+ /* @__PURE__ */ jsx(Input, { value: q, onChange: (e) => setQ(e.target.value), placeholder: "Filter", "aria-label": "Filter conversations", className: "pl-8" })
4081
+ ] }),
4082
+ /* @__PURE__ */ jsx(
4083
+ Button,
4084
+ {
4085
+ type: "button",
4086
+ variant: "ghost",
4087
+ size: "sm",
4088
+ disabled: shown.length === 0,
4089
+ onClick: () => setPicked((p) => {
4090
+ const next = new Set(p);
4091
+ for (const r of shown) {
4092
+ if (allShown) next.delete(r.item.conversationId);
4093
+ else next.add(r.item.conversationId);
4094
+ }
4095
+ return next;
4096
+ }),
4097
+ children: allShown ? "Clear" : "Select all"
4098
+ }
4099
+ )
4100
+ ] }),
4101
+ isLoading ? /* @__PURE__ */ jsxs("div", { className: "flex flex-col gap-2", "aria-busy": true, "aria-label": "Loading conversations", children: [
4102
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-10 w-full" }),
4103
+ /* @__PURE__ */ jsx(Skeleton, { className: "h-10 w-full" })
4104
+ ] }) : 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 }) => {
4105
+ const id = `genesis-chat-broadcast-${item.conversationId}`;
4106
+ const avatar = item.otherUserId ? resolveUser(item.otherUserId).avatarUrl : null;
4107
+ return /* @__PURE__ */ jsxs("li", { className: "flex items-center gap-3 border-b px-3 py-2 last:border-b-0", children: [
4108
+ /* @__PURE__ */ jsx(Checkbox, { id, checked: picked.has(item.conversationId), onCheckedChange: (v) => toggle(item.conversationId, v === true) }),
4109
+ /* @__PURE__ */ jsx(UserAvatar, { name: title, src: avatar, size: "sm" }),
4110
+ /* @__PURE__ */ jsx(Label, { htmlFor: id, className: "min-w-0 flex-1 cursor-pointer truncate font-normal", children: title })
4111
+ ] }, item.conversationId);
4112
+ }) })
4113
+ ] }),
4114
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between gap-2", children: [
4115
+ /* @__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` }),
4116
+ /* @__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" })
4117
+ ] })
4118
+ ]
4119
+ }
4120
+ );
4121
+ }
2402
4122
 
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 };
4123
+ 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
4124
  //# sourceMappingURL=web.js.map
2405
4125
  //# sourceMappingURL=web.js.map