@marktiderman/genesis-chat 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,571 @@
1
+ /**
2
+ * @feat:chat
3
+ * The composer: a growing text box (Enter sends, Shift+Enter is a new line;
4
+ * on touch screens Enter is a new line), photos and files from a picker, a
5
+ * paste or a drop, with previews to remove before sending, a reply banner,
6
+ * internal notes for observers, voice notes recorded in place, videos from the
7
+ * camera, emoji, and scheduled send (the arrow beside Send). Where you may not
8
+ * post it says why instead; when who you are couldn't load, it offers a retry.
9
+ */
10
+ import {
11
+ useCallback,
12
+ useEffect,
13
+ useImperativeHandle,
14
+ useRef,
15
+ useState,
16
+ type ChangeEvent,
17
+ type ClipboardEvent,
18
+ type KeyboardEvent,
19
+ type ReactNode,
20
+ type Ref,
21
+ } from "react";
22
+ import { CalendarClock, FileText, Lock, Mic, Paperclip, RefreshCw, SendHorizontal, Smile, Trash2, Video, X } from "lucide-react";
23
+ import {
24
+ Alert,
25
+ AlertDescription,
26
+ Button,
27
+ Popover,
28
+ PopoverContent,
29
+ PopoverTrigger,
30
+ Skeleton,
31
+ Textarea,
32
+ Toggle,
33
+ Tooltip,
34
+ cn,
35
+ toast,
36
+ } from "@marktiderman/genesis-ui";
37
+ import { scheduleLabel } from "../core/extras";
38
+ import { CHAT_LIMITS, attachmentKindFor, fileExtension, formatDuration, isAllowedExtension, randomId, recordingLimitMs } from "../core/media";
39
+ import type { LocalFile } from "../core/outbox";
40
+ import { parseMentions, previewText } from "../core/thread";
41
+ import type { ChatMessage } from "../core/types";
42
+ import { useChat, useScheduled, type ConversationState } from "../react/index";
43
+ import { ChatEmojiPicker, canPickEmoji } from "./emoji-picker";
44
+ import type { ResolveUser } from "./format";
45
+ import { ChatScheduleMenu, ChatScheduledList } from "./schedule";
46
+ import { ChatVideoRecorder } from "./video";
47
+ import { useVoiceRecorder, voiceFile } from "./use-voice-recorder";
48
+
49
+ export interface ChatComposerHandle {
50
+ /** Adds files as if picked (a drop on the thread, say). Refused ones get a toast. */
51
+ addFiles(files: File[] | FileList): void;
52
+ /** Puts text at the cursor. */
53
+ insertText(text: string): void;
54
+ focus(): void;
55
+ }
56
+
57
+ export interface ChatComposerProps {
58
+ /** From `useConversation`: how to send, and whether you may. */
59
+ conversation: Pick<ConversationState, "send" | "signalTyping" | "signalRecording" | "role" | "canPost" | "accessLoading" | "conversation"> &
60
+ Partial<Pick<ConversationState, "accessError" | "refetchAccess" | "signalRecordingVideo" | "conversationLoading">>;
61
+ /** The message being replied to; a banner shows above the box. */
62
+ replyTo?: ChatMessage | null;
63
+ onCancelReply?: () => void;
64
+ /** Names the replied-to person. */
65
+ resolveUser?: ResolveUser;
66
+ placeholder?: string;
67
+ /** Extra controls beside the attach button (the `composerAccessory` slot of ChatThread). */
68
+ accessory?: ReactNode;
69
+ /** Called after a message is queued. */
70
+ onSent?: () => void;
71
+ autoFocus?: boolean;
72
+ /** Shown instead of the box, with a lock (a closed topic). */
73
+ disabledReason?: string | null;
74
+ /** The topic new messages go to (scheduled ones too). */
75
+ topic?: string | null;
76
+ /** Offer scheduled send (the arrow beside Send) and list what's scheduled. Default true. */
77
+ scheduling?: boolean;
78
+ /** Offer video recording. Default true where the browser can record. */
79
+ video?: boolean;
80
+ /** Offer the emoji picker (only when `emojibaseUrl` is given). Default true. */
81
+ emoji?: boolean;
82
+ /** Where the emoji picker loads its data from. */
83
+ emojibaseUrl?: string;
84
+ className?: string;
85
+ ref?: Ref<ChatComposerHandle>;
86
+ }
87
+
88
+ interface Draft {
89
+ id: string;
90
+ file: File;
91
+ previewUrl: string | null;
92
+ kind: string;
93
+ }
94
+
95
+ const isTouch = () => typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(pointer: coarse)").matches;
96
+ const objectUrl = (f: Blob) => (typeof URL !== "undefined" && typeof URL.createObjectURL === "function" ? URL.createObjectURL(f) : null);
97
+ const revoke = (u: string | null) => {
98
+ if (u && typeof URL !== "undefined" && typeof URL.revokeObjectURL === "function") URL.revokeObjectURL(u);
99
+ };
100
+ const MAX_HEIGHT = 160;
101
+
102
+ export function ChatComposer({
103
+ conversation: state,
104
+ replyTo,
105
+ onCancelReply,
106
+ resolveUser,
107
+ placeholder = "Message",
108
+ accessory,
109
+ onSent,
110
+ autoFocus,
111
+ disabledReason,
112
+ topic,
113
+ scheduling = true,
114
+ video = true,
115
+ emoji = true,
116
+ emojibaseUrl,
117
+ className,
118
+ ref,
119
+ }: ChatComposerProps) {
120
+ const { userId: me, videoUpload } = useChat();
121
+ const conversationId = state.conversation?.id ?? null;
122
+ const scheduled = useScheduled(conversationId, { enabled: scheduling && Boolean(conversationId) });
123
+ const [recordingVideo, setRecordingVideo] = useState(false);
124
+ const [emojiOpen, setEmojiOpen] = useState(false);
125
+ const canRecordVideo = video && typeof MediaRecorder !== "undefined" && typeof navigator !== "undefined" && Boolean(navigator.mediaDevices?.getUserMedia);
126
+ const [text, setText] = useState("");
127
+ const [drafts, setDrafts] = useState<Draft[]>([]);
128
+ const [internal, setInternal] = useState(false);
129
+ const textarea = useRef<HTMLTextAreaElement>(null);
130
+ const picker = useRef<HTMLInputElement>(null);
131
+ const draftsRef = useRef(drafts);
132
+ draftsRef.current = drafts;
133
+ // One clientId per scheduled draft, kept until it is scheduled, so pressing Schedule again
134
+ // after a timeout (the server may have saved it) never schedules it twice.
135
+ const scheduleId = useRef<string | null>(null);
136
+ const [schedulingNow, setSchedulingNow] = useState(false);
137
+ const schedulingRef = useRef(false);
138
+
139
+ const recorder = useVoiceRecorder({
140
+ maxMs: recordingLimitMs(state.role),
141
+ onTick: state.signalRecording,
142
+ onLimit: (rec) => {
143
+ void sendVoice(rec);
144
+ toast.info(`Voice messages stop at ${formatDuration(recordingLimitMs(state.role))}. Sent.`);
145
+ },
146
+ });
147
+
148
+ // Previews not sent are freed when the composer goes.
149
+ useEffect(() => () => draftsRef.current.forEach((d) => revoke(d.previewUrl)), []);
150
+
151
+ const autosize = useCallback(() => {
152
+ const el = textarea.current;
153
+ if (!el) return;
154
+ el.style.height = "auto";
155
+ el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT)}px`;
156
+ }, []);
157
+ useEffect(autosize, [text, autosize]);
158
+
159
+ useEffect(() => {
160
+ if (replyTo) textarea.current?.focus();
161
+ }, [replyTo]);
162
+
163
+ const addFiles = useCallback((list: File[] | FileList) => {
164
+ const files = Array.from(list);
165
+ if (files.length === 0) return;
166
+ const next: Draft[] = [];
167
+ let room = CHAT_LIMITS.attachmentsPerMessage - draftsRef.current.length;
168
+ for (const file of files) {
169
+ const ext = fileExtension(file.name, file.type);
170
+ if (!isAllowedExtension(ext)) {
171
+ toast.error(`${file.name} can't be sent`, { description: "Photos, videos, audio, PDFs and office documents can." });
172
+ continue;
173
+ }
174
+ const type = file.type || "application/octet-stream";
175
+ const kind = attachmentKindFor(type);
176
+ const limit = kind === "video" ? CHAT_LIMITS.videoBytes : CHAT_LIMITS.fileBytes;
177
+ if (file.size > limit) {
178
+ toast.error(`${file.name} is too big`, { description: `The limit is ${Math.round(limit / 1024 / 1024)} MB.` });
179
+ continue;
180
+ }
181
+ if (room <= 0) {
182
+ toast.error(`Up to ${CHAT_LIMITS.attachmentsPerMessage} files per message`);
183
+ break;
184
+ }
185
+ room -= 1;
186
+ next.push({ id: randomId(), file, kind, previewUrl: kind === "image" || kind === "video" ? objectUrl(file) : null });
187
+ }
188
+ if (next.length) setDrafts((d) => [...d, ...next]);
189
+ }, []);
190
+
191
+ const insertText = useCallback((value: string) => {
192
+ const el = textarea.current;
193
+ setText((t) => {
194
+ if (!el) return t + value;
195
+ const start = el.selectionStart ?? t.length;
196
+ const end = el.selectionEnd ?? t.length;
197
+ return t.slice(0, start) + value + t.slice(end);
198
+ });
199
+ el?.focus();
200
+ }, []);
201
+
202
+ useImperativeHandle(ref, () => ({ addFiles, insertText, focus: () => textarea.current?.focus() }), [addFiles, insertText]);
203
+
204
+ const removeDraft = (id: string) =>
205
+ setDrafts((d) => {
206
+ const gone = d.find((x) => x.id === id);
207
+ revoke(gone?.previewUrl ?? null);
208
+ return d.filter((x) => x.id !== id);
209
+ });
210
+
211
+ const common = () => ({
212
+ replyToId: replyTo?.id ?? null,
213
+ visibility: internal ? ("observers" as const) : ("everyone" as const),
214
+ ...(topic ? { topic } : {}),
215
+ });
216
+
217
+ const scheduleAt = async (at: Date) => {
218
+ const body = text.trim();
219
+ if ((!body && drafts.length === 0) || !conversationId || schedulingRef.current) return;
220
+ schedulingRef.current = true;
221
+ setSchedulingNow(true);
222
+ scheduleId.current ??= randomId();
223
+ const files: LocalFile[] = drafts.map((d) => ({
224
+ data: d.file,
225
+ name: d.file.name,
226
+ contentType: d.file.type || "application/octet-stream",
227
+ meta: { kind: d.kind, bytes: d.file.size, name: d.file.name },
228
+ }));
229
+ try {
230
+ await scheduled.schedule(
231
+ { conversationId, clientId: scheduleId.current, body, mentions: parseMentions(body), replyToId: replyTo?.id ?? null, topic: topic ?? null, sendAt: at },
232
+ files,
233
+ );
234
+ scheduleId.current = null;
235
+ setText("");
236
+ drafts.forEach((d) => revoke(d.previewUrl));
237
+ setDrafts([]);
238
+ onCancelReply?.();
239
+ toast.success(`Scheduled for ${scheduleLabel(at)}`);
240
+ } catch (e) {
241
+ toast.error(e instanceof Error ? e.message : "Couldn't schedule it");
242
+ } finally {
243
+ schedulingRef.current = false;
244
+ setSchedulingNow(false);
245
+ }
246
+ };
247
+
248
+ const submit = () => {
249
+ const body = text.trim();
250
+ if (!body && drafts.length === 0) return;
251
+ if (body.length > CHAT_LIMITS.bodyChars) {
252
+ toast.error(`Messages can be up to ${CHAT_LIMITS.bodyChars.toLocaleString()} characters`);
253
+ return;
254
+ }
255
+ const files: LocalFile[] = drafts.map((d) => ({
256
+ data: d.file,
257
+ name: d.file.name,
258
+ contentType: d.file.type || "application/octet-stream",
259
+ previewUrl: d.previewUrl ?? undefined,
260
+ meta: { kind: d.kind, bytes: d.file.size, name: d.file.name },
261
+ }));
262
+ void state.send({ body, mentions: parseMentions(body), ...common() }, files);
263
+ setText("");
264
+ setDrafts([]);
265
+ onCancelReply?.();
266
+ onSent?.();
267
+ textarea.current?.focus();
268
+ };
269
+
270
+ async function sendVoice(rec: Awaited<ReturnType<typeof recorder.stop>>) {
271
+ if (!rec) return;
272
+ if (rec.durationMs < 700) {
273
+ toast.info("Hold on a little longer to record a voice message");
274
+ return;
275
+ }
276
+ await state.send({ body: "", ...common() }, [voiceFile(rec)]);
277
+ onCancelReply?.();
278
+ onSent?.();
279
+ }
280
+
281
+ const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
282
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && !isTouch()) {
283
+ e.preventDefault();
284
+ submit();
285
+ } else if (e.key === "Escape" && replyTo) {
286
+ onCancelReply?.();
287
+ }
288
+ };
289
+
290
+ const onChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
291
+ setText(e.target.value);
292
+ if (e.target.value) state.signalTyping();
293
+ };
294
+
295
+ const onPaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
296
+ const files = Array.from(e.clipboardData?.files ?? []);
297
+ if (files.length) {
298
+ e.preventDefault();
299
+ addFiles(files);
300
+ }
301
+ };
302
+
303
+ // Loading who you are in this conversation.
304
+ if (state.accessLoading) {
305
+ return (
306
+ <div className={cn("border-t p-3", className)} aria-busy>
307
+ <Skeleton className="h-10 w-full rounded-2xl" />
308
+ </div>
309
+ );
310
+ }
311
+
312
+ if (state.accessError) {
313
+ return (
314
+ <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">
315
+ <span>Couldn't check whether you can post here.</span>
316
+ <Button type="button" variant="outline" size="sm" className="h-7 gap-1" onClick={() => void state.refetchAccess?.()} data-testid="genesis-chat-access-retry">
317
+ <RefreshCw className="size-3.5" aria-hidden /> Try again
318
+ </Button>
319
+ </div>
320
+ );
321
+ }
322
+
323
+ // Who may post depends on the conversation's settings: wait for them rather than say the wrong reason.
324
+ if (!state.canPost && state.conversationLoading) {
325
+ return (
326
+ <div className={cn("border-t p-3", className)} aria-busy>
327
+ <Skeleton className="h-10 w-full rounded-2xl" />
328
+ </div>
329
+ );
330
+ }
331
+
332
+ if (state.canPost && disabledReason) {
333
+ return (
334
+ <div className={cn("border-t p-3 text-center text-sm text-muted-foreground", className)} role="status" data-testid="genesis-chat-composer-disabled">
335
+ <Lock className="mr-1 inline size-3.5 align-[-2px]" aria-hidden />
336
+ {disabledReason}
337
+ </div>
338
+ );
339
+ }
340
+
341
+ if (!state.canPost) {
342
+ const adminsOnly = state.conversation?.settings?.who_posts === "admins";
343
+ return (
344
+ <div className={cn("border-t p-3 text-center text-sm text-muted-foreground", className)} role="status" data-testid="genesis-chat-cannot-post">
345
+ <Lock className="mr-1 inline size-3.5 align-[-2px]" aria-hidden />
346
+ {adminsOnly ? "Only admins can post here" : "You can't post in this conversation"}
347
+ </div>
348
+ );
349
+ }
350
+
351
+ const hasContent = text.trim().length > 0 || drafts.length > 0;
352
+ const replyName = replyTo?.senderId ? (replyTo.senderId === me ? "yourself" : (resolveUser?.(replyTo.senderId).name ?? "message")) : "message";
353
+
354
+ return (
355
+ <div className={cn("border-t bg-background", className)} data-testid="genesis-chat-composer">
356
+ {recorder.error ? (
357
+ <Alert variant="destructive" className="m-2 w-auto" data-testid="genesis-chat-mic-error">
358
+ <Mic className="size-4" aria-hidden />
359
+ <AlertDescription className="flex items-start justify-between gap-2">
360
+ <span>{recorder.error}</span>
361
+ <Button type="button" variant="ghost" size="icon" className="size-6 shrink-0" onClick={recorder.clearError} aria-label="Dismiss">
362
+ <X />
363
+ </Button>
364
+ </AlertDescription>
365
+ </Alert>
366
+ ) : null}
367
+ {replyTo ? (
368
+ <div className="flex items-center gap-2 border-b px-3 py-2" data-testid="genesis-chat-reply-banner">
369
+ <div className="min-w-0 flex-1 border-l-4 border-primary pl-2">
370
+ <div className="text-xs font-semibold text-primary">Replying to {replyName}</div>
371
+ <div className="truncate text-xs text-muted-foreground">{previewText(replyTo.body, replyTo.attachments[0]?.kind ?? null, Boolean(replyTo.deletedAt))}</div>
372
+ </div>
373
+ <Button type="button" variant="ghost" size="icon" className="size-7 shrink-0" onClick={onCancelReply} aria-label="Cancel reply">
374
+ <X />
375
+ </Button>
376
+ </div>
377
+ ) : null}
378
+ {scheduling && scheduled.items.length ? (
379
+ <div className="flex items-center px-3 pt-1.5">
380
+ <Popover>
381
+ <PopoverTrigger asChild>
382
+ <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">
383
+ <CalendarClock className="size-3.5" aria-hidden />
384
+ {scheduled.items.length === 1 ? "1 scheduled message" : `${scheduled.items.length} scheduled messages`}
385
+ </Button>
386
+ </PopoverTrigger>
387
+ <PopoverContent align="start" className="w-80 p-0">
388
+ <ChatScheduledList conversationId={conversationId} />
389
+ </PopoverContent>
390
+ </Popover>
391
+ </div>
392
+ ) : null}
393
+ {drafts.length ? (
394
+ <ul className="flex gap-2 overflow-x-auto px-3 pt-3" aria-label="Attachments to send">
395
+ {drafts.map((d) => (
396
+ <li key={d.id} className="relative shrink-0">
397
+ {d.kind === "image" && d.previewUrl ? (
398
+ <img src={d.previewUrl} alt={d.file.name} className="size-16 rounded-lg border object-cover" />
399
+ ) : d.kind === "video" && d.previewUrl ? (
400
+ <video src={d.previewUrl} muted playsInline preload="metadata" className="size-16 rounded-lg border object-cover" aria-label={d.file.name} />
401
+ ) : (
402
+ <span className="flex h-16 w-40 items-center gap-2 rounded-lg border bg-muted px-2 text-xs">
403
+ <FileText className="size-4 shrink-0" aria-hidden />
404
+ <span className="line-clamp-2 break-all">{d.file.name}</span>
405
+ </span>
406
+ )}
407
+ <Button
408
+ type="button"
409
+ size="icon"
410
+ variant="secondary"
411
+ className="absolute -right-1.5 -top-1.5 size-5 rounded-full border shadow-sm [&_svg]:size-3"
412
+ onClick={() => removeDraft(d.id)}
413
+ aria-label={`Remove ${d.file.name}`}
414
+ >
415
+ <X />
416
+ </Button>
417
+ </li>
418
+ ))}
419
+ </ul>
420
+ ) : null}
421
+
422
+ {recorder.recording || recorder.state === "requesting" ? (
423
+ <div className="flex items-center gap-2 p-2" data-testid="genesis-chat-recording">
424
+ <Button type="button" variant="ghost" size="icon" className="shrink-0 rounded-full text-destructive hover:text-destructive" onClick={recorder.cancel} aria-label="Cancel recording">
425
+ <Trash2 />
426
+ </Button>
427
+ <span className="size-2.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
428
+ <span className="w-24 shrink-0 text-sm tabular-nums" role="timer" aria-live="off">
429
+ {formatDuration(recorder.elapsedMs)}
430
+ <span className="text-muted-foreground"> / {formatDuration(recorder.maxMs)}</span>
431
+ </span>
432
+ <span className="flex h-8 flex-1 items-center justify-end gap-[2px] overflow-hidden" aria-hidden>
433
+ {recorder.levels.map((l, i) => (
434
+ <span key={i} className="w-[3px] shrink-0 rounded-full bg-primary" style={{ height: `${Math.max(10, Math.min(100, l * 140))}%` }} />
435
+ ))}
436
+ </span>
437
+ <span className="sr-only" role="status">
438
+ {recorder.state === "requesting" ? "Asking for the microphone" : "Recording a voice message"}
439
+ </span>
440
+ <Button
441
+ type="button"
442
+ size="icon"
443
+ className="shrink-0 rounded-full"
444
+ disabled={!recorder.recording}
445
+ onClick={() => void recorder.stop().then(sendVoice)}
446
+ aria-label="Send voice message"
447
+ data-testid="genesis-chat-voice-send"
448
+ >
449
+ <SendHorizontal />
450
+ </Button>
451
+ </div>
452
+ ) : (
453
+ <div className="flex items-end gap-1 p-2">
454
+ <input
455
+ ref={picker}
456
+ type="file"
457
+ multiple
458
+ className="hidden"
459
+ tabIndex={-1}
460
+ aria-hidden
461
+ accept="image/*,video/*,audio/*,.pdf,.txt,.csv,.docx,.xlsx,.pptx,.heic,.heif"
462
+ onChange={(e) => {
463
+ if (e.target.files) addFiles(e.target.files);
464
+ e.target.value = "";
465
+ }}
466
+ data-testid="genesis-chat-file-input"
467
+ />
468
+ <Tooltip content="Attach photos or files">
469
+ <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">
470
+ <Paperclip />
471
+ </Button>
472
+ </Tooltip>
473
+ {canRecordVideo && conversationId ? (
474
+ <Tooltip content="Record a video">
475
+ <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">
476
+ <Video />
477
+ </Button>
478
+ </Tooltip>
479
+ ) : null}
480
+ {accessory}
481
+ {state.role === "observer" ? (
482
+ <Tooltip content={internal ? "Internal note: only observers see it" : "Write an internal note"}>
483
+ <Toggle
484
+ pressed={internal}
485
+ onPressedChange={setInternal}
486
+ aria-label="Internal note"
487
+ size="sm"
488
+ className="h-10 shrink-0 rounded-full data-[state=on]:bg-warning/20"
489
+ >
490
+ <Lock className="size-4" />
491
+ </Toggle>
492
+ </Tooltip>
493
+ ) : null}
494
+ <Textarea
495
+ ref={textarea}
496
+ rows={1}
497
+ value={text}
498
+ onChange={onChange}
499
+ onKeyDown={onKeyDown}
500
+ onPaste={onPaste}
501
+ placeholder={internal ? "Internal note" : placeholder}
502
+ aria-label={internal ? "Internal note" : placeholder}
503
+ autoFocus={autoFocus}
504
+ maxLength={CHAT_LIMITS.bodyChars}
505
+ enterKeyHint="send"
506
+ className={cn(
507
+ "max-h-40 min-h-10 flex-1 resize-none rounded-2xl py-2 leading-snug",
508
+ internal && "border-warning/60 bg-warning/10",
509
+ )}
510
+ data-testid="genesis-chat-input"
511
+ />
512
+ {emoji && canPickEmoji(emojibaseUrl) ? (
513
+ <Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
514
+ <PopoverTrigger asChild>
515
+ <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">
516
+ <Smile />
517
+ </Button>
518
+ </PopoverTrigger>
519
+ <PopoverContent side="top" align="end" className="w-auto overflow-hidden p-0" onCloseAutoFocus={(e) => { e.preventDefault(); textarea.current?.focus(); }}>
520
+ <ChatEmojiPicker
521
+ emojibaseUrl={emojibaseUrl}
522
+ onSelect={(e) => {
523
+ insertText(e);
524
+ setEmojiOpen(false);
525
+ }}
526
+ />
527
+ </PopoverContent>
528
+ </Popover>
529
+ ) : null}
530
+ {hasContent ? (
531
+ <>
532
+ <Button type="button" size="icon" className="shrink-0 rounded-full" onClick={submit} aria-label="Send" data-testid="genesis-chat-send">
533
+ <SendHorizontal />
534
+ </Button>
535
+ {scheduling && conversationId && !internal ? <ChatScheduleMenu onSchedule={(at) => void scheduleAt(at)} disabled={schedulingNow} /> : null}
536
+ </>
537
+ ) : (
538
+ <Tooltip content="Record a voice message">
539
+ <Button
540
+ type="button"
541
+ variant="ghost"
542
+ size="icon"
543
+ className="shrink-0 rounded-full text-muted-foreground"
544
+ onClick={() => void recorder.start()}
545
+ aria-label="Record a voice message"
546
+ data-testid="genesis-chat-mic"
547
+ >
548
+ <Mic />
549
+ </Button>
550
+ </Tooltip>
551
+ )}
552
+ </div>
553
+ )}
554
+ {canRecordVideo && conversationId ? (
555
+ <ChatVideoRecorder
556
+ open={recordingVideo}
557
+ onOpenChange={setRecordingVideo}
558
+ conversationId={conversationId}
559
+ maxMs={recordingLimitMs(state.role)}
560
+ send={(input, files) => state.send({ ...input, ...common() }, files)}
561
+ videoUpload={videoUpload}
562
+ onTick={state.signalRecordingVideo ?? state.signalRecording}
563
+ onSent={() => {
564
+ onCancelReply?.();
565
+ onSent?.();
566
+ }}
567
+ />
568
+ ) : null}
569
+ </div>
570
+ );
571
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @feat:chat
3
+ * The full emoji picker, on frimousse (headless, MIT): a search box, every emoji by category
4
+ * in a virtualised grid, keyboard navigation and screen-reader labels from frimousse, styled
5
+ * here with Genesis tokens. Used for reactions ("+" beside the quick six) and in the composer.
6
+ * The emoji data loads from `emojibaseUrl`, which the app must give: host the `emojibase-data`
7
+ * package's `<locale>/data.json` and `<locale>/messages.json` (or name a CDN you accept, and add
8
+ * it to your CSP's `connect-src`). Without it the picker loads nothing from anywhere, says so,
9
+ * and the thread and composer don't offer it (the quick reactions still work). frimousse's own
10
+ * default would be jsDelivr's unpinned `emojibase-data@latest`, a third party seeing every
11
+ * reader's IP, so it is never used.
12
+ */
13
+ import { EmojiPicker } from "frimousse";
14
+ import { Spinner, cn } from "@marktiderman/genesis-ui";
15
+
16
+ /** The picker can load: an emojibase URL was given. */
17
+ export const canPickEmoji = (emojibaseUrl: string | null | undefined): emojibaseUrl is string => Boolean(emojibaseUrl?.trim());
18
+
19
+ export interface ChatEmojiPickerProps {
20
+ onSelect: (emoji: string) => void;
21
+ /** Where the Emojibase data is fetched from (`${emojibaseUrl}/en/data.json`). Required to load anything. */
22
+ emojibaseUrl?: string;
23
+ locale?: "en";
24
+ columns?: number;
25
+ className?: string;
26
+ }
27
+
28
+ export function ChatEmojiPicker({ onSelect, emojibaseUrl, locale, columns = 8, className }: ChatEmojiPickerProps) {
29
+ if (!canPickEmoji(emojibaseUrl)) {
30
+ if (typeof console !== "undefined") console.warn("genesis-chat: ChatEmojiPicker needs `emojibaseUrl` (self-hosted emojibase-data); it loads nothing without it.");
31
+ return (
32
+ <p className={cn("w-[18.5rem] p-3 text-sm text-muted-foreground", className)} role="note" data-testid="genesis-chat-emoji-picker-unset">
33
+ The emoji picker isn't set up.
34
+ </p>
35
+ );
36
+ }
37
+ return (
38
+ <EmojiPicker.Root
39
+ onEmojiSelect={(e) => onSelect(e.emoji)}
40
+ emojibaseUrl={emojibaseUrl}
41
+ locale={locale}
42
+ columns={columns}
43
+ className={cn("isolate flex h-80 w-[18.5rem] flex-col bg-popover text-popover-foreground", className)}
44
+ data-testid="genesis-chat-emoji-picker"
45
+ >
46
+ <EmojiPicker.Search
47
+ autoFocus
48
+ aria-label="Search emoji"
49
+ placeholder="Search emoji"
50
+ 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"
51
+ />
52
+ <EmojiPicker.Viewport className="relative flex-1 outline-none">
53
+ <EmojiPicker.Loading className="absolute inset-0 flex items-center justify-center">
54
+ <Spinner size="sm" aria-label="Loading emoji" />
55
+ </EmojiPicker.Loading>
56
+ <EmojiPicker.Empty className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">No emoji found</EmojiPicker.Empty>
57
+ <EmojiPicker.List
58
+ className="select-none pb-1.5"
59
+ components={{
60
+ CategoryHeader: ({ category, ...props }) => (
61
+ <div className="bg-popover px-3 pb-1 pt-2 text-xs font-medium text-muted-foreground" {...props}>
62
+ {category.label}
63
+ </div>
64
+ ),
65
+ Row: ({ children, ...props }) => (
66
+ <div className="scroll-my-1.5 px-1.5" {...props}>
67
+ {children}
68
+ </div>
69
+ ),
70
+ Emoji: ({ emoji, ...props }) => (
71
+ <button
72
+ type="button"
73
+ className={cn("flex size-8 items-center justify-center rounded-md text-xl", emoji.isActive && "bg-accent")}
74
+ {...props}
75
+ >
76
+ {emoji.emoji}
77
+ </button>
78
+ ),
79
+ }}
80
+ />
81
+ </EmojiPicker.Viewport>
82
+ </EmojiPicker.Root>
83
+ );
84
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * @feat:chat
3
+ * `ChatErrorBoundary`: what one message (or one app attachment card) draws can't take the whole
4
+ * thread down. Messages carry what other people sent, and app renderers get sender-controlled
5
+ * data, so a render that throws shows its fallback instead, and the error goes to `onError`
6
+ * (console.error by default) so it is never silent.
7
+ */
8
+ import { Component, type ErrorInfo, type ReactNode } from "react";
9
+
10
+ export interface ChatErrorBoundaryProps {
11
+ /** Drawn instead of the children once they throw. */
12
+ fallback: ReactNode;
13
+ /** Told about the error; console.error by default. */
14
+ onError?: (error: unknown, info: ErrorInfo) => void;
15
+ children: ReactNode;
16
+ }
17
+
18
+ export class ChatErrorBoundary extends Component<ChatErrorBoundaryProps, { failed: boolean }> {
19
+ override state = { failed: false };
20
+
21
+ static getDerivedStateFromError(): { failed: boolean } {
22
+ return { failed: true };
23
+ }
24
+
25
+ override componentDidCatch(error: unknown, info: ErrorInfo) {
26
+ if (this.props.onError) this.props.onError(error, info);
27
+ else console.error("genesis-chat: a message couldn't be drawn", error);
28
+ }
29
+
30
+ override render() {
31
+ return this.state.failed ? this.props.fallback : this.props.children;
32
+ }
33
+ }