@marktiderman/genesis-chat 0.2.2 → 0.3.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 (37) hide show
  1. package/README.md +119 -6
  2. package/dist/{chunk-VEPZROQB.js → chunk-BTG4JWLF.js} +5 -4
  3. package/dist/chunk-BTG4JWLF.js.map +1 -0
  4. package/dist/{chunk-AZS3HNLM.js → chunk-IEFULAC2.js} +25 -3
  5. package/dist/chunk-IEFULAC2.js.map +1 -0
  6. package/dist/{chunk-JU2FAPBI.js → chunk-XQ2ULNHV.js} +2 -2
  7. package/dist/{chunk-JU2FAPBI.js.map → chunk-XQ2ULNHV.js.map} +1 -1
  8. package/dist/native.d.ts +158 -6
  9. package/dist/native.js +404 -181
  10. package/dist/native.js.map +1 -1
  11. package/dist/push.d.ts +2 -1
  12. package/dist/push.js +1 -1
  13. package/dist/react.d.ts +6 -2
  14. package/dist/react.js +1 -1
  15. package/dist/server.js +1 -1
  16. package/dist/web.d.ts +6 -2
  17. package/dist/web.js +6 -18
  18. package/dist/web.js.map +1 -1
  19. package/package.json +9 -9
  20. package/src/native/__tests__/attachment-slot.test.tsx +353 -0
  21. package/src/native/__tests__/organise-screens.test.tsx +12 -0
  22. package/src/native/__tests__/push-ids.test.ts +18 -0
  23. package/src/native/__tests__/push.test.ts +10 -4
  24. package/src/native/attachment-cards.tsx +249 -11
  25. package/src/native/copy.ts +15 -0
  26. package/src/native/index.ts +28 -4
  27. package/src/native/media.tsx +89 -2
  28. package/src/native/message-bubble.tsx +78 -12
  29. package/src/native/push.ts +5 -2
  30. package/src/native/search-screen.tsx +77 -69
  31. package/src/native/thread-screen.tsx +23 -6
  32. package/src/react/error-boundary.tsx +46 -0
  33. package/src/react/index.tsx +6 -2
  34. package/src/web/cards.tsx +2 -2
  35. package/src/web/error-boundary.tsx +2 -30
  36. package/dist/chunk-AZS3HNLM.js.map +0 -1
  37. package/dist/chunk-VEPZROQB.js.map +0 -1
@@ -8,9 +8,16 @@
8
8
  *
9
9
  * The kind's `title`, `subtitle` and `action` get a second argument, `{ mine }` (`ChatCardContext`):
10
10
  * true on the sender's own card, so a kind can drop its button there (you don't answer your own
