@marktiderman/genesis-chat 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +238 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +344 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +421 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +116 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +116 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +57 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +563 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,802 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* One conversation, whole: a header, the messages (oldest at the top, kept at
|
|
4
|
+
* the bottom as new ones arrive unless you scrolled up, then a "New messages"
|
|
5
|
+
* pill; earlier ones load as you reach the top without moving what you were
|
|
6
|
+
* reading), day dividers and the "New" line, who is typing, and the composer.
|
|
7
|
+
* Files dropped anywhere on it attach. Screen readers hear incoming messages.
|
|
8
|
+
* With topics, a topic bar filters it; when the other person is away or outside
|
|
9
|
+
* their hours a banner says so; search (in the header) jumps to a message,
|
|
10
|
+
* loading earlier ones as needed; links get preview cards.
|
|
11
|
+
*/
|
|
12
|
+
import {
|
|
13
|
+
useCallback,
|
|
14
|
+
useEffect,
|
|
15
|
+
useLayoutEffect,
|
|
16
|
+
useMemo,
|
|
17
|
+
useRef,
|
|
18
|
+
useState,
|
|
19
|
+
type ComponentType,
|
|
20
|
+
type DragEvent,
|
|
21
|
+
type ReactNode,
|
|
22
|
+
} from "react";
|
|
23
|
+
import { ArrowDown, ArrowLeft, ImagePlus, MessageSquare, Pin, Search } from "lucide-react";
|
|
24
|
+
import {
|
|
25
|
+
AlertDialog,
|
|
26
|
+
AlertDialogAction,
|
|
27
|
+
AlertDialogCancel,
|
|
28
|
+
AlertDialogContent,
|
|
29
|
+
AlertDialogDescription,
|
|
30
|
+
AlertDialogFooter,
|
|
31
|
+
AlertDialogHeader,
|
|
32
|
+
AlertDialogTitle,
|
|
33
|
+
Button,
|
|
34
|
+
Dialog,
|
|
35
|
+
DialogContent,
|
|
36
|
+
DialogDescription,
|
|
37
|
+
DialogFooter,
|
|
38
|
+
DialogHeader,
|
|
39
|
+
DialogTitle,
|
|
40
|
+
EmptyState,
|
|
41
|
+
Label,
|
|
42
|
+
RadioGroup,
|
|
43
|
+
RadioGroupItem,
|
|
44
|
+
Sheet,
|
|
45
|
+
SheetContent,
|
|
46
|
+
SheetDescription,
|
|
47
|
+
SheetHeader,
|
|
48
|
+
SheetTitle,
|
|
49
|
+
Skeleton,
|
|
50
|
+
Spinner,
|
|
51
|
+
Textarea,
|
|
52
|
+
UserAvatar,
|
|
53
|
+
cn,
|
|
54
|
+
toast,
|
|
55
|
+
} from "@marktiderman/genesis-ui";
|
|
56
|
+
import { renderSlot, type SlotOverride } from "@marktiderman/genesis-core/slots";
|
|
57
|
+
import { ChatError } from "../core/client";
|
|
58
|
+
import { extractLinks } from "../core/extras";
|
|
59
|
+
import { activityLine, dayLabel, itemSender, itemTime, parseMentions, previewText, startsDay, startsGroup, type ThreadItem } from "../core/thread";
|
|
60
|
+
import type { ChatConversation, ChatMember, ChatMessage, ChatRole, ChatSearchHit } from "../core/types";
|
|
61
|
+
import { useAvailability, useChat, useConversation, useFileUrls, useLinkPreviews, useMessageActions, usePins, useTopics } from "../react/index";
|
|
62
|
+
import { awaySummary } from "../core/availability";
|
|
63
|
+
import type { ChatAttachmentSlotProps } from "./attachments";
|
|
64
|
+
import { MessageRow, plainText } from "./bubble";
|
|
65
|
+
import { ChatAwayBanner } from "./availability";
|
|
66
|
+
import type { LinkPreviewImages } from "./cards";
|
|
67
|
+
import { ChatErrorBoundary } from "./error-boundary";
|
|
68
|
+
import { ChatComposer, type ChatComposerHandle } from "./composer";
|
|
69
|
+
import type { ResolveUser } from "./format";
|
|
70
|
+
import { ChatSearch } from "./search";
|
|
71
|
+
import { ChatTopicBar } from "./topics";
|
|
72
|
+
import { QUICK_REACTIONS, ThreadContext, type ThreadContextValue } from "./thread-context";
|
|
73
|
+
|
|
74
|
+
export interface ChatThreadHeaderProps {
|
|
75
|
+
conversationId: string;
|
|
76
|
+
conversation: ChatConversation | null;
|
|
77
|
+
/** The title the thread worked out (the conversation's, or the other person's name). */
|
|
78
|
+
title: string;
|
|
79
|
+
/** How many people are in it, for a group. */
|
|
80
|
+
subtitle: string | null;
|
|
81
|
+
/** "Sam is typing…" while someone is. */
|
|
82
|
+
activity: string | null;
|
|
83
|
+
/** The other person in a two-person conversation. */
|
|
84
|
+
otherUserId: string | null;
|
|
85
|
+
members: ChatMember[];
|
|
86
|
+
role: ChatRole | null;
|
|
87
|
+
resolveUser: ResolveUser;
|
|
88
|
+
onBack?: () => void;
|
|
89
|
+
backClassName?: string;
|
|
90
|
+
/** Opens search in this conversation (absent when search is off). */
|
|
91
|
+
onSearch?: () => void;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ChatThreadEmptyProps {
|
|
95
|
+
conversationId: string;
|
|
96
|
+
canPost: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ChatComposerAccessoryProps {
|
|
100
|
+
conversationId: string;
|
|
101
|
+
insertText(text: string): void;
|
|
102
|
+
addFiles(files: File[]): void;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ChatThreadSlots {
|
|
106
|
+
/** The bar above the messages. */
|
|
107
|
+
header?: SlotOverride<ChatThreadHeaderProps>;
|
|
108
|
+
/** What a conversation with no messages shows. */
|
|
109
|
+
empty?: SlotOverride<ChatThreadEmptyProps>;
|
|
110
|
+
/** Draws the app's own attachment kinds (form, lesson…); a generic card by default, `null` hides them. */
|
|
111
|
+
attachment?: ComponentType<ChatAttachmentSlotProps> | null;
|
|
112
|
+
/** Extra controls in the composer, beside the attach button. */
|
|
113
|
+
composerAccessory?: SlotOverride<ChatComposerAccessoryProps>;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface ChatThreadProps {
|
|
117
|
+
conversationId: string;
|
|
118
|
+
/** Names people. Required: the package knows no people tables, the app does. */
|
|
119
|
+
resolveUser: ResolveUser;
|
|
120
|
+
/** The header's title; worked out from the conversation and its members by default. */
|
|
121
|
+
title?: string;
|
|
122
|
+
/** Shows a back button in the header. */
|
|
123
|
+
onBack?: () => void;
|
|
124
|
+
/** Classes for that back button (ChatPanel hides it on wide screens). */
|
|
125
|
+
backClassName?: string;
|
|
126
|
+
/** One thread's replies instead of the main timeline. */
|
|
127
|
+
threadRootId?: string;
|
|
128
|
+
/** One topic's messages. */
|
|
129
|
+
topic?: string;
|
|
130
|
+
/** Shown on messages that have replies, to open their thread. */
|
|
131
|
+
onOpenThread?: (message: ChatMessage) => void;
|
|
132
|
+
/** The quick reactions; 👍 ❤️ 😂 😮 😢 🙏 by default. */
|
|
133
|
+
quickReactions?: readonly string[];
|
|
134
|
+
placeholder?: string;
|
|
135
|
+
/** Show the topic bar even before the first topic exists (so an owner can add one). */
|
|
136
|
+
showTopics?: boolean;
|
|
137
|
+
/** Scroll to this message once it's loaded (a search result across conversations); `seq` lets earlier pages load to reach it. */
|
|
138
|
+
jumpTo?: { messageId: string; seq?: number } | null;
|
|
139
|
+
/** A search button in the header. Default true. */
|
|
140
|
+
search?: boolean;
|
|
141
|
+
/** Scheduled send in the composer. Default true. */
|
|
142
|
+
scheduling?: boolean;
|
|
143
|
+
/** The away banner. Default true. */
|
|
144
|
+
availability?: boolean;
|
|
145
|
+
/** Where the emoji picker loads its data from (self-hosted emojibase-data). Without it there is no full picker; the quick reactions stay. */
|
|
146
|
+
emojibaseUrl?: string;
|
|
147
|
+
/** Link preview images: `"off"` by default, `"proxy"` through `linkPreviewProxyUrl`, or `"direct"` from the linked site (which then sees readers' IPs). */
|
|
148
|
+
linkPreviewImages?: LinkPreviewImages;
|
|
149
|
+
/** Your image proxy for `linkPreviewImages: "proxy"`: the URL to load for an og:image. */
|
|
150
|
+
linkPreviewProxyUrl?: (url: string) => string | null | undefined;
|
|
151
|
+
slots?: ChatThreadSlots;
|
|
152
|
+
className?: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const REPORT_REASONS = [
|
|
156
|
+
{ value: "spam", label: "Spam" },
|
|
157
|
+
{ value: "harassment", label: "Harassment or bullying" },
|
|
158
|
+
{ value: "safety", label: "Someone may be at risk" },
|
|
159
|
+
{ value: "other", label: "Something else" },
|
|
160
|
+
] as const;
|
|
161
|
+
|
|
162
|
+
type ReportReason = (typeof REPORT_REASONS)[number]["value"];
|
|
163
|
+
|
|
164
|
+
const itemKey = (item: ThreadItem) => (item.type === "message" ? item.message.clientId || item.message.id : item.pending.clientId);
|
|
165
|
+
|
|
166
|
+
function errorText(e: unknown): string {
|
|
167
|
+
if (e instanceof ChatError) {
|
|
168
|
+
if (e.forbidden) return "You can't do that here";
|
|
169
|
+
if (e.rateLimited) return "Slow down a little and try again";
|
|
170
|
+
if (e.transient) return "Check your connection and try again";
|
|
171
|
+
}
|
|
172
|
+
return e instanceof Error ? e.message : "Something went wrong";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function DefaultHeader({ title, subtitle, otherUserId, resolveUser, onBack, backClassName, onSearch }: ChatThreadHeaderProps) {
|
|
176
|
+
const other = otherUserId ? resolveUser(otherUserId) : null;
|
|
177
|
+
return (
|
|
178
|
+
<header className="flex min-h-14 items-center gap-3 border-b px-3 py-2" data-testid="genesis-chat-thread-header">
|
|
179
|
+
{onBack ? (
|
|
180
|
+
<Button type="button" variant="ghost" size="icon" className={cn("-ml-1 shrink-0", backClassName)} onClick={onBack} aria-label="Back to conversations">
|
|
181
|
+
<ArrowLeft />
|
|
182
|
+
</Button>
|
|
183
|
+
) : null}
|
|
184
|
+
<UserAvatar name={title} src={other?.avatarUrl} size="sm" />
|
|
185
|
+
<div className="min-w-0 flex-1">
|
|
186
|
+
<h2 className="truncate text-sm font-semibold leading-tight">{title}</h2>
|
|
187
|
+
{subtitle ? <p className="truncate text-xs text-muted-foreground">{subtitle}</p> : null}
|
|
188
|
+
</div>
|
|
189
|
+
{onSearch ? (
|
|
190
|
+
<Button type="button" variant="ghost" size="icon" className="shrink-0" onClick={onSearch} aria-label="Search this conversation" data-testid="genesis-chat-thread-search">
|
|
191
|
+
<Search />
|
|
192
|
+
</Button>
|
|
193
|
+
) : null}
|
|
194
|
+
</header>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function DefaultEmpty({ canPost }: ChatThreadEmptyProps) {
|
|
199
|
+
return <EmptyState icon={MessageSquare} title="No messages yet" description={canPost ? "Say hello." : undefined} />;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function ChatThread({
|
|
203
|
+
conversationId,
|
|
204
|
+
resolveUser,
|
|
205
|
+
title: titleProp,
|
|
206
|
+
onBack,
|
|
207
|
+
backClassName,
|
|
208
|
+
threadRootId,
|
|
209
|
+
topic: topicProp,
|
|
210
|
+
onOpenThread,
|
|
211
|
+
quickReactions = QUICK_REACTIONS,
|
|
212
|
+
placeholder,
|
|
213
|
+
showTopics,
|
|
214
|
+
jumpTo: jumpToProp,
|
|
215
|
+
search = true,
|
|
216
|
+
scheduling = true,
|
|
217
|
+
availability = true,
|
|
218
|
+
emojibaseUrl,
|
|
219
|
+
linkPreviewImages = "off",
|
|
220
|
+
linkPreviewProxyUrl,
|
|
221
|
+
slots,
|
|
222
|
+
className,
|
|
223
|
+
}: ChatThreadProps) {
|
|
224
|
+
const { userId: me, attachmentKinds } = useChat();
|
|
225
|
+
const topicsState = useTopics(threadRootId ? null : conversationId);
|
|
226
|
+
const [chosenTopic, setChosenTopic] = useState<string | null>(null);
|
|
227
|
+
const topic = topicProp ?? chosenTopic ?? undefined;
|
|
228
|
+
const state = useConversation(conversationId, { threadRootId, topic });
|
|
229
|
+
const actions = useMessageActions();
|
|
230
|
+
const pinIds = usePins(conversationId);
|
|
231
|
+
const { items, messages, members, role, canPost, conversation, activity } = state;
|
|
232
|
+
|
|
233
|
+
const paths = useMemo(
|
|
234
|
+
() => messages.flatMap((m) => m.attachments.flatMap((a) => [a.path, a.posterPath])).filter((p): p is string => Boolean(p)),
|
|
235
|
+
[messages],
|
|
236
|
+
);
|
|
237
|
+
const urls = useFileUrls(paths);
|
|
238
|
+
const byId = useMemo(() => new Map(messages.map((m) => [m.id, m])), [messages]);
|
|
239
|
+
const pins = useMemo(() => new Set(pinIds), [pinIds]);
|
|
240
|
+
|
|
241
|
+
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
|
|
242
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
243
|
+
const [highlightedId, setHighlightedId] = useState<string | null>(null);
|
|
244
|
+
const [confirmDelete, setConfirmDelete] = useState<ChatMessage | null>(null);
|
|
245
|
+
const [reporting, setReporting] = useState<ChatMessage | null>(null);
|
|
246
|
+
const [dragging, setDragging] = useState(false);
|
|
247
|
+
const [newCount, setNewCount] = useState(0);
|
|
248
|
+
const [announcement, setAnnouncement] = useState("");
|
|
249
|
+
const composer = useRef<ChatComposerHandle | null>(null);
|
|
250
|
+
|
|
251
|
+
// A different conversation starts clean.
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
setReplyTo(null);
|
|
254
|
+
setEditingId(null);
|
|
255
|
+
setNewCount(0);
|
|
256
|
+
setChosenTopic(null);
|
|
257
|
+
}, [conversationId]);
|
|
258
|
+
|
|
259
|
+
const topics = topicsState.topics;
|
|
260
|
+
const currentTopic = topic ? topics.find((t) => t.topic === topic) : undefined;
|
|
261
|
+
const canManageTopics = role === "owner" || role === "admin";
|
|
262
|
+
const showTopicBar = !threadRootId && topicProp === undefined && (topics.length > 0 || (showTopics && canManageTopics));
|
|
263
|
+
const topicClosed = Boolean(currentTopic?.closedAt) && role !== "owner" && role !== "admin" && role !== "observer";
|
|
264
|
+
|
|
265
|
+
const linkMessageIds = useMemo(
|
|
266
|
+
() => messages.filter((m) => !m.deletedAt && m.moderation !== "hidden" && extractLinks(m.body).length > 0).map((m) => m.id),
|
|
267
|
+
[messages],
|
|
268
|
+
);
|
|
269
|
+
const previews = useLinkPreviews(linkMessageIds);
|
|
270
|
+
const avail = useAvailability(availability ? conversationId : null);
|
|
271
|
+
const [searching, setSearching] = useState(false);
|
|
272
|
+
|
|
273
|
+
const nameOf = useCallback((id: string) => (id === me ? "You" : resolveUser(id).name), [me, resolveUser]);
|
|
274
|
+
const others = members.filter((m) => m.userId !== me);
|
|
275
|
+
const otherUserId = members.length === 2 && others.length === 1 ? others[0]!.userId : null;
|
|
276
|
+
const isGroup = members.length > 2 || role === "observer";
|
|
277
|
+
const title =
|
|
278
|
+
titleProp ??
|
|
279
|
+
conversation?.title ??
|
|
280
|
+
(otherUserId
|
|
281
|
+
? resolveUser(otherUserId).name
|
|
282
|
+
: others.length
|
|
283
|
+
? others.length <= 3
|
|
284
|
+
? others.map((m) => resolveUser(m.userId).name).join(", ")
|
|
285
|
+
: `${others.slice(0, 2).map((m) => resolveUser(m.userId).name).join(", ")} and ${others.length - 2} others`
|
|
286
|
+
: "Conversation");
|
|
287
|
+
// Someone who just posted has stopped typing, whatever their last signal said.
|
|
288
|
+
const arrived = useRef(new Map<string, number>());
|
|
289
|
+
const live = activityLine(
|
|
290
|
+
activity.filter((a) => a.at > (arrived.current.get(a.userId) ?? 0)),
|
|
291
|
+
(id) => resolveUser(id).name,
|
|
292
|
+
);
|
|
293
|
+
const subtitle = members.length > 2 ? `${members.length} members` : null;
|
|
294
|
+
|
|
295
|
+
// The away line: the other person in a two-person conversation; in a group, everyone away or outside their hours.
|
|
296
|
+
const awayPeople = useMemo(() => {
|
|
297
|
+
if (!availability) return null;
|
|
298
|
+
const ids = otherUserId ? [otherUserId] : members.filter((m) => m.userId !== me).map((m) => m.userId);
|
|
299
|
+
const people = ids.flatMap((id) => {
|
|
300
|
+
const a = avail.forUser(id);
|
|
301
|
+
return a ? [{ availability: a, name: resolveUser(id).name }] : [];
|
|
302
|
+
});
|
|
303
|
+
return awaySummary(people) ? people : null;
|
|
304
|
+
}, [availability, otherUserId, members, me, avail, resolveUser]);
|
|
305
|
+
|
|
306
|
+
// ---- scrolling ------------------------------------------------------------
|
|
307
|
+
const scroller = useRef<HTMLDivElement>(null);
|
|
308
|
+
const content = useRef<HTMLDivElement>(null);
|
|
309
|
+
const atBottom = useRef(true);
|
|
310
|
+
const initialised = useRef<string | null>(null);
|
|
311
|
+
const edges = useRef<{ first: string | null; last: string | null }>({ first: null, last: null });
|
|
312
|
+
const anchor = useRef<{ height: number; top: number } | null>(null);
|
|
313
|
+
|
|
314
|
+
const scrollToBottom = useCallback((smooth = false) => {
|
|
315
|
+
const el = scroller.current;
|
|
316
|
+
if (!el) return;
|
|
317
|
+
if (smooth && typeof el.scrollTo === "function") el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
|
318
|
+
else el.scrollTop = el.scrollHeight;
|
|
319
|
+
atBottom.current = true;
|
|
320
|
+
setNewCount(0);
|
|
321
|
+
}, []);
|
|
322
|
+
|
|
323
|
+
useLayoutEffect(() => {
|
|
324
|
+
const el = scroller.current;
|
|
325
|
+
if (!el || items.length === 0) return;
|
|
326
|
+
const first = itemKey(items[0]!);
|
|
327
|
+
const lastItem = items[items.length - 1]!;
|
|
328
|
+
const last = itemKey(lastItem);
|
|
329
|
+
const prev = edges.current;
|
|
330
|
+
edges.current = { first, last };
|
|
331
|
+
|
|
332
|
+
if (initialised.current !== conversationId) {
|
|
333
|
+
initialised.current = conversationId;
|
|
334
|
+
const unread = state.firstUnreadId ? document.getElementById(`genesis-chat-msg-${state.firstUnreadId}`) : null;
|
|
335
|
+
if (unread && typeof unread.scrollIntoView === "function") unread.scrollIntoView({ block: "center" });
|
|
336
|
+
else scrollToBottom();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (anchor.current && first !== prev.first) {
|
|
340
|
+
el.scrollTop = el.scrollHeight - anchor.current.height + anchor.current.top;
|
|
341
|
+
anchor.current = null;
|
|
342
|
+
}
|
|
343
|
+
if (last !== prev.last) {
|
|
344
|
+
const mine = itemSender(lastItem) === me;
|
|
345
|
+
if (atBottom.current || mine) scrollToBottom();
|
|
346
|
+
else setNewCount((n) => n + 1);
|
|
347
|
+
if (!mine && lastItem.type === "message" && lastItem.message.kind !== "system") {
|
|
348
|
+
const m = lastItem.message;
|
|
349
|
+
if (m.senderId) arrived.current.set(m.senderId, Date.now());
|
|
350
|
+
setAnnouncement(`${m.senderId ? resolveUser(m.senderId).name : "Someone"}: ${previewText(m.body, m.attachments[0]?.kind ?? null, Boolean(m.deletedAt))}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}, [items, conversationId, me, resolveUser, scrollToBottom, state.firstUnreadId]);
|
|
354
|
+
|
|
355
|
+
const hasItems = items.length > 0;
|
|
356
|
+
// Photos and previews that load later grow the thread: stay at the bottom if you were there.
|
|
357
|
+
useEffect(() => {
|
|
358
|
+
const el = content.current;
|
|
359
|
+
if (!el || typeof ResizeObserver === "undefined") return;
|
|
360
|
+
const ro = new ResizeObserver(() => {
|
|
361
|
+
if (atBottom.current && scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight;
|
|
362
|
+
});
|
|
363
|
+
ro.observe(el);
|
|
364
|
+
return () => ro.disconnect();
|
|
365
|
+
}, [hasItems]);
|
|
366
|
+
|
|
367
|
+
const { hasEarlier, loadingEarlier, loadEarlier } = state;
|
|
368
|
+
const loadOlder = useCallback(() => {
|
|
369
|
+
const el = scroller.current;
|
|
370
|
+
if (!el || !hasEarlier || loadingEarlier) return;
|
|
371
|
+
anchor.current = { height: el.scrollHeight, top: el.scrollTop };
|
|
372
|
+
void loadEarlier().catch((e) => toast.error(errorText(e)));
|
|
373
|
+
}, [hasEarlier, loadingEarlier, loadEarlier]);
|
|
374
|
+
|
|
375
|
+
const onScroll = () => {
|
|
376
|
+
const el = scroller.current;
|
|
377
|
+
if (!el) return;
|
|
378
|
+
const bottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
|
379
|
+
atBottom.current = bottom;
|
|
380
|
+
if (bottom && newCount) setNewCount(0);
|
|
381
|
+
if (el.scrollTop < 200) loadOlder();
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const jumpTo = useCallback((id: string) => {
|
|
385
|
+
const el = document.getElementById(`genesis-chat-msg-${id}`);
|
|
386
|
+
if (!el) {
|
|
387
|
+
toast.info("That message is further up", { description: "Scroll up to load it." });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (typeof el.scrollIntoView === "function") el.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
391
|
+
setHighlightedId(id);
|
|
392
|
+
setTimeout(() => setHighlightedId((h) => (h === id ? null : h)), 1600);
|
|
393
|
+
}, []);
|
|
394
|
+
|
|
395
|
+
// A message to reach: loads earlier pages until it is here (leaving a topic it isn't in), then scrolls to it.
|
|
396
|
+
type Jump = { messageId: string; seq?: number; phase: "new" | "loading" | "loaded" };
|
|
397
|
+
const [pendingJump, setPendingJump] = useState<Jump | null>(jumpToProp ? { ...jumpToProp, phase: "new" } : null);
|
|
398
|
+
useEffect(() => {
|
|
399
|
+
if (jumpToProp) setPendingJump({ ...jumpToProp, phase: "new" });
|
|
400
|
+
}, [jumpToProp?.messageId, jumpToProp?.seq]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
401
|
+
const { loadUntil } = state;
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (!pendingJump || state.isLoading) return;
|
|
404
|
+
const { messageId, seq, phase } = pendingJump;
|
|
405
|
+
if (byId.has(messageId)) {
|
|
406
|
+
setPendingJump(null);
|
|
407
|
+
// After the rows it needs have rendered.
|
|
408
|
+
requestAnimationFrame(() => jumpTo(messageId));
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (phase === "loading") return;
|
|
412
|
+
if (phase === "loaded" || seq == null) {
|
|
413
|
+
setPendingJump(null);
|
|
414
|
+
toast.info("That message isn't here any more");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
setPendingJump({ messageId, seq, phase: "loading" });
|
|
418
|
+
const settle = () => setPendingJump((p) => (p && p.messageId === messageId ? { ...p, phase: "loaded" } : p));
|
|
419
|
+
void loadUntil(seq).then(settle, (e) => {
|
|
420
|
+
toast.error(errorText(e));
|
|
421
|
+
settle();
|
|
422
|
+
});
|
|
423
|
+
}, [pendingJump, byId, state.isLoading, loadUntil, jumpTo]);
|
|
424
|
+
|
|
425
|
+
const openHit = useCallback(
|
|
426
|
+
(hit: ChatSearchHit) => {
|
|
427
|
+
setSearching(false);
|
|
428
|
+
if (topic && topicProp === undefined) setChosenTopic(null);
|
|
429
|
+
setPendingJump({ messageId: hit.messageId, seq: hit.seq, phase: "new" });
|
|
430
|
+
},
|
|
431
|
+
[topic, topicProp],
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
// ---- actions --------------------------------------------------------------
|
|
435
|
+
const run = useCallback(async (p: Promise<unknown>, done?: string) => {
|
|
436
|
+
try {
|
|
437
|
+
await p;
|
|
438
|
+
if (done) toast.success(done);
|
|
439
|
+
} catch (e) {
|
|
440
|
+
toast.error(errorText(e));
|
|
441
|
+
}
|
|
442
|
+
}, []);
|
|
443
|
+
|
|
444
|
+
const ctx: ThreadContextValue = {
|
|
445
|
+
me,
|
|
446
|
+
resolveUser,
|
|
447
|
+
role,
|
|
448
|
+
isGroup,
|
|
449
|
+
canModerate: role === "owner" || role === "admin",
|
|
450
|
+
canPin: role === "owner" || role === "admin" || members.length === 2,
|
|
451
|
+
canPost,
|
|
452
|
+
pins,
|
|
453
|
+
urls,
|
|
454
|
+
byId,
|
|
455
|
+
quickReactions,
|
|
456
|
+
attachmentSlot: slots?.attachment,
|
|
457
|
+
attachmentKinds,
|
|
458
|
+
previews,
|
|
459
|
+
emojibaseUrl,
|
|
460
|
+
linkPreviewImages,
|
|
461
|
+
linkPreviewProxyUrl,
|
|
462
|
+
highlightedId,
|
|
463
|
+
editingId,
|
|
464
|
+
reply: (m) => {
|
|
465
|
+
setReplyTo(m);
|
|
466
|
+
composer.current?.focus();
|
|
467
|
+
},
|
|
468
|
+
focusComposer: () => composer.current?.focus(),
|
|
469
|
+
startEdit: setEditingId,
|
|
470
|
+
saveEdit: async (m, body) => {
|
|
471
|
+
try {
|
|
472
|
+
await actions.edit(m, body, parseMentions(body));
|
|
473
|
+
} catch (e) {
|
|
474
|
+
toast.error(errorText(e));
|
|
475
|
+
throw e;
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
askDelete: setConfirmDelete,
|
|
479
|
+
askReport: setReporting,
|
|
480
|
+
react: (m, emoji, on) => void run(actions.react(m, emoji, on)),
|
|
481
|
+
pin: (m, on) => void run(actions.pin(m, on), on ? "Pinned" : "Unpinned"),
|
|
482
|
+
save: (m) => void run(actions.save(m, true), "Saved"),
|
|
483
|
+
copy: (m) => {
|
|
484
|
+
const text = plainText(m.body);
|
|
485
|
+
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) void run(navigator.clipboard.writeText(text), "Copied");
|
|
486
|
+
else toast.error("Copying isn't available here");
|
|
487
|
+
},
|
|
488
|
+
jumpTo,
|
|
489
|
+
retry: (clientId) => void state.retry(clientId),
|
|
490
|
+
discard: (clientId) => void state.discard(clientId),
|
|
491
|
+
openThread: onOpenThread,
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
// ---- drag and drop --------------------------------------------------------
|
|
495
|
+
const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files");
|
|
496
|
+
const dropProps = canPost
|
|
497
|
+
? {
|
|
498
|
+
onDragOver: (e: DragEvent<HTMLDivElement>) => {
|
|
499
|
+
if (!hasFiles(e)) return;
|
|
500
|
+
e.preventDefault();
|
|
501
|
+
if (!dragging) setDragging(true);
|
|
502
|
+
},
|
|
503
|
+
onDragLeave: (e: DragEvent<HTMLDivElement>) => {
|
|
504
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setDragging(false);
|
|
505
|
+
},
|
|
506
|
+
onDrop: (e: DragEvent<HTMLDivElement>) => {
|
|
507
|
+
if (!hasFiles(e)) return;
|
|
508
|
+
e.preventDefault();
|
|
509
|
+
setDragging(false);
|
|
510
|
+
composer.current?.addFiles(e.dataTransfer.files);
|
|
511
|
+
},
|
|
512
|
+
}
|
|
513
|
+
: {};
|
|
514
|
+
|
|
515
|
+
// ---- render ---------------------------------------------------------------
|
|
516
|
+
const pinnedMessage = pinIds.length ? byId.get(pinIds[pinIds.length - 1]!) : undefined;
|
|
517
|
+
let body: ReactNode;
|
|
518
|
+
if (state.isLoading) {
|
|
519
|
+
body = (
|
|
520
|
+
<div className="flex flex-col gap-3 p-4" aria-busy aria-label="Loading messages" data-testid="genesis-chat-thread-loading">
|
|
521
|
+
{[40, 60, 30, 50].map((w, i) => (
|
|
522
|
+
<Skeleton key={i} className={cn("h-9 rounded-2xl", i % 2 ? "self-end" : "self-start")} style={{ width: `${w}%` }} />
|
|
523
|
+
))}
|
|
524
|
+
</div>
|
|
525
|
+
);
|
|
526
|
+
} else if (state.error && items.length === 0) {
|
|
527
|
+
body = (
|
|
528
|
+
<EmptyState
|
|
529
|
+
icon={MessageSquare}
|
|
530
|
+
title="Couldn't load this conversation"
|
|
531
|
+
description={errorText(state.error)}
|
|
532
|
+
action={
|
|
533
|
+
<Button type="button" variant="outline" onClick={() => void state.refetch()}>
|
|
534
|
+
Try again
|
|
535
|
+
</Button>
|
|
536
|
+
}
|
|
537
|
+
/>
|
|
538
|
+
);
|
|
539
|
+
} else if (items.length === 0) {
|
|
540
|
+
body = renderSlot(DefaultEmpty, slots?.empty, { conversationId, canPost });
|
|
541
|
+
} else {
|
|
542
|
+
body = (
|
|
543
|
+
<div ref={content} className="flex min-h-full flex-col justify-end pb-2 pt-1">
|
|
544
|
+
{hasEarlier ? (
|
|
545
|
+
<div className="flex justify-center py-2">
|
|
546
|
+
{loadingEarlier ? (
|
|
547
|
+
<Spinner size="sm" aria-label="Loading earlier messages" />
|
|
548
|
+
) : (
|
|
549
|
+
<Button type="button" variant="ghost" size="sm" className="h-7 text-xs text-muted-foreground" onClick={loadOlder}>
|
|
550
|
+
Load earlier messages
|
|
551
|
+
</Button>
|
|
552
|
+
)}
|
|
553
|
+
</div>
|
|
554
|
+
) : null}
|
|
555
|
+
{items.map((item, i) => {
|
|
556
|
+
const id = item.type === "message" ? item.message.id : null;
|
|
557
|
+
return (
|
|
558
|
+
<div key={itemKey(item)}>
|
|
559
|
+
{startsDay(items, i) ? (
|
|
560
|
+
<div className="sticky top-1 z-10 flex justify-center py-2" role="separator" aria-label={dayLabel(itemTime(item))}>
|
|
561
|
+
<span className="rounded-full border bg-background/95 px-3 py-0.5 text-xs font-medium text-muted-foreground shadow-sm">{dayLabel(itemTime(item))}</span>
|
|
562
|
+
</div>
|
|
563
|
+
) : null}
|
|
564
|
+
{id && id === state.firstUnreadId ? (
|
|
565
|
+
<div className="my-2 flex items-center gap-2 px-4" role="separator" aria-label="New messages" data-testid="genesis-chat-new-line">
|
|
566
|
+
<span className="h-px flex-1 bg-primary/40" />
|
|
567
|
+
<span className="text-xs font-semibold text-primary">New</span>
|
|
568
|
+
<span className="h-px flex-1 bg-primary/40" />
|
|
569
|
+
</div>
|
|
570
|
+
) : null}
|
|
571
|
+
<ChatErrorBoundary
|
|
572
|
+
fallback={
|
|
573
|
+
<p className="px-4 py-1 text-xs italic text-muted-foreground" role="note" data-testid="genesis-chat-row-error">
|
|
574
|
+
This message couldn't be shown.
|
|
575
|
+
</p>
|
|
576
|
+
}
|
|
577
|
+
>
|
|
578
|
+
<MessageRow item={item} startsGroup={startsGroup(items, i)} />
|
|
579
|
+
</ChatErrorBoundary>
|
|
580
|
+
</div>
|
|
581
|
+
);
|
|
582
|
+
})}
|
|
583
|
+
</div>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
return (
|
|
588
|
+
<ThreadContext.Provider value={ctx}>
|
|
589
|
+
<section
|
|
590
|
+
className={cn("relative flex h-full min-h-0 flex-col bg-background", className)}
|
|
591
|
+
aria-label={title}
|
|
592
|
+
data-testid="genesis-chat-thread"
|
|
593
|
+
{...dropProps}
|
|
594
|
+
>
|
|
595
|
+
{renderSlot(DefaultHeader, slots?.header, {
|
|
596
|
+
conversationId,
|
|
597
|
+
conversation,
|
|
598
|
+
title,
|
|
599
|
+
subtitle,
|
|
600
|
+
activity: live,
|
|
601
|
+
otherUserId,
|
|
602
|
+
members,
|
|
603
|
+
role,
|
|
604
|
+
resolveUser,
|
|
605
|
+
onBack,
|
|
606
|
+
backClassName,
|
|
607
|
+
onSearch: search && !threadRootId ? () => setSearching(true) : undefined,
|
|
608
|
+
})}
|
|
609
|
+
{showTopicBar ? (
|
|
610
|
+
<ChatTopicBar
|
|
611
|
+
topics={topics}
|
|
612
|
+
value={topic ?? null}
|
|
613
|
+
onValueChange={setChosenTopic}
|
|
614
|
+
canManage={canManageTopics}
|
|
615
|
+
onSetTopic={topicsState.setTopic}
|
|
616
|
+
/>
|
|
617
|
+
) : null}
|
|
618
|
+
{awayPeople ? <ChatAwayBanner people={awayPeople} /> : null}
|
|
619
|
+
{pinnedMessage ? (
|
|
620
|
+
<button
|
|
621
|
+
type="button"
|
|
622
|
+
onClick={() => jumpTo(pinnedMessage.id)}
|
|
623
|
+
className="flex items-center gap-2 border-b bg-muted/40 px-3 py-1.5 text-left text-xs hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
624
|
+
aria-label={`Pinned message: ${previewText(pinnedMessage.body, pinnedMessage.attachments[0]?.kind ?? null, Boolean(pinnedMessage.deletedAt))}. Show it`}
|
|
625
|
+
>
|
|
626
|
+
<Pin className="size-3.5 shrink-0 text-primary" aria-hidden />
|
|
627
|
+
<span className="truncate">{previewText(pinnedMessage.body, pinnedMessage.attachments[0]?.kind ?? null, Boolean(pinnedMessage.deletedAt))}</span>
|
|
628
|
+
{pinIds.length > 1 ? <span className="ml-auto shrink-0 text-muted-foreground">+{pinIds.length - 1}</span> : null}
|
|
629
|
+
</button>
|
|
630
|
+
) : null}
|
|
631
|
+
|
|
632
|
+
<div className="relative min-h-0 flex-1">
|
|
633
|
+
{/* A plain scroller, not ScrollArea: the thread sets scrollTop to keep your place as earlier messages load, and needs the element that scrolls. */}
|
|
634
|
+
<div ref={scroller} onScroll={onScroll} className="h-full overflow-y-auto overscroll-contain" role="log" aria-live="off" aria-label="Messages" data-testid="genesis-chat-scroller">
|
|
635
|
+
{body}
|
|
636
|
+
</div>
|
|
637
|
+
{newCount > 0 ? (
|
|
638
|
+
<Button
|
|
639
|
+
type="button"
|
|
640
|
+
size="sm"
|
|
641
|
+
className="absolute bottom-3 left-1/2 h-8 -translate-x-1/2 rounded-full shadow-md"
|
|
642
|
+
onClick={() => scrollToBottom(true)}
|
|
643
|
+
data-testid="genesis-chat-new-pill"
|
|
644
|
+
>
|
|
645
|
+
New messages <ArrowDown />
|
|
646
|
+
</Button>
|
|
647
|
+
) : null}
|
|
648
|
+
{dragging ? (
|
|
649
|
+
<div className="pointer-events-none absolute inset-2 flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-primary bg-background/90 text-sm font-medium text-primary">
|
|
650
|
+
<ImagePlus className="size-6" aria-hidden />
|
|
651
|
+
Drop to attach
|
|
652
|
+
</div>
|
|
653
|
+
) : null}
|
|
654
|
+
</div>
|
|
655
|
+
|
|
656
|
+
<div className="h-5 px-4 text-xs italic text-muted-foreground" data-testid="genesis-chat-activity">
|
|
657
|
+
{live ? (
|
|
658
|
+
<span className="inline-flex items-center gap-1.5">
|
|
659
|
+
<span className="inline-flex gap-0.5" aria-hidden>
|
|
660
|
+
<span className="size-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.3s]" />
|
|
661
|
+
<span className="size-1 animate-bounce rounded-full bg-muted-foreground [animation-delay:-0.15s]" />
|
|
662
|
+
<span className="size-1 animate-bounce rounded-full bg-muted-foreground" />
|
|
663
|
+
</span>
|
|
664
|
+
{live}
|
|
665
|
+
</span>
|
|
666
|
+
) : null}
|
|
667
|
+
</div>
|
|
668
|
+
<div className="sr-only" aria-live="polite" aria-atomic="true" data-testid="genesis-chat-announcer">
|
|
669
|
+
{announcement}
|
|
670
|
+
</div>
|
|
671
|
+
|
|
672
|
+
<ChatComposer
|
|
673
|
+
ref={composer}
|
|
674
|
+
conversation={state}
|
|
675
|
+
replyTo={replyTo}
|
|
676
|
+
onCancelReply={() => setReplyTo(null)}
|
|
677
|
+
resolveUser={resolveUser}
|
|
678
|
+
placeholder={currentTopic ? `Message #${currentTopic.title}` : placeholder}
|
|
679
|
+
topic={topic ?? null}
|
|
680
|
+
disabledReason={topicClosed ? "This topic is closed" : null}
|
|
681
|
+
scheduling={scheduling}
|
|
682
|
+
emojibaseUrl={emojibaseUrl}
|
|
683
|
+
accessory={renderSlot(EmptyAccessory, slots?.composerAccessory, {
|
|
684
|
+
conversationId,
|
|
685
|
+
insertText: (t) => composer.current?.insertText(t),
|
|
686
|
+
addFiles: (f) => composer.current?.addFiles(f),
|
|
687
|
+
})}
|
|
688
|
+
/>
|
|
689
|
+
</section>
|
|
690
|
+
|
|
691
|
+
{search && !threadRootId ? (
|
|
692
|
+
<Sheet open={searching} onOpenChange={setSearching}>
|
|
693
|
+
<SheetContent side="right" className="flex w-full flex-col gap-0 p-0 sm:max-w-md">
|
|
694
|
+
<SheetHeader className="border-b px-4 py-3">
|
|
695
|
+
<SheetTitle>Search</SheetTitle>
|
|
696
|
+
<SheetDescription className="sr-only">Search this conversation and jump to a message.</SheetDescription>
|
|
697
|
+
</SheetHeader>
|
|
698
|
+
<ChatSearch conversationId={conversationId} resolveUser={resolveUser} onOpen={openHit} autoFocus className="min-h-0 flex-1" />
|
|
699
|
+
</SheetContent>
|
|
700
|
+
</Sheet>
|
|
701
|
+
) : null}
|
|
702
|
+
|
|
703
|
+
<AlertDialog open={confirmDelete !== null} onOpenChange={(o) => !o && setConfirmDelete(null)}>
|
|
704
|
+
<AlertDialogContent>
|
|
705
|
+
<AlertDialogHeader>
|
|
706
|
+
<AlertDialogTitle>Delete this message?</AlertDialogTitle>
|
|
707
|
+
<AlertDialogDescription>
|
|
708
|
+
{confirmDelete?.senderId === me ? "It's deleted for everyone in the conversation." : "It's deleted for everyone in the conversation, including the person who sent it."}
|
|
709
|
+
</AlertDialogDescription>
|
|
710
|
+
</AlertDialogHeader>
|
|
711
|
+
<AlertDialogFooter>
|
|
712
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
713
|
+
<AlertDialogAction
|
|
714
|
+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
715
|
+
onClick={() => {
|
|
716
|
+
const m = confirmDelete;
|
|
717
|
+
setConfirmDelete(null);
|
|
718
|
+
if (m) void run(actions.remove(m));
|
|
719
|
+
}}
|
|
720
|
+
data-testid="genesis-chat-confirm-delete"
|
|
721
|
+
>
|
|
722
|
+
Delete
|
|
723
|
+
</AlertDialogAction>
|
|
724
|
+
</AlertDialogFooter>
|
|
725
|
+
</AlertDialogContent>
|
|
726
|
+
</AlertDialog>
|
|
727
|
+
|
|
728
|
+
<ReportDialog
|
|
729
|
+
message={reporting}
|
|
730
|
+
onClose={() => setReporting(null)}
|
|
731
|
+
onSubmit={async (m, reason, note) => {
|
|
732
|
+
await run(actions.report(m, reason, note || undefined), "Reported. Thank you.");
|
|
733
|
+
setReporting(null);
|
|
734
|
+
}}
|
|
735
|
+
/>
|
|
736
|
+
</ThreadContext.Provider>
|
|
737
|
+
);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function EmptyAccessory(_props: ChatComposerAccessoryProps) {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function ReportDialog({
|
|
745
|
+
message,
|
|
746
|
+
onClose,
|
|
747
|
+
onSubmit,
|
|
748
|
+
}: {
|
|
749
|
+
message: ChatMessage | null;
|
|
750
|
+
onClose: () => void;
|
|
751
|
+
onSubmit: (m: ChatMessage, reason: ReportReason, note: string) => Promise<void>;
|
|
752
|
+
}) {
|
|
753
|
+
const [reason, setReason] = useState<ReportReason>("spam");
|
|
754
|
+
const [note, setNote] = useState("");
|
|
755
|
+
const [busy, setBusy] = useState(false);
|
|
756
|
+
useEffect(() => {
|
|
757
|
+
if (message) {
|
|
758
|
+
setReason("spam");
|
|
759
|
+
setNote("");
|
|
760
|
+
}
|
|
761
|
+
}, [message]);
|
|
762
|
+
return (
|
|
763
|
+
<Dialog open={message !== null} onOpenChange={(o) => !o && onClose()}>
|
|
764
|
+
<DialogContent className="sm:max-w-md">
|
|
765
|
+
<DialogHeader>
|
|
766
|
+
<DialogTitle>Report this message</DialogTitle>
|
|
767
|
+
<DialogDescription>The people who look after this conversation see your report and the messages around it.</DialogDescription>
|
|
768
|
+
</DialogHeader>
|
|
769
|
+
<RadioGroup value={reason} onValueChange={(v) => setReason(v as ReportReason)} aria-label="Reason">
|
|
770
|
+
{REPORT_REASONS.map((r) => (
|
|
771
|
+
<div key={r.value} className="flex items-center gap-2">
|
|
772
|
+
<RadioGroupItem value={r.value} id={`genesis-chat-report-${r.value}`} />
|
|
773
|
+
<Label htmlFor={`genesis-chat-report-${r.value}`}>{r.label}</Label>
|
|
774
|
+
</div>
|
|
775
|
+
))}
|
|
776
|
+
</RadioGroup>
|
|
777
|
+
<Textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="Anything else? (optional)" aria-label="Details" maxLength={1000} />
|
|
778
|
+
<DialogFooter>
|
|
779
|
+
<Button type="button" variant="outline" onClick={onClose}>
|
|
780
|
+
Cancel
|
|
781
|
+
</Button>
|
|
782
|
+
<Button
|
|
783
|
+
type="button"
|
|
784
|
+
variant="destructive"
|
|
785
|
+
loading={busy}
|
|
786
|
+
onClick={async () => {
|
|
787
|
+
if (!message) return;
|
|
788
|
+
setBusy(true);
|
|
789
|
+
try {
|
|
790
|
+
await onSubmit(message, reason, note.trim());
|
|
791
|
+
} finally {
|
|
792
|
+
setBusy(false);
|
|
793
|
+
}
|
|
794
|
+
}}
|
|
795
|
+
>
|
|
796
|
+
Report
|
|
797
|
+
</Button>
|
|
798
|
+
</DialogFooter>
|
|
799
|
+
</DialogContent>
|
|
800
|
+
</Dialog>
|
|
801
|
+
);
|
|
802
|
+
}
|