@marktiderman/genesis-chat 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,585 @@
1
+ /**
2
+ * @feat:chat
3
+ * One row of a thread: a system line, or a bubble (yours on the right in the
4
+ * primary colour, theirs on the left), with its reply quote, body, attachments,
5
+ * time and edited marker, reactions, and actions on hover, right-click or
6
+ * long-press. Pending messages show "Sending…"; failed ones offer Retry and
7
+ * Delete.
8
+ */
9
+ import { useEffect, useRef, useState, type ComponentType, type MutableRefObject, type ReactNode } from "react";
10
+ import {
11
+ AlertCircle,
12
+ Ban,
13
+ Bookmark,
14
+ Clock,
15
+ Copy,
16
+ EyeOff,
17
+ Flag,
18
+ Lock,
19
+ MessageSquare,
20
+ MoreHorizontal,
21
+ Pencil,
22
+ Pin,
23
+ PinOff,
24
+ Plus,
25
+ Reply,
26
+ ShieldAlert,
27
+ SmilePlus,
28
+ Trash2,
29
+ } from "lucide-react";
30
+ import {
31
+ Badge,
32
+ Button,
33
+ ContextMenu,
34
+ ContextMenuContent,
35
+ ContextMenuItem,
36
+ ContextMenuSeparator,
37
+ ContextMenuTrigger,
38
+ DropdownMenu,
39
+ DropdownMenuContent,
40
+ DropdownMenuItem,
41
+ DropdownMenuSeparator,
42
+ DropdownMenuTrigger,
43
+ Popover,
44
+ PopoverContent,
45
+ PopoverTrigger,
46
+ Progress,
47
+ Textarea,
48
+ Tooltip,
49
+ UserAvatar,
50
+ cn,
51
+ } from "@marktiderman/genesis-ui";
52
+ import { attachmentKindFor } from "../core/media";
53
+ import type { QueuedMessage } from "../core/outbox";
54
+ import { previewText, summarizeReactions, type ThreadItem } from "../core/thread";
55
+ import type { ChatMessage, PendingMessage } from "../core/types";
56
+ import { MessageAttachments, toAttachmentView, type AttachmentView } from "./attachments";
57
+ import { ChatLinkPreview } from "./cards";
58
+ import { ChatEmojiPicker, canPickEmoji } from "./emoji-picker";
59
+ import { clockTime, messageLabel } from "./format";
60
+ import { ChatMarkdown } from "./markdown";
61
+ import { useThreadContext } from "./thread-context";
62
+
63
+ export interface MessageRowProps {
64
+ item: ThreadItem;
65
+ /** First of a run from one sender: the name and avatar show (others' messages, in groups). */
66
+ startsGroup: boolean;
67
+ className?: string;
68
+ }
69
+
70
+ /** The pending message as a message, so one bubble draws both. */
71
+ function pendingAsMessage(p: PendingMessage): ChatMessage {
72
+ return {
73
+ id: `pending:${p.clientId}`,
74
+ conversationId: p.input.conversationId,
75
+ seq: Number.MAX_SAFE_INTEGER,
76
+ clientId: p.clientId,
77
+ senderId: p.senderId,
78
+ senderRole: "member",
79
+ kind: "text",
80
+ body: p.input.body ?? "",
81
+ mentions: p.input.mentions ?? [],
82
+ replyToId: p.input.replyToId ?? null,
83
+ threadRootId: p.input.threadRootId ?? null,
84
+ threadCount: 0,
85
+ threadLastAt: null,
86
+ topic: p.input.topic ?? null,
87
+ visibility: p.input.visibility ?? "everyone",
88
+ silent: Boolean(p.input.silent),
89
+ meta: {},
90
+ createdAt: p.createdAt,
91
+ editedAt: null,
92
+ deletedAt: null,
93
+ moderation: null,
94
+ attachments: [],
95
+ reactions: [],
96
+ };
97
+ }
98
+
99
+ function pendingViews(p: PendingMessage): AttachmentView[] {
100
+ const files = (p as Partial<QueuedMessage>).files ?? [];
101
+ return files.map((f, i) => ({
102
+ key: `${p.clientId}:${i}`,
103
+ kind: f.meta?.kind ?? attachmentKindFor(f.contentType),
104
+ url: p.previews?.[i] ?? f.previewUrl ?? null,
105
+ posterUrl: null,
106
+ name: f.meta?.name ?? f.name,
107
+ mime: f.contentType,
108
+ bytes: f.meta?.bytes ?? (typeof Blob !== "undefined" && f.data instanceof Blob ? f.data.size : null),
109
+ width: f.meta?.width ?? null,
110
+ height: f.meta?.height ?? null,
111
+ durationMs: f.meta?.durationMs ?? null,
112
+ waveform: f.meta?.waveform ?? null,
113
+ transcript: null,
114
+ transcriptStatus: null,
115
+ pending: p.status === "sending",
116
+ }));
117
+ }
118
+
119
+ /** How much of a sending message's files has uploaded (0–100), once any file reports progress. */
120
+ export function pendingProgress(p: PendingMessage): number | null {
121
+ if (p.status !== "sending") return null;
122
+ const files = p.files ?? [];
123
+ if (!files.some((f) => typeof f.progress === "number")) return null;
124
+ return Math.round((files.reduce((sum, f) => sum + (f.progress ?? 0), 0) / files.length) * 100);
125
+ }
126
+
127
+ /** A menu gives focus back to what opened it, unless the action put it somewhere (the composer, the edit box). */
128
+ function returnFocus(e: Event, keepFocus: MutableRefObject<(() => void) | null>) {
129
+ const next = keepFocus.current;
130
+ if (next) {
131
+ e.preventDefault();
132
+ keepFocus.current = null;
133
+ next();
134
+ }
135
+ }
136
+
137
+ /** Plain text for the clipboard: mentions as @Name. */
138
+ export function plainText(body: string): string {
139
+ return body.replace(/@\[([^\]]+)\]\(user:[0-9a-f-]{36}\)/gi, "@$1");
140
+ }
141
+
142
+ export function MessageRow({ item, startsGroup, className }: MessageRowProps) {
143
+ const ctx = useThreadContext();
144
+ const keepFocus = useRef<(() => void) | null>(null);
145
+ const pending = item.type === "pending" ? item.pending : null;
146
+ const message = item.type === "message" ? item.message : pendingAsMessage(item.pending);
147
+
148
+ if (message.kind === "system") {
149
+ return (
150
+ <div className={cn("flex justify-center px-4 py-1", className)} role="note" data-testid="genesis-chat-system">
151
+ <span className="max-w-[85%] rounded-full bg-muted px-3 py-1 text-center text-xs text-muted-foreground">
152
+ <ChatMarkdown text={message.body} me={ctx.me} />
153
+ </span>
154
+ </div>
155
+ );
156
+ }
157
+
158
+ const mine = message.senderId === ctx.me;
159
+ const sender = message.senderId ? ctx.resolveUser(message.senderId) : { name: "Someone" };
160
+ const deleted = Boolean(message.deletedAt);
161
+ const note = message.visibility === "observers";
162
+ const showHeader = ctx.isGroup && !mine && startsGroup;
163
+ const tone: "default" | "primary" = mine && !note && !deleted ? "primary" : "default";
164
+ const views = pending ? pendingViews(pending) : message.attachments.map((a) => toAttachmentView(a, ctx.urls));
165
+ const progress = pending ? pendingProgress(pending) : null;
166
+ const editing = ctx.editingId === message.id;
167
+ const reactions = pending ? [] : summarizeReactions(message, ctx.me);
168
+ const highlighted = ctx.highlightedId === message.id;
169
+ const actionable = !pending && !deleted;
170
+ const hidden = !deleted && message.moderation === "hidden";
171
+ const flagged = !deleted && message.moderation === "flagged";
172
+ const observer = ctx.role === "observer";
173
+ // One preview per message, iMessage-style: the first link the server has fetched.
174
+ const preview = !pending && !deleted && !hidden && message.body ? ctx.previews[message.id]?.[0] : undefined;
175
+
176
+ const bubble = (
177
+ <div
178
+ className={cn(
179
+ "relative min-w-0 max-w-full rounded-2xl px-3 py-2 text-sm leading-relaxed shadow-sm transition-shadow",
180
+ tone === "primary" ? "bg-primary text-primary-foreground" : "bg-muted text-foreground",
181
+ mine ? (startsGroup ? "rounded-tr-md" : "") : startsGroup ? "rounded-tl-md" : "",
182
+ note && "border border-warning/50 bg-warning/15 text-foreground",
183
+ deleted && "border border-dashed bg-transparent text-muted-foreground shadow-none",
184
+ highlighted && "ring-2 ring-ring ring-offset-2 ring-offset-background",
185
+ pending?.status === "failed" && "opacity-80",
186
+ hidden && "opacity-70",
187
+ )}
188
+ data-testid={deleted ? "genesis-chat-deleted" : note ? "genesis-chat-note" : mine ? "genesis-chat-mine" : "genesis-chat-theirs"}
189
+ >
190
+ {note ? (
191
+ <div className="mb-1 flex items-center gap-1 text-xs font-semibold text-foreground/80">
192
+ <Lock className="size-3" aria-hidden /> Internal note
193
+ </div>
194
+ ) : null}
195
+ {deleted ? (
196
+ <span className="flex items-center gap-1.5 italic">
197
+ <Ban className="size-3.5" aria-hidden /> Message deleted
198
+ </span>
199
+ ) : editing ? (
200
+ <EditBox message={message} />
201
+ ) : (
202
+ <>
203
+ {message.replyToId ? <ReplyQuote id={message.replyToId} tone={tone} /> : null}
204
+ {views.length ? (
205
+ <div className={cn(message.body ? "mb-1.5" : "")}>
206
+ <MessageAttachments views={views} message={pending ? undefined : message} mine={mine} tone={tone} slot={ctx.attachmentSlot} kinds={ctx.attachmentKinds} />
207
+ {progress !== null ? (
208
+ <Progress
209
+ value={progress}
210
+ className={cn("mt-1.5 h-1", tone === "primary" && "bg-primary-foreground/30 [&>div]:bg-primary-foreground")}
211
+ role="progressbar"
212
+ aria-label="Uploading"
213
+ aria-valuemin={0}
214
+ aria-valuemax={100}
215
+ aria-valuenow={progress}
216
+ data-testid="genesis-chat-upload-progress"
217
+ />
218
+ ) : null}
219
+ </div>
220
+ ) : null}
221
+ {message.body ? <ChatMarkdown text={message.body} me={ctx.me} tone={tone} /> : null}
222
+ {preview ? <ChatLinkPreview preview={preview} images={ctx.linkPreviewImages} proxyUrl={ctx.linkPreviewProxyUrl} tone={tone} /> : null}
223
+ {hidden && mine && !observer ? (
224
+ <span className="mt-1 flex items-center gap-1 text-xs font-medium" data-testid="genesis-chat-hidden-notice">
225
+ <EyeOff className="size-3" aria-hidden /> Hidden by moderation. Only you can see it.
226
+ </span>
227
+ ) : null}
228
+ <Meta message={message} pending={pending} tone={tone} />
229
+ </>
230
+ )}
231
+ </div>
232
+ );
233
+
234
+ return (
235
+ <article
236
+ id={`genesis-chat-msg-${message.id}`}
237
+ data-message-id={pending ? undefined : message.id}
238
+ aria-label={`${messageLabel(message, mine ? "You" : sender.name)}${hidden ? " (hidden by moderation)" : flagged && observer ? " (flagged)" : ""}`}
239
+ className={cn("group/msg flex gap-2 px-3", mine ? "flex-row-reverse" : "flex-row", startsGroup ? "mt-2" : "mt-0.5", className)}
240
+ data-testid={pending ? `genesis-chat-pending-${pending.status}` : "genesis-chat-message"}
241
+ >
242
+ {ctx.isGroup && !mine ? (
243
+ showHeader ? (
244
+ <UserAvatar name={sender.name} src={sender.avatarUrl} size="sm" className="mt-5" />
245
+ ) : (
246
+ <span className="w-8 shrink-0" aria-hidden />
247
+ )
248
+ ) : null}
249
+ <div className={cn("flex min-w-0 max-w-[min(78%,36rem)] flex-col", mine ? "items-end" : "items-start")}>
250
+ {showHeader ? <span className="mb-0.5 px-3 text-xs font-medium text-muted-foreground">{sender.name}</span> : null}
251
+ {observer && (hidden || flagged) ? (
252
+ <Badge
253
+ variant={hidden ? "destructive" : "outline"}
254
+ className="mb-0.5 gap-1 px-1.5 py-0 text-[10px]"
255
+ data-testid={hidden ? "genesis-chat-moderation-hidden" : "genesis-chat-moderation-flagged"}
256
+ >
257
+ {hidden ? <EyeOff className="size-3" aria-hidden /> : <ShieldAlert className="size-3" aria-hidden />}
258
+ {hidden ? "Hidden by moderation" : "Flagged"}
259
+ </Badge>
260
+ ) : null}
261
+ <div className={cn("flex max-w-full items-center gap-1", mine ? "flex-row-reverse" : "flex-row")}>
262
+ {actionable ? (
263
+ <ContextMenu>
264
+ <ContextMenuTrigger asChild>{bubble}</ContextMenuTrigger>
265
+ <ContextMenuContent className="w-56" onCloseAutoFocus={(e) => returnFocus(e, keepFocus)}>
266
+ <ActionItems message={message} mine={mine} keepFocus={keepFocus} Item={ContextMenuItem} Separator={ContextMenuSeparator} />
267
+ </ContextMenuContent>
268
+ </ContextMenu>
269
+ ) : (
270
+ bubble
271
+ )}
272
+ {actionable && !editing ? <HoverBar message={message} mine={mine} /> : null}
273
+ </div>
274
+ {reactions.length ? (
275
+ <div className={cn("-mt-1 flex flex-wrap gap-1 px-2", mine ? "justify-end" : "justify-start")} data-testid="genesis-chat-reactions">
276
+ {reactions.map((r) => (
277
+ <Tooltip key={r.emoji} content={r.userIds.map((u) => (u === ctx.me ? "You" : ctx.resolveUser(u).name)).join(", ")}>
278
+ <button
279
+ type="button"
280
+ aria-pressed={r.mine}
281
+ aria-label={`${r.emoji} ${r.count}${r.mine ? ", including you" : ""}. ${r.mine ? "Remove" : "Add"} your reaction`}
282
+ onClick={() => ctx.react(message, r.emoji, !r.mine)}
283
+ disabled={!ctx.canPost}
284
+ className={cn(
285
+ "inline-flex h-6 items-center gap-1 rounded-full border bg-background px-2 text-xs shadow-sm transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none",
286
+ r.mine && "border-primary bg-primary/10 text-primary",
287
+ )}
288
+ data-testid={`genesis-chat-reaction-${r.emoji}`}
289
+ >
290
+ <span aria-hidden>{r.emoji}</span>
291
+ <span className="tabular-nums" aria-hidden>
292
+ {r.count}
293
+ </span>
294
+ </button>
295
+ </Tooltip>
296
+ ))}
297
+ </div>
298
+ ) : null}
299
+ {!pending && message.threadCount > 0 && ctx.openThread ? (
300
+ <Button type="button" variant="link" size="sm" className="h-6 px-2 text-xs" onClick={() => ctx.openThread?.(message)}>
301
+ <MessageSquare className="size-3" aria-hidden />
302
+ {message.threadCount === 1 ? "1 reply" : `${message.threadCount} replies`}
303
+ </Button>
304
+ ) : null}
305
+ {pending?.status === "failed" ? (
306
+ <div className="mt-1 flex items-center gap-1 text-xs text-destructive" role="alert">
307
+ <AlertCircle className="size-3.5" aria-hidden />
308
+ <span>Not sent{pending.error && pending.error !== "Not sent" ? `: ${pending.error}` : ""}</span>
309
+ <Button type="button" size="sm" variant="ghost" className="h-6 px-2 text-xs" onClick={() => ctx.retry(pending.clientId)} data-testid="genesis-chat-retry">
310
+ Retry
311
+ </Button>
312
+ <Button type="button" size="sm" variant="ghost" className="h-6 px-2 text-xs text-destructive hover:text-destructive" onClick={() => ctx.discard(pending.clientId)} data-testid="genesis-chat-discard">
313
+ Delete
314
+ </Button>
315
+ </div>
316
+ ) : null}
317
+ </div>
318
+ </article>
319
+ );
320
+ }
321
+
322
+ function Meta({ message, pending, tone }: { message: ChatMessage; pending: PendingMessage | null; tone: "default" | "primary" }) {
323
+ const muted = tone === "primary" ? "text-primary-foreground/75" : "text-muted-foreground";
324
+ return (
325
+ <span className={cn("float-right ml-3 mt-1 flex translate-y-1 items-center gap-1 text-[11px] leading-none", muted)}>
326
+ {message.editedAt ? <span>edited</span> : null}
327
+ {pending?.status === "sending" ? (
328
+ <>
329
+ <Clock className="size-3" aria-hidden />
330
+ <span>Sending…</span>
331
+ </>
332
+ ) : pending?.status === "failed" ? (
333
+ <AlertCircle className="size-3" aria-label="Not sent" />
334
+ ) : (
335
+ <time dateTime={message.createdAt}>{clockTime(message.createdAt)}</time>
336
+ )}
337
+ </span>
338
+ );
339
+ }
340
+
341
+ function ReplyQuote({ id, tone }: { id: string; tone: "default" | "primary" }) {
342
+ const ctx = useThreadContext();
343
+ const original = ctx.byId.get(id);
344
+ const who = original?.senderId ? (original.senderId === ctx.me ? "You" : ctx.resolveUser(original.senderId).name) : null;
345
+ const text = original ? previewText(original.body, original.attachments[0]?.kind ?? null, Boolean(original.deletedAt)) : "Earlier message";
346
+ return (
347
+ <button
348
+ type="button"
349
+ onClick={() => ctx.jumpTo(id)}
350
+ className={cn(
351
+ "mb-1.5 block w-full min-w-0 rounded-md border-l-4 px-2 py-1 text-left text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
352
+ tone === "primary" ? "border-primary-foreground/60 bg-primary-foreground/15" : "border-primary bg-background/60",
353
+ )}
354
+ aria-label={`Replying to ${who ?? "a message"}: ${text}. Show it`}
355
+ data-testid="genesis-chat-reply-quote"
356
+ >
357
+ {who ? <span className="block font-semibold">{who}</span> : null}
358
+ <span className="line-clamp-2 opacity-80">{text}</span>
359
+ </button>
360
+ );
361
+ }
362
+
363
+ function EditBox({ message }: { message: ChatMessage }) {
364
+ const ctx = useThreadContext();
365
+ const [value, setValue] = useState(message.body);
366
+ const [busy, setBusy] = useState(false);
367
+ const ref = useRef<HTMLTextAreaElement>(null);
368
+ useEffect(() => {
369
+ const el = ref.current;
370
+ if (!el) return;
371
+ el.focus();
372
+ el.setSelectionRange(el.value.length, el.value.length);
373
+ }, []);
374
+ const save = async () => {
375
+ const body = value.trim();
376
+ if (!body || body === message.body) return ctx.startEdit(null);
377
+ setBusy(true);
378
+ try {
379
+ await ctx.saveEdit(message, body);
380
+ ctx.startEdit(null);
381
+ } finally {
382
+ setBusy(false);
383
+ }
384
+ };
385
+ return (
386
+ <div className="flex min-w-[16rem] flex-col gap-2">
387
+ <Textarea
388
+ ref={ref}
389
+ value={value}
390
+ onChange={(e) => setValue(e.target.value)}
391
+ onKeyDown={(e) => {
392
+ if (e.key === "Escape") {
393
+ e.preventDefault();
394
+ ctx.startEdit(null);
395
+ } else if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
396
+ e.preventDefault();
397
+ void save();
398
+ }
399
+ }}
400
+ id={`genesis-chat-edit-${message.id}`}
401
+ aria-label="Edit message"
402
+ className="min-h-[60px] resize-none bg-background text-foreground"
403
+ />
404
+ <div className="flex justify-end gap-2">
405
+ <Button type="button" size="sm" variant="ghost" onClick={() => ctx.startEdit(null)} className={cn("h-7", "text-current")}>
406
+ Cancel
407
+ </Button>
408
+ <Button type="button" size="sm" variant="secondary" onClick={() => void save()} loading={busy} className="h-7">
409
+ Save
410
+ </Button>
411
+ </div>
412
+ </div>
413
+ );
414
+ }
415
+
416
+ type ItemComponent = ComponentType<{ onSelect?: (e: Event) => void; className?: string; children?: ReactNode; "data-testid"?: string; "aria-label"?: string }>;
417
+
418
+ interface ActionItemsProps {
419
+ message: ChatMessage;
420
+ /** Set when the picked action moves focus itself (Reply, Edit), so the menu doesn't take it back. */
421
+ keepFocus: MutableRefObject<(() => void) | null>;
422
+ mine: boolean;
423
+ Item: ItemComponent;
424
+ Separator: ComponentType<{ className?: string }>;
425
+ }
426
+
427
+ /** The actions menu, the same in the right-click menu and the "More" menu. */
428
+ function ActionItems({ message, mine, keepFocus, Item, Separator }: ActionItemsProps) {
429
+ const ctx = useThreadContext();
430
+ const pinned = ctx.pins.has(message.id);
431
+ const canDelete = mine || ctx.canModerate;
432
+ const canEdit = mine && message.kind === "text" && message.body.length > 0;
433
+ return (
434
+ <>
435
+ {ctx.canPost ? (
436
+ <>
437
+ <div className="flex justify-between gap-0.5 px-1 py-1" role="group" aria-label="React">
438
+ {ctx.quickReactions.map((emoji) => {
439
+ const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
440
+ return (
441
+ <Item
442
+ key={emoji}
443
+ className={cn("justify-center rounded-full px-2 text-lg", on && "bg-primary/10")}
444
+ onSelect={() => ctx.react(message, emoji, !on)}
445
+ aria-label={`${on ? "Remove" : "React with"} ${emoji}`}
446
+ >
447
+ {emoji}
448
+ </Item>
449
+ );
450
+ })}
451
+ </div>
452
+ <Separator />
453
+ <Item className="gap-2" onSelect={() => { keepFocus.current = ctx.focusComposer; ctx.reply(message); }}>
454
+ <Reply className="size-4" aria-hidden /> Reply
455
+ </Item>
456
+ </>
457
+ ) : null}
458
+ {message.body ? (
459
+ <Item className="gap-2" onSelect={() => ctx.copy(message)}>
460
+ <Copy className="size-4" aria-hidden /> Copy text
461
+ </Item>
462
+ ) : null}
463
+ {canEdit && ctx.canPost ? (
464
+ <Item className="gap-2" onSelect={() => { keepFocus.current = () => document.getElementById(`genesis-chat-edit-${message.id}`)?.focus(); ctx.startEdit(message.id); }}>
465
+ <Pencil className="size-4" aria-hidden /> Edit
466
+ </Item>
467
+ ) : null}
468
+ {ctx.canPin ? (
469
+ <Item className="gap-2" onSelect={() => ctx.pin(message, !pinned)}>
470
+ {pinned ? <PinOff className="size-4" aria-hidden /> : <Pin className="size-4" aria-hidden />} {pinned ? "Unpin" : "Pin"}
471
+ </Item>
472
+ ) : null}
473
+ <Item className="gap-2" onSelect={() => ctx.save(message)}>
474
+ <Bookmark className="size-4" aria-hidden /> Save
475
+ </Item>
476
+ {!mine && message.senderId ? (
477
+ <Item className="gap-2" onSelect={() => ctx.askReport(message)}>
478
+ <Flag className="size-4" aria-hidden /> Report
479
+ </Item>
480
+ ) : null}
481
+ {canDelete ? (
482
+ <>
483
+ <Separator />
484
+ <Item onSelect={() => ctx.askDelete(message)} className="gap-2 text-destructive focus:text-destructive">
485
+ <Trash2 className="size-4" aria-hidden /> Delete
486
+ </Item>
487
+ </>
488
+ ) : null}
489
+ </>
490
+ );
491
+ }
492
+
493
+ /** Desktop: a small bar beside the bubble on hover or focus (React, Reply, More). Touch uses long-press. */
494
+ function HoverBar({ message, mine }: { message: ChatMessage; mine: boolean }) {
495
+ const ctx = useThreadContext();
496
+ const keepFocus = useRef<(() => void) | null>(null);
497
+ const [open, setOpen] = useState<"react" | "more" | null>(null);
498
+ const [full, setFull] = useState(false);
499
+ return (
500
+ <div
501
+ className={cn(
502
+ "flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity focus-within:opacity-100 group-hover/msg:opacity-100 [@media(hover:none)]:hidden",
503
+ open && "opacity-100",
504
+ mine ? "flex-row-reverse" : "flex-row",
505
+ )}
506
+ >
507
+ {ctx.canPost ? (
508
+ <Popover
509
+ open={open === "react"}
510
+ onOpenChange={(o) => {
511
+ setOpen(o ? "react" : null);
512
+ if (!o) setFull(false);
513
+ }}
514
+ >
515
+ <PopoverTrigger asChild>
516
+ <Button type="button" variant="ghost" size="icon" className="size-7 rounded-full text-muted-foreground" aria-label="React">
517
+ <SmilePlus />
518
+ </Button>
519
+ </PopoverTrigger>
520
+ {full ? (
521
+ <PopoverContent side="top" align={mine ? "end" : "start"} className="w-auto overflow-hidden p-0">
522
+ <ChatEmojiPicker
523
+ emojibaseUrl={ctx.emojibaseUrl}
524
+ onSelect={(emoji) => {
525
+ const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
526
+ ctx.react(message, emoji, !on);
527
+ setOpen(null);
528
+ setFull(false);
529
+ }}
530
+ />
531
+ </PopoverContent>
532
+ ) : (
533
+ <PopoverContent side="top" align={mine ? "end" : "start"} className="flex w-auto gap-0.5 rounded-full p-1">
534
+ {ctx.quickReactions.map((emoji) => {
535
+ const on = message.reactions.some((r) => r.emoji === emoji && r.userId === ctx.me);
536
+ return (
537
+ <button
538
+ key={emoji}
539
+ type="button"
540
+ 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")}
541
+ aria-label={`${on ? "Remove" : "React with"} ${emoji}`}
542
+ aria-pressed={on}
543
+ onClick={() => {
544
+ ctx.react(message, emoji, !on);
545
+ setOpen(null);
546
+ }}
547
+ >
548
+ {emoji}
549
+ </button>
550
+ );
551
+ })}
552
+ {canPickEmoji(ctx.emojibaseUrl) ? (
553
+ <button
554
+ type="button"
555
+ 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"
556
+ aria-label="More reactions"
557
+ onClick={() => setFull(true)}
558
+ data-testid="genesis-chat-more-reactions"
559
+ >
560
+ <Plus className="size-4" />
561
+ </button>
562
+ ) : null}
563
+ </PopoverContent>
564
+ )}
565
+ </Popover>
566
+ ) : null}
567
+ {ctx.canPost ? (
568
+ <Button type="button" variant="ghost" size="icon" className="size-7 rounded-full text-muted-foreground" aria-label="Reply" onClick={() => ctx.reply(message)}>
569
+ <Reply />
570
+ </Button>
571
+ ) : null}
572
+ <DropdownMenu open={open === "more"} onOpenChange={(o) => setOpen(o ? "more" : null)}>
573
+ <DropdownMenuTrigger asChild>
574
+ <Button type="button" variant="ghost" size="icon" className="size-7 rounded-full text-muted-foreground" aria-label="More actions" data-testid="genesis-chat-more">
575
+ <MoreHorizontal />
576
+ </Button>
577
+ </DropdownMenuTrigger>
578
+ <DropdownMenuContent align={mine ? "end" : "start"} className="w-56" onCloseAutoFocus={(e) => returnFocus(e, keepFocus)}>
579
+ <ActionItems message={message} mine={mine} keepFocus={keepFocus} Item={DropdownMenuItem} Separator={DropdownMenuSeparator} />
580
+ </DropdownMenuContent>
581
+ </DropdownMenu>
582
+ </div>
583
+ );
584
+ }
585
+