@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.
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,158 @@
1
+ // @feat:chat
2
+ /**
3
+ * A fetch handler that gives a composer a direct-upload URL for a video, after
4
+ * the app confirms the person may post in that conversation.
5
+ *
6
+ * POST { conversationId } → { url, providerId, provider: "mux" }
7
+ * OPTIONS → 204 with CORS headers, for an allowed origin
8
+ *
9
+ * The client PUTs the file to `url` and sends its message with an attachment
10
+ * `{ kind: "video", provider: "mux", provider_id: providerId, status: "pending" }`.
11
+ *
12
+ * The caller chooses nothing the webhook trusts. The server sets the upload's
13
+ * passthrough to the conversation (and the uploader, when `authorize` names
14
+ * them); the webhook finds the attachment only by the upload id the client sent
15
+ * with its message, and applies an event only when the passthrough names that
16
+ * attachment's conversation (and its sender). So nobody can aim their upload at
17
+ * another conversation's video.
18
+ */
19
+ import type { VideoProvider } from "./types";
20
+
21
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
22
+ const PASSTHROUGH_PREFIX = "gc1";
23
+
24
+ /** Who an upload is for: written by the server into the upload's passthrough, never by the client. */
25
+ export interface ChatUploadBinding {
26
+ conversationId: string;
27
+ /** The uploader, when the app's `authorize` named them. */
28
+ userId?: string | undefined;
29
+ }
30
+
31
+ /** `gc1:<conversation id>:<user id or empty>`: the passthrough the upload handler sets. */
32
+ export function chatUploadPassthrough(binding: ChatUploadBinding): string {
33
+ return `${PASSTHROUGH_PREFIX}:${binding.conversationId}:${binding.userId ?? ""}`;
34
+ }
35
+
36
+ /** Reads a passthrough the upload handler set, or null for anything else. */
37
+ export function parseChatUploadPassthrough(value: string | null | undefined): ChatUploadBinding | null {
38
+ if (!value) return null;
39
+ const [prefix, conversationId, userId, ...rest] = value.split(":");
40
+ if (prefix !== PASSTHROUGH_PREFIX || rest.length > 0 || !conversationId || !UUID.test(conversationId)) return null;
41
+ if (userId && !UUID.test(userId)) return null;
42
+ return { conversationId: conversationId.toLowerCase(), ...(userId ? { userId: userId.toLowerCase() } : {}) };
43
+ }
44
+
45
+ export interface VideoUploadHandlerOptions {
46
+ /** `muxVideo(...)`. */
47
+ video: Pick<VideoProvider, "name" | "createUpload">;
48
+ /**
49
+ * Must confirm the request's user may post in `conversationId` (verify their
50
+ * session, then ask the database as them, e.g. a select on
51
+ * `genesis_chat.members` under row level security). Resolve false to refuse.
52
+ * Resolve `{ userId }` to also bind the upload to its uploader: the webhook
53
+ * then applies it only to a message they sent.
54
+ */
55
+ authorize: (
56
+ request: Request,
57
+ context: { conversationId: string },
58
+ ) => Promise<boolean | { userId: string }> | boolean | { userId: string };
59
+ /**
60
+ * Required. The web origin(s) allowed to upload: one origin, or a list that
61
+ * the request's `origin` header must be in. Native apps send no origin; they
62
+ * get the first. `"*"` allows any origin (only for an app with no web client).
63
+ */
64
+ corsOrigin: string | readonly string[];
65
+ /**
66
+ * A per-user cap on minting uploads (each one can cost money). Resolve false
67
+ * to answer 429. Called after `authorize`, with the user it named.
68
+ */
69
+ rateLimit?:
70
+ | ((request: Request, context: { conversationId: string; userId: string | undefined }) => Promise<boolean> | boolean)
71
+ | undefined;
72
+ }
73
+
74
+ export function createVideoUploadHandler(options: VideoUploadHandlerOptions): (request: Request) => Promise<Response> {
75
+ const configured = options.corsOrigin;
76
+ if (!configured || (typeof configured !== "string" && configured.length === 0)) {
77
+ throw new Error("createVideoUploadHandler: `corsOrigin` is required (an origin, a list of origins, or \"*\")");
78
+ }
79
+
80
+ /** The origin to allow for this request, or null when its origin isn't allowed. */
81
+ function allowedOrigin(request: Request): string | null {
82
+ const origin = request.headers.get("origin");
83
+ if (typeof configured === "string") {
84
+ if (configured === "*") return origin ?? "*";
85
+ return !origin || origin === configured ? configured : null;
86
+ }
87
+ if (!origin) return configured[0]!;
88
+ return configured.includes(origin) ? origin : null;
89
+ }
90
+
91
+ const respond = (status: number, body: unknown, origin: string | null) =>
92
+ new Response(body === null ? null : JSON.stringify(body), {
93
+ status,
94
+ headers: {
95
+ ...(body === null ? {} : { "content-type": "application/json" }),
96
+ "cache-control": "no-store",
97
+ vary: "origin",
98
+ ...(origin ? { "access-control-allow-origin": origin } : {}),
99
+ },
100
+ });
101
+
102
+ return async (request) => {
103
+ const origin = allowedOrigin(request);
104
+ if (request.method === "OPTIONS") {
105
+ if (!origin) return respond(403, { error: "origin not allowed" }, null);
106
+ const preflight = respond(204, null, origin);
107
+ preflight.headers.set("access-control-allow-methods", "POST, OPTIONS");
108
+ preflight.headers.set("access-control-allow-headers", request.headers.get("access-control-request-headers") ?? "authorization, content-type");
109
+ preflight.headers.set("access-control-max-age", "600");
110
+ return preflight;
111
+ }
112
+ if (request.method !== "POST") return respond(405, { error: "method not allowed" }, origin);
113
+ if (!origin) return respond(403, { error: "origin not allowed" }, null);
114
+
115
+ let body: { conversationId?: unknown };
116
+ try {
117
+ body = (await request.json()) as typeof body;
118
+ } catch {
119
+ return respond(400, { error: "bad json" }, origin);
120
+ }
121
+ const conversationId = typeof body.conversationId === "string" ? body.conversationId.toLowerCase() : "";
122
+ if (!UUID.test(conversationId)) return respond(400, { error: "conversationId is required" }, origin);
123
+
124
+ let userId: string | undefined;
125
+ let allowed = false;
126
+ try {
127
+ const verdict = await options.authorize(request, { conversationId });
128
+ if (verdict === true) allowed = true;
129
+ else if (verdict && typeof verdict === "object" && typeof verdict.userId === "string" && UUID.test(verdict.userId)) {
130
+ allowed = true;
131
+ userId = verdict.userId.toLowerCase();
132
+ }
133
+ } catch {
134
+ allowed = false;
135
+ }
136
+ if (!allowed) return respond(403, { error: "not allowed to post in this conversation" }, origin);
137
+
138
+ if (options.rateLimit) {
139
+ let underCap = false;
140
+ try {
141
+ underCap = (await options.rateLimit(request, { conversationId, userId })) === true;
142
+ } catch {
143
+ underCap = false;
144
+ }
145
+ if (!underCap) return respond(429, { error: "too many uploads; try again later" }, origin);
146
+ }
147
+
148
+ try {
149
+ const upload = await options.video.createUpload({
150
+ corsOrigin: origin,
151
+ passthrough: chatUploadPassthrough({ conversationId, userId }),
152
+ });
153
+ return respond(200, { url: upload.url, providerId: upload.uploadId, provider: options.video.name }, origin);
154
+ } catch (e) {
155
+ return respond(502, { error: e instanceof Error ? e.message.slice(0, 200) : "upload failed" }, origin);
156
+ }
157
+ };
158
+ }
@@ -0,0 +1,379 @@
1
+ /**
2
+ * @feat:chat
3
+ * What a message carries: photos in a grid of up to four with a lightbox,
4
+ * voice notes, videos, files as a chip, links, and the app's own kinds (a
5
+ * form, a lesson) through the `attachment` slot, a generic card by default.
6
+ * Pending uploads draw from their local previews with a spinner.
7
+ */
8
+ import { useCallback, useState, type ComponentType, type KeyboardEvent } from "react";
9
+ import { ChevronLeft, ChevronRight, Download, FileText, Link2, Paperclip } from "lucide-react";
10
+ import {
11
+ Button,
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogTitle,
16
+ Spinner,
17
+ VisuallyHidden,
18
+ cn,
19
+ } from "@marktiderman/genesis-ui";
20
+ import type { ChatAttachment, ChatMessage } from "../core/types";
21
+ import type { ChatAttachmentKind } from "../react/index";
22
+ import { ChatAttachmentCard, type ChatAttachmentCardProps } from "./cards";
23
+ import { ChatErrorBoundary } from "./error-boundary";
24
+ import { formatBytes } from "./format";
25
+ import { isSafeHref } from "./markdown";
26
+ import { ChatVideoPlayer } from "./video";
27
+ import { VoiceNote } from "./voice-note";
28
+
29
+ /** One attachment as the renderers draw it, sent or still uploading. */
30
+ export interface AttachmentView {
31
+ key: string;
32
+ kind: string;
33
+ /** Signed URL, or the local preview while uploading. */
34
+ url: string | null;
35
+ posterUrl: string | null;
36
+ name: string | null;
37
+ mime: string | null;
38
+ bytes: number | null;
39
+ width: number | null;
40
+ height: number | null;
41
+ durationMs: number | null;
42
+ waveform: number[] | null;
43
+ transcript: string | null;
44
+ transcriptStatus: ChatAttachment["transcriptStatus"];
45
+ /** Still uploading (or the server says it is still processing). */
46
+ pending: boolean;
47
+ /** The stored attachment; absent while it uploads. */
48
+ attachment?: ChatAttachment;
49
+ }
50
+
51
+ /** Props the `attachment` slot receives, for kinds the package doesn't draw (form, lesson…). */
52
+ export interface ChatAttachmentSlotProps {
53
+ attachment: ChatAttachment;
54
+ message: ChatMessage;
55
+ /** Its signed URL when it has a file. */
56
+ url: string | null;
57
+ mine: boolean;
58
+ }
59
+
60
+ export const BUILT_IN_KINDS = new Set(["image", "voice", "video", "file", "link"]);
61
+
62
+ export function toAttachmentView(a: ChatAttachment, urls: Record<string, string>): AttachmentView {
63
+ return {
64
+ key: a.id,
65
+ kind: a.kind,
66
+ url: (a.path ? urls[a.path] : null) ?? (a.provider === "external" ? a.url : null) ?? (a.kind === "link" ? a.url : null),
67
+ posterUrl: a.posterPath ? (urls[a.posterPath] ?? null) : null,
68
+ name: a.name,
69
+ mime: a.mime,
70
+ bytes: a.bytes,
71
+ width: a.width,
72
+ height: a.height,
73
+ durationMs: a.durationMs,
74
+ waveform: a.waveform,
75
+ transcript: a.transcript,
76
+ transcriptStatus: a.transcriptStatus,
77
+ pending: a.status === "pending",
78
+ attachment: a,
79
+ };
80
+ }
81
+
82
+ /** Generic card for an app's own kind, when no `attachment` slot is given. */
83
+ export function GenericAttachment({ attachment, url }: ChatAttachmentSlotProps) {
84
+ const label = attachment.name ?? attachment.kind.charAt(0).toUpperCase() + attachment.kind.slice(1);
85
+ const body = (
86
+ <>
87
+ <Paperclip className="size-4 shrink-0 text-muted-foreground" aria-hidden />
88
+ <span className="min-w-0 flex-1">
89
+ <span className="block truncate text-sm font-medium">{label}</span>
90
+ <span className="block text-xs capitalize text-muted-foreground">{attachment.kind}</span>
91
+ </span>
92
+ </>
93
+ );
94
+ return url && isSafeHref(url) ? (
95
+ <a href={url} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 rounded-lg border bg-background p-2 text-foreground hover:bg-accent">
96
+ {body}
97
+ </a>
98
+ ) : (
99
+ <div className="flex items-center gap-2 rounded-lg border bg-background p-2 text-foreground">{body}</div>
100
+ );
101
+ }
102
+
103
+ function Uploading({ className }: { className?: string }) {
104
+ return (
105
+ <span className={cn("absolute inset-0 flex items-center justify-center bg-background/40", className)} aria-label="Uploading" role="status">
106
+ <Spinner size="sm" className="text-foreground" />
107
+ </span>
108
+ );
109
+ }
110
+
111
+ export interface ImageGridProps {
112
+ images: AttachmentView[];
113
+ className?: string;
114
+ }
115
+
116
+ /** Up to four photos in a grid (a "+N" on the fourth for more), opening a lightbox. */
117
+ export function ImageGrid({ images, className }: ImageGridProps) {
118
+ const [open, setOpen] = useState<number | null>(null);
119
+ const shown = images.slice(0, 4);
120
+ const more = images.length - shown.length;
121
+ const cols = shown.length === 1 ? "grid-cols-1" : "grid-cols-2";
122
+ return (
123
+ <>
124
+ <div className={cn("grid w-[min(18rem,100%)] gap-1 overflow-hidden rounded-xl", cols, className)} data-testid="genesis-chat-images">
125
+ {shown.map((img, i) => {
126
+ const tall = shown.length === 3 && i === 0;
127
+ return (
128
+ <button
129
+ key={img.key}
130
+ type="button"
131
+ className={cn(
132
+ "relative block overflow-hidden bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
133
+ shown.length === 1 ? "max-h-80" : "aspect-square",
134
+ tall && "row-span-2 aspect-auto",
135
+ )}
136
+ onClick={() => setOpen(i)}
137
+ aria-label={`Open photo${img.name ? ` ${img.name}` : ""}`}
138
+ >
139
+ {img.url ? (
140
+ <img
141
+ src={img.url}
142
+ alt={img.name ?? "Photo"}
143
+ loading="lazy"
144
+ className={cn("h-full w-full object-cover", img.pending && "opacity-70")}
145
+ width={img.width ?? undefined}
146
+ height={img.height ?? undefined}
147
+ />
148
+ ) : (
149
+ <span className="block h-full min-h-24 w-full animate-pulse bg-muted" />
150
+ )}
151
+ {img.pending ? <Uploading /> : null}
152
+ {more > 0 && i === shown.length - 1 ? (
153
+ <span className="absolute inset-0 flex items-center justify-center bg-foreground/50 text-xl font-semibold text-background">+{more}</span>
154
+ ) : null}
155
+ </button>
156
+ );
157
+ })}
158
+ </div>
159
+ <Lightbox images={images} index={open} onIndexChange={setOpen} />
160
+ </>
161
+ );
162
+ }
163
+
164
+ export interface LightboxProps {
165
+ images: AttachmentView[];
166
+ /** The photo shown; null closes it. */
167
+ index: number | null;
168
+ onIndexChange: (index: number | null) => void;
169
+ }
170
+
171
+ /** A photo full size, with the previous and next ones on the arrow keys. */
172
+ export function Lightbox({ images, index, onIndexChange }: LightboxProps) {
173
+ const img = index === null ? null : images[index];
174
+ const go = useCallback(
175
+ (delta: number) => {
176
+ if (index === null || images.length < 2) return;
177
+ onIndexChange((index + delta + images.length) % images.length);
178
+ },
179
+ [images.length, index, onIndexChange],
180
+ );
181
+ const onKey = (e: KeyboardEvent) => {
182
+ if (e.key === "ArrowRight") {
183
+ e.preventDefault();
184
+ go(1);
185
+ } else if (e.key === "ArrowLeft") {
186
+ e.preventDefault();
187
+ go(-1);
188
+ }
189
+ };
190
+ return (
191
+ <Dialog open={img != null} onOpenChange={(o) => !o && onIndexChange(null)}>
192
+ <DialogContent className="max-w-[min(64rem,95vw)] border-0 bg-background p-2" onKeyDown={onKey} data-testid="genesis-chat-lightbox">
193
+ <VisuallyHidden>
194
+ <DialogTitle>{img?.name ?? "Photo"}</DialogTitle>
195
+ <DialogDescription>
196
+ Photo {index === null ? 0 : index + 1} of {images.length}. Use the arrow keys for the others.
197
+ </DialogDescription>
198
+ </VisuallyHidden>
199
+ {img?.url ? <img src={img.url} alt={img.name ?? "Photo"} className="mx-auto max-h-[80vh] w-auto rounded-md object-contain" /> : null}
200
+ {images.length > 1 ? (
201
+ <div className="flex items-center justify-between gap-2">
202
+ <Button type="button" variant="ghost" size="icon" onClick={() => go(-1)} aria-label="Previous photo">
203
+ <ChevronLeft />
204
+ </Button>
205
+ <span className="text-sm tabular-nums text-muted-foreground">
206
+ {(index ?? 0) + 1} / {images.length}
207
+ </span>
208
+ <Button type="button" variant="ghost" size="icon" onClick={() => go(1)} aria-label="Next photo">
209
+ <ChevronRight />
210
+ </Button>
211
+ </div>
212
+ ) : null}
213
+ </DialogContent>
214
+ </Dialog>
215
+ );
216
+ }
217
+
218
+ /** A video: a hosted one (Mux) streams once ready and says "Processing…" until then; a file plays from storage. */
219
+ export function VideoAttachment({ view }: { view: AttachmentView }) {
220
+ const a = view.attachment;
221
+ const hosted = a != null && a.provider !== "storage" && a.provider !== "external" && a.provider !== "none";
222
+ return (
223
+ <ChatVideoPlayer
224
+ src={hosted ? null : view.url}
225
+ playbackId={hosted ? a.playbackId : null}
226
+ poster={view.posterUrl}
227
+ processing={hosted && a.status === "pending"}
228
+ failed={a?.status === "failed"}
229
+ uploading={!a && view.pending}
230
+ label={view.name ?? "Video"}
231
+ />
232
+ );
233
+ }
234
+
235
+ export function FileChip({ view, tone = "default" }: { view: AttachmentView; tone?: "default" | "primary" }) {
236
+ const primary = tone === "primary";
237
+ const ext = (view.name?.split(".").pop() ?? "").toUpperCase();
238
+ return (
239
+ <div
240
+ className={cn("flex w-[min(18rem,100%)] items-center gap-2 rounded-lg p-2", primary ? "bg-primary-foreground/15" : "border bg-background")}
241
+ data-testid="genesis-chat-file"
242
+ >
243
+ <span className={cn("relative flex size-9 shrink-0 items-center justify-center rounded-md", primary ? "bg-primary-foreground/20" : "bg-muted")}>
244
+ {view.pending ? <Spinner size="sm" className={primary ? "text-primary-foreground" : undefined} /> : <FileText className="size-4" aria-hidden />}
245
+ </span>
246
+ <span className="min-w-0 flex-1">
247
+ <span className="block truncate text-sm font-medium">{view.name ?? "File"}</span>
248
+ <span className={cn("block text-xs", primary ? "text-primary-foreground/80" : "text-muted-foreground")}>
249
+ {[ext, formatBytes(view.bytes)].filter(Boolean).join(" · ")}
250
+ {view.pending ? " · Uploading…" : ""}
251
+ </span>
252
+ </span>
253
+ {view.url && !view.pending ? (
254
+ <Button
255
+ asChild
256
+ size="icon"
257
+ variant="ghost"
258
+ className={cn("size-8 shrink-0", primary && "text-primary-foreground hover:bg-primary-foreground/15 hover:text-primary-foreground")}
259
+ >
260
+ <a href={view.url} download={view.name ?? true} target="_blank" rel="noopener noreferrer" aria-label={`Download ${view.name ?? "file"}`}>
261
+ <Download />
262
+ </a>
263
+ </Button>
264
+ ) : null}
265
+ </div>
266
+ );
267
+ }
268
+
269
+ function LinkAttachment({ view, tone }: { view: AttachmentView; tone: "default" | "primary" }) {
270
+ if (!view.url || !isSafeHref(view.url)) return null;
271
+ let host = view.url;
272
+ try {
273
+ host = new URL(view.url).host;
274
+ } catch {
275
+ // shown as written
276
+ }
277
+ return (
278
+ <a
279
+ href={view.url}
280
+ target="_blank"
281
+ rel="noopener noreferrer"
282
+ className={cn("flex w-[min(18rem,100%)] items-center gap-2 rounded-lg p-2", tone === "primary" ? "bg-primary-foreground/15" : "border bg-background hover:bg-accent")}
283
+ >
284
+ <Link2 className="size-4 shrink-0" aria-hidden />
285
+ <span className="min-w-0">
286
+ <span className="block truncate text-sm font-medium">{view.name ?? host}</span>
287
+ <span className="block truncate text-xs opacity-80">{host}</span>
288
+ </span>
289
+ </a>
290
+ );
291
+ }
292
+
293
+ /** The app's kind for this name: its own key only (never `constructor` from the prototype), with a `title` function. */
294
+ export function attachmentKindOf(kinds: Record<string, ChatAttachmentKind> | undefined, kind: string): ChatAttachmentKind | null {
295
+ if (!kinds || !Object.prototype.hasOwnProperty.call(kinds, kind)) return null;
296
+ const k = kinds[kind];
297
+ return k && typeof k.title === "function" ? k : null;
298
+ }
299
+
300
+ /**
301
+ * What the app's kind says to draw for this attachment, or null when its functions throw or
302
+ * return the wrong shapes (the attachment's `ref` is whatever the sender put there).
303
+ */
304
+ export function attachmentCardOf(k: ChatAttachmentKind, a: ChatAttachment): Pick<ChatAttachmentCardProps, "title" | "subtitle" | "action"> | null {
305
+ try {
306
+ const title = k.title(a);
307
+ if (typeof title !== "string") return null;
308
+ const sub = typeof k.subtitle === "function" ? k.subtitle(a) : null;
309
+ const act = typeof k.action === "function" ? k.action(a) : null;
310
+ const action =
311
+ act && typeof act === "object" && typeof act.label === "string" && (typeof act.href === "string" || typeof act.onPress === "function")
312
+ ? { label: act.label, href: typeof act.href === "string" ? act.href : undefined, onPress: typeof act.onPress === "function" ? act.onPress : undefined }
313
+ : null;
314
+ return { title, subtitle: typeof sub === "string" ? sub : null, action };
315
+ } catch (e) {
316
+ console.error(`genesis-chat: the "${a.kind}" card couldn't be drawn`, e);
317
+ return null;
318
+ }
319
+ }
320
+
321
+ export interface MessageAttachmentsProps {
322
+ views: AttachmentView[];
323
+ message?: ChatMessage;
324
+ mine: boolean;
325
+ tone: "default" | "primary";
326
+ /** For kinds the package doesn't draw; a generic card by default. */
327
+ slot?: ComponentType<ChatAttachmentSlotProps> | null;
328
+ /** Cards for registered app kinds (ChatProvider `attachmentKinds`), drawn before the slot. */
329
+ kinds?: Record<string, ChatAttachmentKind>;
330
+ }
331
+
332
+ /** Every attachment of one message, photos grouped into one grid. */
333
+ export function MessageAttachments({ views, message, mine, tone, slot, kinds }: MessageAttachmentsProps) {
334
+ if (views.length === 0) return null;
335
+ const images = views.filter((v) => v.kind === "image");
336
+ const others = views.filter((v) => v.kind !== "image");
337
+ const Custom = slot === undefined ? GenericAttachment : slot;
338
+ return (
339
+ <div className="flex flex-col gap-1.5">
340
+ {images.length ? <ImageGrid images={images} /> : null}
341
+ {others.map((v) => {
342
+ switch (v.kind) {
343
+ case "voice":
344
+ return (
345
+ <VoiceNote
346
+ key={v.key}
347
+ src={v.url}
348
+ durationMs={v.durationMs}
349
+ waveform={v.waveform}
350
+ transcript={v.transcript}
351
+ transcriptStatus={v.transcriptStatus}
352
+ tone={tone}
353
+ />
354
+ );
355
+ case "video":
356
+ return <VideoAttachment key={v.key} view={v} />;
357
+ case "file":
358
+ return <FileChip key={v.key} view={v} tone={tone} />;
359
+ case "link":
360
+ return <LinkAttachment key={v.key} view={v} tone={tone} />;
361
+ default:
362
+ if (!v.attachment || !message) return <FileChip key={v.key} view={v} tone={tone} />;
363
+ const generic = <GenericAttachment attachment={v.attachment} message={message} url={v.url} mine={mine} />;
364
+ const k = attachmentKindOf(kinds, v.kind);
365
+ if (k) {
366
+ const card = attachmentCardOf(k, v.attachment);
367
+ return card ? <ChatAttachmentCard key={v.key} {...card} tone={tone} /> : <GenericAttachment key={v.key} attachment={v.attachment} message={message} url={v.url} mine={mine} />;
368
+ }
369
+ // The app's slot draws sender-controlled data: if it throws, the generic card stands in.
370
+ return Custom ? (
371
+ <ChatErrorBoundary key={v.key} fallback={generic}>
372
+ <Custom attachment={v.attachment} message={message} url={v.url} mine={mine} />
373
+ </ChatErrorBoundary>
374
+ ) : null;
375
+ }
376
+ })}
377
+ </div>
378
+ );
379
+ }