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