11
- * check-in). Previews (the inbox, the long-press sheet) name a card by its kind's `label`, else
12
- * its title, not "Attachment".
11
+ * check-in). Previews (the inbox, the long-press sheet, a reply's quote) name a card by its kind's
12
+ * `label`, else its title, not "Attachment".
13
+ *
14
+ * For more than a card (a task with a checkbox, a worksheet with its progress) the app draws its
15
+ * kinds itself through the `attachment` slot (`ChatThreadScreen slots`, `MessageBubble
16
+ * attachmentSlot`), the native twin of the web slot: the same props (`attachment`, `message`,
17
+ * `url`, `mine`) plus the kind, its action, `run` for the app's own actions with `pending` and
18
+ * `error`, and `fallback` (the card it would have been). A slot that throws shows the fallback.
13
19
  */
20
+ import { useCallback, useLayoutEffect, useRef, useState, type ComponentType, type ReactElement, type ReactNode } from "react";
14
21
  import { Linking, View } from "react-native";
15
22
  import { NativeButton, NativeIcon, NativeText } from "@marktiderman/genesis-ui-native/components";
16
23
  import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
@@ -20,18 +27,31 @@ import { attachmentPreview, callAttachmentKind as call, cardTitle, messagePrevie
20
27
 
21
28
  export type { ChatCardContext };
22
29
  export { attachmentPreview, cardTitle, messagePreview };
30
+ import { ChatErrorBoundary } from "../react/error-boundary";
23
31
  import { fill } from "./copy";
24
32
  import { useChatNative } from "./context";
25
33
  import { ON_TINT_FILL, ON_TINT_FILL_STRONG } from "./media-colors";
26
34
 
27
35
  export type OpenAttachment = (attachment: ChatAttachment, message: ChatMessage) => void;
28
36
 
29
- /** A message's attachments split into the app's cards and the rest (photos, voice, video, files). */
30
- export function splitCardAttachments<T extends { kind: string }>(attachments: readonly T[], kinds: Record<string, ChatAttachmentKind> | undefined): { cards: T[]; rest: T[] } {
31
- if (!kinds) return { cards: [], rest: [...attachments] };
37
+ /** The kinds the package draws itself; every other kind is the app's. */
38
+ export const BUILT_IN_KINDS: ReadonlySet<string> = new Set(["image", "voice", "video", "file", "link"]);
39
+
40
+ /** The app's registered kind for this attachment kind (own keys only, never `constructor` and the like). */
41
+ export function attachmentKindOf(kinds: Record<string, ChatAttachmentKind> | undefined, kind: string): ChatAttachmentKind | undefined {
42
+ return kinds && Object.prototype.hasOwnProperty.call(kinds, kind) ? kinds[kind] : undefined;
43
+ }
44
+
45
+ /**
46
+ * A message's attachments split into the app's cards and the rest (photos, voice, video, files).
47
+ * A card is a registered kind; with `slot` (the app draws its kinds) also any kind the package
48
+ * doesn't draw itself, as on the web.
49
+ */
50
+ export function splitCardAttachments<T extends { kind: string }>(attachments: readonly T[], kinds: Record<string, ChatAttachmentKind> | undefined, slot = false): { cards: T[]; rest: T[] } {
51
+ if (!kinds && !slot) return { cards: [], rest: [...attachments] };
32
52
  const cards: T[] = [];
33
53
  const rest: T[] = [];
34
- for (const a of attachments) (Object.prototype.hasOwnProperty.call(kinds, a.kind) ? cards : rest).push(a);
54
+ for (const a of attachments) (attachmentKindOf(kinds, a.kind) || (slot && !BUILT_IN_KINDS.has(a.kind)) ? cards : rest).push(a);
35
55
  return { cards, rest };
36
56
  }
37
57
 
@@ -71,16 +91,143 @@ export function cardAction(
71
91
  return run ? { label: action.label, run } : null;
72
92
  }
73
93
 
94
+ /** Where one card's app action stands: running, or failed with a short message. */
95
+ export interface CardRunState {
96
+ pending: boolean;
97
+ error: string | null;
98
+ }
99
+
100
+ const IDLE: CardRunState = { pending: false, error: null };
101
+
102
+ /** How long a card's action may run before it reads as failed (`useCardRuns`, `cardActionTimeoutMs`). */
103
+ export const CARD_ACTION_TIMEOUT_MS = 30_000;
104
+
105
+ export interface CardRunOptions {
106
+ /** The error once an action threw or rejected (copy `cardActionFailed`). */
107
+ failedText: string;
108
+ /** The error once an action ran longer than `timeoutMs` (copy `cardActionTimedOut`). */
109
+ timedOutText?: string;
110
+ /** Milliseconds before a running action reads as failed; 30 s by default, 0 for never. */
111
+ timeoutMs?: number;
112
+ }
113
+
114
+ /**
115
+ * Runs the app's actions for a message's cards (tick a task, answer a check-in) and keeps each
116
+ * card's state by attachment id: `pending` until the action settles, then `error` (the copy's
117
+ * `cardActionFailed`) if it threw or rejected. Only a promise makes a card busy; a second run
118
+ * while one is pending is ignored. One that hasn't settled after `timeoutMs` (30 s) stops being
119
+ * busy and reads as failed (`cardActionTimedOut`); what it settles to later is ignored.
120
+ */
121
+ export function useCardRuns(options: CardRunOptions | string): { stateOf(id: string): CardRunState; run(id: string, task: () => unknown): Promise<void> } {
122
+ const { failedText, timedOutText = failedText, timeoutMs = CARD_ACTION_TIMEOUT_MS } = typeof options === "string" ? { failedText: options } : options;
123
+ const [states, setStates] = useState<Record<string, CardRunState>>({});
124
+ const busy = useRef(new Set<string>());
125
+ const run = useCallback(
126
+ async (id: string, task: () => unknown) => {
127
+ if (busy.current.has(id)) return;
128
+ const failed = (e: unknown, text = failedText) => {
129
+ if (typeof __DEV__ !== "undefined" && __DEV__) console.warn("[genesis-chat] an attachment card's action failed:", e);
130
+ setStates((s) => ({ ...s, [id]: { pending: false, error: text } }));
131
+ };
132
+ let result: unknown;
133
+ try {
134
+ result = task();
135
+ } catch (e) {
136
+ failed(e);
137
+ return;
138
+ }
139
+ // Only an action that returns a promise is busy; a plain handler is done when it returns.
140
+ if (!result || typeof (result as PromiseLike<unknown>).then !== "function") {
141
+ setStates((s) => (s[id] ? { ...s, [id]: IDLE } : s));
142
+ return;
143
+ }
144
+ busy.current.add(id);
145
+ setStates((s) => ({ ...s, [id]: { pending: true, error: null } }));
146
+ const TIMED_OUT = Symbol("timed out");
147
+ let timer: ReturnType<typeof setTimeout> | undefined;
148
+ const limit = timeoutMs > 0 ? new Promise<typeof TIMED_OUT>((resolve) => (timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs))) : null;
149
+ try {
150
+ const settled = await (limit ? Promise.race([result as PromiseLike<unknown>, limit]) : result);
151
+ if (settled === TIMED_OUT) {
152
+ failed(new Error(`timed out after ${timeoutMs} ms`), timedOutText);
153
+ // Swallow what the abandoned action settles to.
154
+ void Promise.resolve(result).catch(() => undefined);
155
+ } else setStates((s) => ({ ...s, [id]: IDLE }));
156
+ } catch (e) {
157
+ failed(e);
158
+ } finally {
159
+ if (timer !== undefined) clearTimeout(timer);
160
+ busy.current.delete(id);
161
+ }
162
+ },
163
+ [failedText, timedOutText, timeoutMs],
164
+ );
165
+ const stateOf = useCallback((id: string) => states[id] ?? IDLE, [states]);
166
+ return { stateOf, run };
167
+ }
168
+
169
+ /**
170
+ * What the `attachment` slot gets: the web slot's props (`attachment`, `message`, `url`, `mine`)
171
+ * and what a native card needs besides. The bubble is one VoiceOver element, so a control drawn
172
+ * here (a checkbox) is reached through the kind's `action`, which the bubble offers as its own
173
+ * action: give the kind one, and call `action.run` from the control so both stay the same.
174
+ */
175
+ export interface ChatAttachmentSlotProps {
176
+ attachment: ChatAttachment;
177
+ message: ChatMessage;
178
+ /**
179
+ * Its signed URL when it has a file (else its `url`), only when it is http(s): the attachment's
180
+ * `url` is whatever the sender put there, so any other scheme (javascript:, file:) is null.
181
+ */
182
+ url: string | null;
183
+ /** On a message you sent (drawn on the tint). */
184
+ mine: boolean;
185
+ /** The app's kind for it (`ChatProvider attachmentKinds`), when registered. */
186
+ kind: ChatAttachmentKind | undefined;
187
+ /** The kind's `action`, ready to call: it runs through `run`, so `pending` and `error` follow it. Null without one. */
188
+ action: { label: string; run: () => void } | null;
189
+ /** Runs an app action (tick a task): `pending` until it settles, `error` if it throws or rejects. */
190
+ run(task: () => unknown): void;
191
+ pending: boolean;
192
+ /** A short message once an action failed; cleared by the next run. */
193
+ error: string | null;
194
+ /** The card the package would draw: return it for kinds the slot doesn't draw. */
195
+ fallback: ReactElement;
196
+ testID: string;
197
+ }
198
+
199
+ /**
200
+ * Draws the app's own attachment kinds; `null` hides the kinds that aren't registered. A slot that
201
+ * returns null draws nothing for that attachment (a bubble left with nothing is not shown).
202
+ */
203
+ export type ChatAttachmentSlot = ComponentType<ChatAttachmentSlotProps>;
204
+
205
+ /** A URL a slot may be given: http or https only, else null. */
206
+ export function slotUrl(url: string | null | undefined): string | null {
207
+ if (!url) return null;
208
+ try {
209
+ const u = new URL(url);
210
+ return (u.protocol === "https:" || u.protocol === "http:") && u.hostname ? url : null;
211
+ } catch {
212
+ return null;
213
+ }
214
+ }
215
+
74
216
  export interface ChatAttachmentCardProps {
75
217
  attachment: ChatAttachment;
76
218
  message: ChatMessage;
77
- kind: ChatAttachmentKind;
219
+ /** The app's kind; without one the card shows the attachment's name (or kind) and no button. */
220
+ kind?: ChatAttachmentKind;
78
221
  mine?: boolean;
79
222
  onOpenAttachment?: OpenAttachment;
223
+ /** Runs the button's action (the bubble passes its own, for busy and error state); calls it straight away by default. */
224
+ run?: (task: () => unknown) => void;
225
+ /** Busy while the action runs; the error under the card once it failed. */
226
+ state?: CardRunState;
80
227
  testID?: string;
81
228
  }
82
229
 
83
- export function ChatAttachmentCard({ attachment, message, kind, mine = false, onOpenAttachment, testID = "chat-attachment-card" }: ChatAttachmentCardProps) {
230
+ export function ChatAttachmentCard({ attachment, message, kind, mine = false, onOpenAttachment, run, state = IDLE, testID = "chat-attachment-card" }: ChatAttachmentCardProps) {
84
231
  const { copy } = useChatNative();
85
232
  const colors = useThemeRoles();
86
233
  let title = "";
@@ -97,9 +244,9 @@ export function ChatAttachmentCard({ attachment, message, kind, mine = false, on
97
244
  const action = cardAction(kind, attachment, message, onOpenAttachment, ctx);
98
245
  const fg = mine ? "text-primary-foreground" : undefined;
99
246
  return (
247
+ <View testID={testID} style={{ gap: 4, minWidth: 220 }}>
100
248
  <View
101
- testID={testID}
102
- style={{ flexDirection: "row", alignItems: "center", gap: 10, padding: 10, borderRadius: 12, minWidth: 220, backgroundColor: mine ? ON_TINT_FILL : colors.groupedBackground }}
249
+ style={{ flexDirection: "row", alignItems: "center", gap: 10, padding: 10, borderRadius: 12, backgroundColor: mine ? ON_TINT_FILL : colors.groupedBackground }}
103
250
  >
104
251
  <View
105
252
  accessible
@@ -121,12 +268,103 @@ export function ChatAttachmentCard({ attachment, message, kind, mine = false, on
121
268
  </View>
122
269
  </View>
123
270
  {action ? (
124
- <NativeButton testID={`${testID}-action`} variant={mine ? "secondary" : "default"} onPress={action.run}>
271
+ <NativeButton
272
+ testID={`${testID}-action`}
273
+ variant={mine ? "secondary" : "default"}
274
+ className="h-11"
275
+ loading={state.pending}
276
+ onPress={() => (run ? run(action.run) : action.run())}
277
+ >
125
278
  {action.label}
126
279
  </NativeButton>
127
280
  ) : null}
128
281
  </View>
282
+ {state.error ? <CardError text={state.error} mine={mine} testID={`${testID}-error`} /> : null}
283
+ </View>
284
+ );
285
+ }
286
+
287
+ /** The line under a card whose action failed. */
288
+ export function CardError({ text, mine, testID }: { text: string; mine: boolean; testID?: string }) {
289
+ const colors = useThemeRoles();
290
+ return (
291
+ <NativeText testID={testID} preset="caption" accessibilityLiveRegion="polite" className={mine ? "text-primary-foreground" : undefined} style={mine ? { paddingHorizontal: 4 } : { paddingHorizontal: 4, color: colors.destructive }}>
292
+ {text}
293
+ </NativeText>
129
294
  );
130
295
  }
131
296
 
297
+ export interface AppAttachmentProps {
298
+ attachment: ChatAttachment;
299
+ message: ChatMessage;
300
+ kind: ChatAttachmentKind | undefined;
301
+ url: string | null;
302
+ mine: boolean;
303
+ /** The app's slot: a component draws it, `null` hides an unregistered kind, undefined draws the card. */
304
+ slot?: ChatAttachmentSlot | null;
305
+ /** Told whether it drew nothing (the slot returned null, or hid the kind), so the bubble can leave it out. */
306
+ onEmpty?: (id: string, empty: boolean) => void;
307
+ onOpenAttachment?: OpenAttachment;
308
+ run: (task: () => unknown) => void;
309
+ state: CardRunState;
310
+ testID: string;
311
+ }
312
+
313
+ /** One app attachment in a bubble: through the app's slot when there is one, else the card. */
314
+ export function AppAttachment({ attachment, message, kind, url, mine, slot, onOpenAttachment, onEmpty, run, state, testID }: AppAttachmentProps) {
315
+ const hidden = slot === null && !kind;
316
+ useLayoutEffect(() => {
317
+ if (slot === null || slot === undefined) onEmpty?.(attachment.id, hidden);
318
+ }, [slot, hidden, attachment.id, onEmpty]);
319
+ const fallback = <ChatAttachmentCard attachment={attachment} message={message} kind={kind} mine={mine} onOpenAttachment={onOpenAttachment} run={run} state={state} testID={testID} />;
320
+ if (slot === undefined) return fallback;
321
+ if (slot === null) return hidden ? null : fallback;
322
+ const found = cardAction(kind, attachment, message, onOpenAttachment, { mine });
323
+ const action = found ? { label: found.label, run: () => run(found.run) } : null;
324
+ const props: ChatAttachmentSlotProps = { attachment, message, url: slotUrl(url), mine, kind, action, run, pending: state.pending, error: state.error, fallback, testID };
325
+ // The slot draws sender-controlled data: if it throws, the card stands in until what it draws changes.
326
+ return (
327
+ <ChatErrorBoundary fallback={<Reported id={attachment.id} empty={false} onEmpty={onEmpty}>{fallback}</Reported>} resetKeys={[attachment, message, kind, slot]}>
328
+ <SlotProbe key={slotKey(slot)} slot={slot} props={props} onEmpty={onEmpty} />
329
+ </ChatErrorBoundary>
330
+ );
331
+ }
332
+
333
+ const slotKeys = new WeakMap<object, number>();
334
+ let nextSlotKey = 0;
335
+ /** A stable key per slot component, so a new slot starts a new probe (its hooks start over). */
336
+ function slotKey(slot: ChatAttachmentSlot): number {
337
+ let k = slotKeys.get(slot);
338
+ if (k === undefined) slotKeys.set(slot, (k = ++nextSlotKey));
339
+ return k;
340
+ }
341
+
342
+ /** A component React lets us call as a function (not a class, not a memo or forwardRef object). */
343
+ function isPlainFunction(slot: ChatAttachmentSlot): slot is (props: ChatAttachmentSlotProps) => ReactNode {
344
+ return typeof slot === "function" && !(slot.prototype as { isReactComponent?: unknown } | undefined)?.isReactComponent;
345
+ }
346
+
347
+ /**
348
+ * Draws the slot and tells the bubble when it drew nothing. A plain function slot is called here,
349
+ * so its hooks are this component's (always the same slot: the probe is keyed on it); a
350
+ * class or memo slot renders as an element and counts as drawn.
351
+ */
352
+ function SlotProbe({ slot, props, onEmpty }: { slot: ChatAttachmentSlot; props: ChatAttachmentSlotProps; onEmpty?: (id: string, empty: boolean) => void }) {
353
+ const Slot = slot;
354
+ const out: ReactNode = isPlainFunction(slot) ? slot(props) : <Slot {...props} />;
355
+ const empty = out === null || out === undefined || out === false;
356
+ return (
357
+ <Reported id={props.attachment.id} empty={empty} onEmpty={onEmpty}>
358
+ {empty ? null : out}
359
+ </Reported>
360
+ );
361
+ }
362
+
363
+ function Reported({ id, empty, onEmpty, children }: { id: string; empty: boolean; onEmpty?: (id: string, empty: boolean) => void; children: ReactNode }) {
364
+ useLayoutEffect(() => {
365
+ onEmpty?.(id, empty);
366
+ }, [id, empty, onEmpty]);
367
+ return <>{children}</>;
368
+ }
369
+
132
370
  declare const __DEV__: boolean | undefined;
@@ -104,6 +104,15 @@ export interface ChatNativeCopy extends ChatOrganiseCopy {
104
104
  /** The bubble's VoiceOver action for its link preview. */
105
105
  openLinkAction: string;
106
106
  attachmentCard: string;
107
+ /** Under an attachment card whose action failed (ticking a task offline). */
108
+ cardActionFailed: string;
109
+ /** Under an attachment card whose action ran too long (`cardActionTimeoutMs`). */
110
+ cardActionTimedOut: string;
111
+ /** A voice note's transcript: still being made, its VoiceOver label, and the toggle for a long one. */
112
+ transcribing: string;
113
+ transcriptLabel: string;
114
+ showMore: string;
115
+ showLess: string;
107
116
  openImage: string;
108
117
  closeViewer: string;
109
118
  photoOf: string;
@@ -239,6 +248,12 @@ export const chatNativeCopy: ChatNativeCopy = {
239
248
  openLink: "Opens the link",
240
249
  openLinkAction: "Open link",
241
250
  attachmentCard: "{title}, {subtitle}",
251
+ cardActionFailed: "That didn't work. Try again.",
252
+ cardActionTimedOut: "That's taking too long. Try again.",
253
+ transcribing: "Transcribing…",
254
+ transcriptLabel: "Transcript: {text}",
255
+ showMore: "Show more",
256
+ showLess: "Show less",
242
257
  openImage: "Open photo",
243
258
  closeViewer: "Close",
244
259
  photoOf: "Photo {n} of {count}",
@@ -16,7 +16,8 @@
16
16
  * Video messages: `ChatVideoRecorder` (expo-camera), `ChatVideoPlayer`
17
17
  * (expo-video, HLS), uploads through `ChatProvider videoUpload` with
18
18
  * `chunkedPut`; link previews (`ChatLinkPreviewCard`) and the app's own
19
- * attachment cards (`ChatAttachmentCard`).
19
+ * attachment cards (`ChatAttachmentCard`), or their own drawing through the
20
+ * `attachment` slot (`ChatThreadScreen slots`).
20
21
  *
21
22
  * Optional peers, passed in rather than imported: expo-audio,
22
23
  * expo-image-picker, expo-haptics, expo-clipboard, expo-camera, expo-video,
@@ -26,14 +27,14 @@
26
27
  * @stability Beta
27
28
  */
28
29
  export { ChatInboxScreen, inboxRow, type ChatInboxScreenProps } from "./inbox-screen";
29
- export { ChatThreadScreen, threadListMode, nearBottom, AT_BOTTOM_POINTS, type ChatThreadScreenProps } from "./thread-screen";
30
+ export { ChatThreadScreen, threadListMode, nearBottom, AT_BOTTOM_POINTS, type ChatThreadScreenProps, type ChatThreadScreenSlots } from "./thread-screen";
30
31
  export { chatThreadStackOptions, useChatThreadStackOptions, type ChatThreadStackOptionsProps } from "./stack-options";
31
32
  export { ChatStartSheet, ChatStartButton, type ChatStartPerson, type ChatStartSheetProps } from "./start-sheet";
32
33
  export { ChatComposer, type ChatComposerProps } from "./composer";
33
34
  export { MessageBubble, displayBody, formatClock, type MessageBubbleProps } from "./message-bubble";
34
35
  export { MessageActionsSheet, type MessageActionsSheetProps } from "./message-actions-sheet";
35
36
  export { VoiceNotePlayer, claimPlayback, releasePlayback, pauseChatPlayback, activePlaybackId, toggleVoiceNote, cycleVoiceNoteRate, usePlayingVoiceNote, scrubSeconds, waveformBars, barsThatFit, type VoiceNotePlayerProps } from "./voice-player";
36
- export { MessageAttachments, attachmentPaths, type BubbleAttachment } from "./media";
37
+ export { MessageAttachments, attachmentPaths, VoiceTranscript, transcriptIsLong, TRANSCRIPT_LINES, type BubbleAttachment, type VoiceTranscriptProps } from "./media";
37
38
  export {
38
39
  ChatMediaViewer,
39
40
  ChatPhotoViewer,
@@ -72,7 +73,30 @@ export { ChatVideoPlayer, videoState, videoSource, muxStreamUrl, muxPosterUrl, t
72
73
  export { chunkedPut, fileSource, nextOffset, createVideoUploads, videoUploads, type VideoUploadError, type MintedUpload, useVideoUploads, recordedVideoFile, VIDEO_CHUNK_BYTES, type ByteSource, type ChunkedPutOptions, type RecordedVideo, type VideoUploadTask, type VideoUploads, type StartVideoInput, type OutboxSend } from "./video-upload";
73
74
  export { VideoUploadTray, type VideoUploadTrayProps } from "./video-upload-tray";
74
75
  export { ChatLinkPreviewCard, previewToShow, linkPreviewIds, openChatLink, linkHost, type ChatLinkPreviewCardProps } from "./link-preview";
75
- export { ChatAttachmentCard, splitCardAttachments, isOpenableHref, cardAction, cardTitle, attachmentPreview, messagePreview, type ChatAttachmentCardProps, type ChatCardContext, type OpenAttachment } from "./attachment-cards";
76
+ export {
77
+ ChatAttachmentCard,
78
+ AppAttachment,
79
+ CardError,
80
+ useCardRuns,
81
+ slotUrl,
82
+ CARD_ACTION_TIMEOUT_MS,
83
+ type CardRunOptions,
84
+ splitCardAttachments,
85
+ attachmentKindOf,
86
+ BUILT_IN_KINDS,
87
+ isOpenableHref,
88
+ cardAction,
89
+ cardTitle,
90
+ attachmentPreview,
91
+ messagePreview,
92
+ type ChatAttachmentCardProps,
93
+ type ChatAttachmentSlot,
94
+ type ChatAttachmentSlotProps,
95
+ type AppAttachmentProps,
96
+ type CardRunState,
97
+ type ChatCardContext,
98
+ type OpenAttachment,
99
+ } from "./attachment-cards";
76
100
  export { ChatNativeProvider, useChatNative, initials, type ChatNativeProviderProps, type ChatNativeContextValue, type ChatPerson } from "./context";
77
101
  export { chatNativeCopy, resolveChatCopy, fill, type ChatNativeCopy, type ChatNativeCopyOverrides } from "./copy";
78
102
  export {
@@ -3,8 +3,10 @@
3
3
  * Pictures and files in a message: photos as a grid of NativeImage (expo-image
4
4
  * when the app gave it) over signed links from `useFileUrls`, a local preview
5
5
  * while one still uploads, a tap opening the full-screen viewer (photo-viewer.tsx); voice notes
6
- * as VoiceNotePlayer; other files as a row with their name.
6
+ * as VoiceNotePlayer with their transcript under them (three lines, then "Show more"), as on the
7
+ * web; other files as a row with their name.
7
8
  */
9
+ import { useState } from "react";
8
10
  import { Pressable, View, useWindowDimensions } from "react-native";
9
11
  import { NativeIcon, NativeImage, NativeText } from "@marktiderman/genesis-ui-native/components";
10
12
  import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
@@ -33,6 +35,9 @@ export interface BubbleAttachment {
33
35
  posterUri?: string | null;
34
36
  /** Still on its way from this phone. */
35
37
  uploading?: boolean;
38
+ /** A voice note's (or video's) transcript, and whether one is still being made. */
39
+ transcript?: string | null;
40
+ transcriptStatus?: ChatAttachment["transcriptStatus"];
36
41
  }
37
42
 
38
43
  export function fromServer(a: ChatAttachment, urls: Record<string, string>): BubbleAttachment {
@@ -50,6 +55,8 @@ export function fromServer(a: ChatAttachment, urls: Record<string, string>): Bub
50
55
  playbackId: a.playbackId,
51
56
  status: a.status,
52
57
  posterUri: a.posterPath ? (urls[a.posterPath] ?? null) : null,
58
+ transcript: a.transcript,
59
+ transcriptStatus: a.transcriptStatus,
53
60
  };
54
61
  }
55
62
 
@@ -149,7 +156,10 @@ export function MessageAttachments({ attachments, mine, onOpenImage, testID }: A
149
156
  ) : null}
150
157
  {others.map((a, i) =>
151
158
  a.kind === "voice" ? (
152
- <VoiceNotePlayer key={a.key} id={a.key} uri={a.uri} durationMs={a.durationMs} waveform={a.waveform} mine={mine} testID={`${testID}-voice-${i}`} />
159
+ <View key={a.key} style={{ gap: 2 }}>
160
+ <VoiceNotePlayer id={a.key} uri={a.uri} durationMs={a.durationMs} waveform={a.waveform} mine={mine} testID={`${testID}-voice-${i}`} />
161
+ <VoiceTranscript transcript={a.transcript} status={a.transcriptStatus} mine={mine} testID={`${testID}-voice-${i}-transcript`} />
162
+ </View>
153
163
  ) : a.kind === "video" && modules.video ? (
154
164
  <ChatVideoPlayer
155
165
  key={a.key}
@@ -185,3 +195,80 @@ export function MessageAttachments({ attachments, mine, onOpenImage, testID }: A
185
195
  }
186
196
 
187
197
  // ChatMediaViewer (one picture) is in photo-viewer.tsx, over the full-screen ChatPhotoViewer.
198
+
199
+ /** Lines a transcript shows before "Show more". */
200
+ export const TRANSCRIPT_LINES = 3;
201
+
202
+ /**
203
+ * Whether a transcript folds behind "Show more": by its laid-out line count once measured. Before
204
+ * any layout (unit tests, where nothing is laid out) a rough guess from its length stands in.
205
+ */
206
+ export function transcriptIsLong(text: string, measuredLines?: number | null): boolean {
207
+ if (typeof measuredLines === "number") return measuredLines > TRANSCRIPT_LINES;
208
+ return text.length > 140 || text.split("\n").length > TRANSCRIPT_LINES;
209
+ }
210
+
211
+ export interface VoiceTranscriptProps {
212
+ transcript: string | null | undefined;
213
+ status: ChatAttachment["transcriptStatus"] | undefined;
214
+ mine: boolean;
215
+ testID: string;
216
+ }
217
+
218
+ /**
219
+ * A voice note's transcript under the player: "Transcribing…" while it is made, then the text,
220
+ * folded to three lines with "Show more" when it is longer. The bubble reads the whole transcript
221
+ * to VoiceOver, so the toggle is for sight only.
222
+ */
223
+ export function VoiceTranscript({ transcript, status, mine, testID }: VoiceTranscriptProps) {
224
+ const { copy } = useChatNative();
225
+ const colors = useThemeRoles();
226
+ const [open, setOpen] = useState(false);
227
+ // Lines the whole transcript takes at this width, from an unclamped copy laid out out of sight
228
+ // (a clamped Text reports only its visible lines on Android).
229
+ const [lines, setLines] = useState<number | null>(null);
230
+ const fg = mine ? "text-primary-foreground" : undefined;
231
+ const text = transcript?.trim();
232
+ if (status === "pending" && !text) {
233
+ return (
234
+ <NativeText testID={testID} preset="caption" className={fg} style={mine ? { fontStyle: "italic", opacity: 0.85, paddingHorizontal: 8 } : { fontStyle: "italic", color: colors.secondaryLabel, paddingHorizontal: 8 }}>
235
+ {copy.transcribing}
236
+ </NativeText>
237
+ );
238
+ }
239
+ if (!text) return null;
240
+ const long = transcriptIsLong(text, lines);
241
+ const textStyle = mine ? { fontSize: 15, lineHeight: 20 } : { fontSize: 15, lineHeight: 20, color: colors.label };
242
+ return (
243
+ <View testID={testID} style={{ paddingHorizontal: 8, alignItems: "flex-start" }}>
244
+ <NativeText testID={`${testID}-text`} className={fg} numberOfLines={open || !long ? undefined : TRANSCRIPT_LINES} style={textStyle}>
245
+ {text}
246
+ </NativeText>
247
+ <NativeText
248
+ testID={`${testID}-measure`}
249
+ aria-hidden
250
+ accessibilityElementsHidden
251
+ importantForAccessibility="no-hide-descendants"
252
+ onTextLayout={(e: { nativeEvent: { lines: unknown[] } }) => setLines(e.nativeEvent.lines.length)}
253
+ style={[textStyle, { position: "absolute", left: 8, right: 8, top: 0, opacity: 0 }]}
254
+ pointerEvents="none"
255
+ >
256
+ {text}
257
+ </NativeText>
258
+ {long ? (
259
+ <Pressable
260
+ testID={`${testID}-toggle`}
261
+ accessibilityRole="button"
262
+ accessibilityState={{ expanded: open }}
263
+ onPress={() => setOpen((o) => !o)}
264
+ hitSlop={{ top: 8, bottom: 8 }}
265
+ style={{ minHeight: 44, minWidth: 44, justifyContent: "center" }}
266
+ >
267
+ <NativeText preset="caption" className={fg} style={mine ? { fontWeight: "600" } : { fontWeight: "600", color: colors.tint }}>
268
+ {open ? copy.showLess : copy.showMore}
269
+ </NativeText>
270
+ </Pressable>
271
+ ) : null}
272
+ </View>
273
+ );
274
+ }