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