@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,16 +8,19 @@
8
8
  * are also VoiceOver / TalkBack actions, so nothing needs a gesture. The
9
9
  * bubble is one accessible element (its parts would otherwise be read one by
10
10
  * one), which hides the controls inside it, so it carries theirs as actions
11
- * too: play or pause a voice note and change its speed, open each photo.
11
+ * too: play or pause a voice note and change its speed, open each photo, an
12
+ * app card's action. App attachment kinds draw as cards, or through the app's
13
+ * `attachmentSlot` (see attachment-cards.tsx); a voice note's transcript is
14
+ * part of the bubble's label.
12
15
  */
13
- import { memo, useMemo, useRef } from "react";
16
+ import { memo, useCallback, useMemo, useRef, useState } from "react";
14
17
  import { Animated, PanResponder, Pressable, View } from "react-native";
15
18
  import { NativeAvatar, NativeText } from "@marktiderman/genesis-ui-native/components";
16
19
  import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
17
- import { previewText, summarizeReactions } from "../core/thread";
20
+ import { summarizeReactions } from "../core/thread";
18
21
  import type { ChatLinkPreview, ChatMessage, PendingFile } from "../core/types";
19
22
  import type { ChatAttachmentKind } from "../react/index";
20
- import { cardAction, cardTitle, ChatAttachmentCard, splitCardAttachments, type OpenAttachment } from "./attachment-cards";
23
+ import { AppAttachment, attachmentKindOf, attachmentPreview, cardAction, cardTitle, splitCardAttachments, useCardRuns, type ChatAttachmentSlot, type OpenAttachment } from "./attachment-cards";
21
24
  import { ChatLinkPreviewCard, linkHost, openChatLink } from "./link-preview";
22
25
  import { cycleVideoRate, fullScreenVideo, toggleVideo, toggleVideoMute, usePlayingVideo } from "./video-player";
23
26
  import { fill } from "./copy";
@@ -61,6 +64,14 @@ export interface MessageBubbleProps {
61
64
  attachmentKinds?: Record<string, ChatAttachmentKind>;
62
65
  /** A card's button, when its kind gives no `onPress` of its own. */
63
66
  onOpenAttachment?: OpenAttachment;
67
+ /**
68
+ * Draws the app's own attachment kinds (a task with a checkbox, a worksheet with its progress)
69
+ * instead of the card: every kind the package doesn't draw, registered or not. `null` hides the
70
+ * kinds that aren't registered. Define it at module scope (or memoize it) so rows stay memoized.
71
+ */
72
+ attachmentSlot?: ChatAttachmentSlot | null;
73
+ /** How long an app card's action may run before it reads as failed; 30 s by default, 0 for never. */
74
+ cardActionTimeoutMs?: number;
64
75
  testID: string;
65
76
  }
66
77
 
@@ -94,10 +105,24 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
94
105
  const senderName = mine ? copy.you : (sender?.name ?? "");
95
106
 
96
107
  const kinds = props.attachmentKinds;
108
+ const slot = props.attachmentSlot;
97
109
  const { cards, rest: plain } = useMemo(
98
- () => splitCardAttachments(message && !message.deletedAt ? message.attachments : [], kinds),
99
- [message, kinds],
110
+ () => splitCardAttachments(message && !message.deletedAt ? message.attachments : [], kinds, slot !== undefined),
111
+ [message, kinds, slot],
100
112
  );
113
+ const runs = useCardRuns({ failedText: copy.cardActionFailed, timedOutText: copy.cardActionTimedOut, timeoutMs: props.cardActionTimeoutMs });
114
+ // App attachments that drew nothing (the slot returned null): left out of the label and actions.
115
+ const [empty, setEmpty] = useState<ReadonlySet<string>>(() => new Set());
116
+ const onEmpty = useCallback((id: string, isEmpty: boolean) => {
117
+ setEmpty((s) => {
118
+ if (s.has(id) === isEmpty) return s;
119
+ const next = new Set(s);
120
+ if (isEmpty) next.add(id);
121
+ else next.delete(id);
122
+ return next;
123
+ });
124
+ }, []);
125
+ const shownCards = cards.filter((a) => !empty.has(a.id));
101
126
  const attachments: BubbleAttachment[] = useMemo(() => {
102
127
  if (message) return message.deletedAt ? [] : plain.map((a) => fromServer(a, urls));
103
128
  // What it already carries (a hosted video) comes first, as the outbox sends it.
@@ -113,7 +138,13 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
113
138
  const photos = attachments.filter((a) => a.kind === "image" && a.uri && props.onOpenImage);
114
139
  const videos = attachments.filter((a) => a.kind === "video" && modules.video && (a.uri || a.playbackId));
115
140
  const playingVideo = usePlayingVideo(videos.map((a) => a.key));
116
- const cardActions = message ? cards.map((a) => cardAction(kinds?.[a.kind], a, message, props.onOpenAttachment, { mine })) : [];
141
+ const cardActions = message
142
+ ? cards.map((a) => {
143
+ if (empty.has(a.id)) return null;
144
+ const c = cardAction(attachmentKindOf(kinds, a.kind), a, message, props.onOpenAttachment, { mine });
145
+ return c ? { label: c.label, run: () => void runs.run(a.id, c.run) } : null;
146
+ })
147
+ : [];
117
148
  const playingVoice = usePlayingVoiceNote(voices.map((a) => a.key));
118
149
 
119
150
  // ---- Swipe to reply ----
@@ -165,8 +196,23 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
165
196
  const label = [
166
197
  senderName,
167
198
  message?.deletedAt ? copy.deleted : displayBody(body),
168
- attachments.map((a) => (a.kind === "image" ? copy.photo : a.kind === "voice" ? copy.voice : a.kind === "video" ? copy.video : copy.file)).join(", "),
169
- cards.map((a) => cardTitle(kinds?.[a.kind], a, { mine })).join(", "),
199
+ attachments
200
+ .map((a) =>
201
+ a.kind === "image"
202
+ ? copy.photo
203
+ : a.kind === "voice"
204
+ ? [copy.voice, a.transcript?.trim() ? fill(copy.transcriptLabel, { text: a.transcript.trim() }) : null].filter(Boolean).join(", ")
205
+ : a.kind === "video"
206
+ ? copy.video
207
+ : copy.file,
208
+ )
209
+ .join(", "),
210
+ shownCards
211
+ .map((a) => {
212
+ const s = runs.stateOf(a.id);
213
+ return [cardTitle(attachmentKindOf(kinds, a.kind), a, { mine }), s.error].filter(Boolean).join(", ");
214
+ })
215
+ .join(", "),
170
216
  preview ? fill(copy.linkPreview, { title: preview.title?.trim() || linkHost(preview.url), site: preview.siteName?.trim() || linkHost(preview.url) }) : null,
171
217
  time,
172
218
  message?.editedAt ? copy.edited : null,
@@ -221,12 +267,19 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
221
267
 
222
268
  const quote = message?.replyToId ?? pending?.input.replyToId ?? null;
223
269
  const quoted = quote && repliedTo ? repliedTo : null;
270
+ // A message that is only app attachments the slot drew nothing for has nothing to show or read.
271
+ const invisible = Boolean(message && !message.deletedAt && !body && !attachments.length && !preview && !quote && !moderation && cards.length && !shownCards.length);
224
272
  const bubbleColor = mine ? colors.tint : colors.surface;
225
273
  const fg = mine ? "text-primary-foreground" : undefined;
226
274
  const textStyle = mine ? { fontSize: 17, lineHeight: 22 } : { fontSize: 17, lineHeight: 22, color: colors.label };
227
275
 
228
276
  return (
229
- <View testID={testID} style={{ paddingHorizontal: 8, paddingTop: row.groupStart ? 8 : 2, backgroundColor: props.highlighted ? colors.fill : undefined }}>
277
+ <View
278
+ testID={testID}
279
+ accessibilityElementsHidden={invisible || undefined}
280
+ importantForAccessibility={invisible ? "no-hide-descendants" : undefined}
281
+ style={{ paddingHorizontal: 8, paddingTop: row.groupStart ? 8 : 2, backgroundColor: props.highlighted ? colors.fill : undefined, display: invisible ? "none" : undefined }}
282
+ >
230
283
  {row.showName && sender?.name ? (
231
284
  <NativeText preset="caption" style={{ marginLeft: group ? 48 : 12, marginBottom: 2, color: colors.secondaryLabel }}>
232
285
  {sender.name}
@@ -269,14 +322,27 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
269
322
  {quoted ? (quoted.senderId === me ? copy.you : (person(quoted.senderId)?.name ?? "")) : ""}
270
323
  </NativeText>
271
324
  <NativeText preset="caption" numberOfLines={2} className={fg} style={mine ? undefined : { color: colors.secondaryLabel }}>
272
- {quoted ? previewText(quoted.body, quoted.attachments[0]?.kind, Boolean(quoted.deletedAt)) : "…"}
325
+ {quoted ? attachmentPreview(displayBody(quoted.body), quoted.attachments[0], kinds, Boolean(quoted.deletedAt)) : "…"}
273
326
  </NativeText>
274
327
  </View>
275
328
  ) : null}
276
329
  {attachments.length ? <MessageAttachments attachments={attachments} mine={mine} onOpenImage={props.onOpenImage} testID={testID} /> : null}
277
330
  {message && cards.length
278
331
  ? cards.map((a, i) => (
279
- <ChatAttachmentCard key={a.id} attachment={a} message={message} kind={kinds![a.kind]!} mine={mine} onOpenAttachment={props.onOpenAttachment} testID={`${testID}-card-${i}`} />
332
+ <AppAttachment
333
+ key={a.id}
334
+ attachment={a}
335
+ message={message}
336
+ kind={attachmentKindOf(kinds, a.kind)}
337
+ url={(a.path ? urls[a.path] : null) ?? a.url ?? null}
338
+ mine={mine}
339
+ slot={slot}
340
+ onOpenAttachment={props.onOpenAttachment}
341
+ onEmpty={onEmpty}
342
+ run={(task) => void runs.run(a.id, task)}
343
+ state={runs.stateOf(a.id)}
344
+ testID={`${testID}-card-${i}`}
345
+ />
280
346
  ))
281
347
  : null}
282
348
  {message?.deletedAt ? (
@@ -29,7 +29,10 @@ import type { Outbox, OutboxStore, QueuedMessage } from "../core/outbox";
29
29
 
30
30
  export const CHAT_PUSH_CATEGORY = "messages";
31
31
  export const CHAT_REPLY_ACTION = "reply";
32
- export const CHAT_MARK_READ_ACTION = "mark-read";
32
+ /** The same id as genesis-notifications' `MESSAGE_ACTIONS` (device ids take no "-" or ":"). */
33
+ export const CHAT_MARK_READ_ACTION = "mark_read";
34
+ /** The id before 0.2.2: a notification already on a phone still carries it. */
35
+ const LEGACY_MARK_READ_ACTION = "mark-read";
33
36
 
34
37
  export interface ChatPushCopy {
35
38
  title: string;
@@ -212,7 +215,7 @@ export async function handleChatPushResponse(
212
215
  waitMs?: number;
213
216
  } & ChatPushOutboxDeps,
214
217
  ): Promise<ChatPushOutcome> {
215
- const action = response.actionIdentifier;
218
+ const action = response.actionIdentifier === LEGACY_MARK_READ_ACTION ? CHAT_MARK_READ_ACTION : response.actionIdentifier;
216
219
  if (action !== CHAT_REPLY_ACTION && action !== CHAT_MARK_READ_ACTION) return "ignored";
217
220
  const data = response.notification.request.content.data ?? {};
218
221
  const conversationId = conversationIdFromData(data);
@@ -88,79 +88,87 @@ export function ChatSearchScreen({ conversationId, onOpen, conversationTitle, de
88
88
  useEffect(() => {
89
89
  if (announce) AccessibilityInfo.announceForAccessibility?.(announce);
90
90
  }, [announce]);
91
+ const field = (
92
+ <View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
93
+ <NativeSearchBar
94
+ testID={`${testID}-field`}
95
+ value={value}
96
+ onChange={setValue}
97
+ placeholder={conversationId ? copy.searchThreadPlaceholder : copy.searchPlaceholder}
98
+ cancelLabel={copy.cancel}
99
+ showCancel={false}
100
+ />
101
+ </View>
102
+ );
103
+ const empty = short ? (
104
+ <NativeText preset="caption" style={{ paddingHorizontal: 20, color: colors.secondaryLabel }} testID={`${testID}-hint`}>
105
+ {copy.searchHint}
106
+ </NativeText>
107
+ ) : isSearching ? (
108
+ <View testID={`${testID}-loading`} style={{ padding: 16, gap: 10 }}>
109
+ <NativeSkeleton height={52} className="w-full" />
110
+ <NativeSkeleton height={52} className="w-full" />
111
+ </View>
112
+ ) : (
113
+ <NativeEmptyState variant="no-results" title={copy.searchNoResults} body={fill(copy.searchNoResultsBody, { query })} testID={`${testID}-empty`} />
114
+ );
115
+ // One list, with the field as its header and the hint, loading and empty states as its empty
116
+ // content: under a large-title or see-through (Liquid Glass) header only a scroll view's automatic
117
+ // content inset moves the content below the bar, as InboxPage does. A plain View at the top of the
118
+ // SafeAreaView drew the field behind the navigation bar, where it couldn't be tapped.
91
119
  return (
92
120
  <SafeAreaView testID={testID} edges={["left", "right"]} style={{ flex: 1, backgroundColor: colors.groupedBackground }}>
93
- <View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
94
- <NativeSearchBar
95
- testID={`${testID}-field`}
96
- value={value}
97
- onChange={setValue}
98
- placeholder={conversationId ? copy.searchThreadPlaceholder : copy.searchPlaceholder}
99
- cancelLabel={copy.cancel}
100
- showCancel={false}
101
- />
102
- </View>
103
- {short ? (
104
- <NativeText preset="caption" style={{ paddingHorizontal: 20, color: colors.secondaryLabel }} testID={`${testID}-hint`}>
105
- {copy.searchHint}
106
- </NativeText>
107
- ) : isSearching && hits.length === 0 ? (
108
- <View testID={`${testID}-loading`} style={{ padding: 16, gap: 10 }}>
109
- <NativeSkeleton height={52} className="w-full" />
110
- <NativeSkeleton height={52} className="w-full" />
111
- </View>
112
- ) : hits.length === 0 ? (
113
- <NativeEmptyState variant="no-results" title={copy.searchNoResults} body={fill(copy.searchNoResultsBody, { query })} testID={`${testID}-empty`} />
114
- ) : (
115
- <FlatList
116
- testID={`${testID}-results`}
117
- data={hits}
118
- keyExtractor={(h) => `${h.messageId}:${h.inTranscript}`}
119
- keyboardShouldPersistTaps="handled"
120
- keyboardDismissMode="on-drag"
121
- renderItem={({ item: h }) => {
122
- const who = nameOf(h.senderId);
123
- const where = conversationId ? null : fill(copy.searchIn, { name: titleOf(h.conversationId) });
124
- const when = formatInboxTime(new Date(h.createdAt), now());
125
- return (
126
- <HitRow
127
- testID={`${testID}-hit-${h.messageId}`}
128
- accessibilityLabel={[who, where, when, h.inTranscript ? copy.transcript : null, snippetText(h.snippet)].filter(Boolean).join(", ")}
129
- onPress={() => onOpen(h)}
130
- >
131
- <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
132
- <NativeText preset="label" numberOfLines={1} style={{ flexShrink: 1 }}>
133
- {who}
121
+ <FlatList
122
+ testID={`${testID}-results`}
123
+ data={short ? [] : hits}
124
+ keyExtractor={(h) => `${h.messageId}:${h.inTranscript}`}
125
+ contentInsetAdjustmentBehavior="automatic"
126
+ keyboardShouldPersistTaps="handled"
127
+ keyboardDismissMode="on-drag"
128
+ ListHeaderComponent={field}
129
+ ListEmptyComponent={empty}
130
+ renderItem={({ item: h }) => {
131
+ const who = nameOf(h.senderId);
132
+ const where = conversationId ? null : fill(copy.searchIn, { name: titleOf(h.conversationId) });
133
+ const when = formatInboxTime(new Date(h.createdAt), now());
134
+ return (
135
+ <HitRow
136
+ testID={`${testID}-hit-${h.messageId}`}
137
+ accessibilityLabel={[who, where, when, h.inTranscript ? copy.transcript : null, snippetText(h.snippet)].filter(Boolean).join(", ")}
138
+ onPress={() => onOpen(h)}
139
+ >
140
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
141
+ <NativeText preset="label" numberOfLines={1} style={{ flexShrink: 1 }}>
142
+ {who}
143
+ </NativeText>
144
+ {where ? (
145
+ <NativeText preset="caption" numberOfLines={1} style={{ flexShrink: 1, color: colors.secondaryLabel }}>
146
+ {where}
134
147
  </NativeText>
135
- {where ? (
136
- <NativeText preset="caption" numberOfLines={1} style={{ flexShrink: 1, color: colors.secondaryLabel }}>
137
- {where}
138
- </NativeText>
139
- ) : null}
140
- <NativeText preset="caption" style={{ marginLeft: "auto", color: colors.tertiaryLabel }}>
141
- {when}
142
- </NativeText>
143
- </View>
144
- <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 6 }}>
145
- {h.inTranscript ? (
146
- <NativeBadge variant="muted" testID={`${testID}-transcript-${h.messageId}`}>
147
- <View style={{ flexDirection: "row", alignItems: "center", gap: 3 }}>
148
- <NativeIcon name="mic" size={11} color={colors.secondaryLabel} />
149
- <NativeText preset="caption" style={{ color: colors.secondaryLabel }}>
150
- {copy.transcript}
151
- </NativeText>
152
- </View>
153
- </NativeBadge>
154
- ) : null}
155
- <View style={{ flex: 1 }}>
156
- <SearchSnippet snippet={h.snippet} />
157
- </View>
148
+ ) : null}
149
+ <NativeText preset="caption" style={{ marginLeft: "auto", color: colors.tertiaryLabel }}>
150
+ {when}
151
+ </NativeText>
152
+ </View>
153
+ <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 6 }}>
154
+ {h.inTranscript ? (
155
+ <NativeBadge variant="muted" testID={`${testID}-transcript-${h.messageId}`}>
156
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 3 }}>
157
+ <NativeIcon name="mic" size={11} color={colors.secondaryLabel} />
158
+ <NativeText preset="caption" style={{ color: colors.secondaryLabel }}>
159
+ {copy.transcript}
160
+ </NativeText>
161
+ </View>
162
+ </NativeBadge>
163
+ ) : null}
164
+ <View style={{ flex: 1 }}>
165
+ <SearchSnippet snippet={h.snippet} />
158
166
  </View>
159
- </HitRow>
160
- );
161
- }}
162
- />
163
- )}
167
+ </View>
168
+ </HitRow>
169
+ );
170
+ }}
171
+ />
164
172
  </SafeAreaView>
165
173
  );
166
174
  }
@@ -27,11 +27,11 @@ import { AccessibilityInfo, FlatList, KeyboardAvoidingView, Platform, Pressable,
27
27
  import { SafeAreaView } from "react-native-safe-area-context";
28
28
  import { NativeEmptyState, NativeSkeleton, NativeText, useGenesisModules, useToast } from "@marktiderman/genesis-ui-native/components";
29
29
  import { useReducedMotion, useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
30
- import { activityLine, previewText } from "../core/thread";
30
+ import { activityLine } from "../core/thread";
31
31
  import type { ChatMessage, PendingFile } from "../core/types";
32
32
  import { useChat, useConversation, useFileUrls, useLinkPreviews, useMessageActions, usePeople, usePins, useTopics } from "../react/index";
33
33
  import { recordingLimitMs } from "../core/media";
34
- import type { OpenAttachment } from "./attachment-cards";
34
+ import { attachmentPreview, type ChatAttachmentSlot, type OpenAttachment } from "./attachment-cards";
35
35
  import { linkPreviewIds, previewToShow } from "./link-preview";
36
36
  import { ChatVideoRecorder } from "./video-recorder";
37
37
  import { VideoUploadTray } from "./video-upload-tray";
@@ -80,6 +80,14 @@ export interface ChatThreadScreenProps {
80
80
  availability?: boolean;
81
81
  /** An app attachment card's button (a form, a check-in), when its kind gives no `onPress`. */
82
82
  onOpenAttachment?: OpenAttachment;
83
+ /**
84
+ * Swap parts, as the web `ChatThread` does. `attachment` draws the app's own attachment kinds
85
+ * (a task with a checkbox): every kind the package doesn't draw, with `fallback` for the card it
86
+ * would have been; `null` hides kinds that aren't registered. Define it at module scope.
87
+ */
88
+ slots?: ChatThreadScreenSlots;
89
+ /** How long an app card's action may run before it reads as failed; 30 s by default, 0 for never. */
90
+ cardActionTimeoutMs?: number;
83
91
  /**
84
92
  * Whether the screen is the one shown (the navigator's `useIsFocused()`). When it turns false a
85
93
  * video or voice note of this thread that is playing pauses: switching tab keeps the screen
@@ -89,6 +97,11 @@ export interface ChatThreadScreenProps {
89
97
  testID?: string;
90
98
  }
91
99
 
100
+ export interface ChatThreadScreenSlots {
101
+ /** Draws the app's own attachment kinds; the card by default. */
102
+ attachment?: ChatAttachmentSlot | null;
103
+ }
104
+
92
105
  /** The list props chat passes; FlashList v2 and FlatList both take them. */
93
106
  interface ChatListProps {
94
107
  ref?: unknown;
@@ -143,6 +156,8 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
143
156
  const { conversationId, threadRootId = null, keyboardVerticalOffset, onOpenAttachment, jumpTo, topic: topicProp, showTopics = false, scheduling = true, availability = true, focused = true, testID = "chat-thread" } = props;
144
157
  const chat = useChat();
145
158
  const { userId, videoUpload, attachmentKinds, outbox } = chat;
159
+ const attachmentSlot = props.slots?.attachment;
160
+ const cardActionTimeoutMs = props.cardActionTimeoutMs;
146
161
  // Off unless the app turns it on: an og:image fetched from the phone tells its site who reads it.
147
162
  const linkPreviewImages = (chat as { linkPreviewImages?: boolean }).linkPreviewImages === true;
148
163
  const topicsState = useTopics(threadRootId ? null : conversationId);
@@ -242,9 +257,9 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
242
257
 
243
258
  const reply = useCallback(
244
259
  (m: ChatMessage) => {
245
- setReply(replyTargetFor(m, nameOf(m.senderId), previewText(displayBody(m.body), m.attachments[0]?.kind, Boolean(m.deletedAt))));
260
+ setReply(replyTargetFor(m, nameOf(m.senderId), attachmentPreview(displayBody(m.body), m.attachments[0], attachmentKinds, Boolean(m.deletedAt))));
246
261
  },
247
- [setReply, nameOf],
262
+ [setReply, nameOf, attachmentKinds],
248
263
  );
249
264
 
250
265
  const onAction = useCallback(
@@ -355,7 +370,7 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
355
370
  },
356
371
  onLanded: (id) => {
357
372
  const m = byId.get(id);
358
- if (m) AccessibilityInfo.announceForAccessibility?.(fill(copy.jumpedTo, { name: nameOf(m.senderId), text: previewText(displayBody(m.body), m.attachments[0]?.kind, Boolean(m.deletedAt)) }));
373
+ if (m) AccessibilityInfo.announceForAccessibility?.(fill(copy.jumpedTo, { name: nameOf(m.senderId), text: attachmentPreview(displayBody(m.body), m.attachments[0], attachmentKinds, Boolean(m.deletedAt)) }));
359
374
  },
360
375
  onGone: () => {
361
376
  toast.show({ title: copy.messageGone });
@@ -395,11 +410,13 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
395
410
  linkPreviewImages={linkPreviewImages}
396
411
  attachmentKinds={attachmentKinds}
397
412
  onOpenAttachment={onOpenAttachment}
413
+ attachmentSlot={attachmentSlot}
414
+ cardActionTimeoutMs={cardActionTimeoutMs}
398
415
  testID={`${testID}-message-${m?.id ?? row.key}`}
399
416
  />
400
417
  );
401
418
  },
402
- [testID, copy.newMessages, userId, urls, byId, group, reducedMotion, onLongPress, allowed, reply, onSwipeReply, onRetry, onDiscard, react, onOpenImage, picker, reattachable, onReattach, previews, linkPreviewImages, attachmentKinds, onOpenAttachment, observer, highlightedId],
419
+ [testID, copy.newMessages, userId, urls, byId, group, reducedMotion, onLongPress, allowed, reply, onSwipeReply, onRetry, onDiscard, react, onOpenImage, picker, reattachable, onReattach, previews, linkPreviewImages, attachmentKinds, onOpenAttachment, attachmentSlot, cardActionTimeoutMs, observer, highlightedId],
403
420
  );
404
421
 
405
422
  // ---- Video messages ----
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @feat:chat
3
+ * `ChatErrorBoundary`: what one message (or one app attachment card) draws can't take the whole
4
+ * thread down. Messages carry what other people sent, and app renderers get sender-controlled
5
+ * data, so a render that throws shows its fallback instead, and the error goes to `onError`
6
+ * (console.error by default) so it is never silent. `resetKeys` lets it try again once what it
7
+ * draws changes (the app's data arrived).
8
+ */
9
+ import { Component, type ErrorInfo, type ReactNode } from "react";
10
+
11
+ export interface ChatErrorBoundaryProps {
12
+ /** Drawn instead of the children once they throw. */
13
+ fallback: ReactNode;
14
+ /** Told about the error; console.error by default. */
15
+ onError?: (error: unknown, info: ErrorInfo) => void;
16
+ /** When any of these changes (by identity), a boundary showing its fallback tries the children again. */
17
+ resetKeys?: readonly unknown[];
18
+ children: ReactNode;
19
+ }
20
+
21
+ function changed(a: readonly unknown[] | undefined, b: readonly unknown[] | undefined): boolean {
22
+ if (a === b) return false;
23
+ if (!a || !b || a.length !== b.length) return true;
24
+ return a.some((v, i) => !Object.is(v, b[i]));
25
+ }
26
+
27
+ export class ChatErrorBoundary extends Component<ChatErrorBoundaryProps, { failed: boolean }> {
28
+ override state = { failed: false };
29
+
30
+ static getDerivedStateFromError(): { failed: boolean } {
31
+ return { failed: true };
32
+ }
33
+
34
+ override componentDidUpdate(prev: ChatErrorBoundaryProps) {
35
+ if (this.state.failed && changed(prev.resetKeys, this.props.resetKeys)) this.setState({ failed: false });
36
+ }
37
+
38
+ override componentDidCatch(error: unknown, info: ErrorInfo) {
39
+ if (this.props.onError) this.props.onError(error, info);
40
+ else console.error("genesis-chat: a message couldn't be drawn", error);
41
+ }
42
+
43
+ override render() {
44
+ return this.state.failed ? this.props.fallback : this.props.children;
45
+ }
46
+ }
@@ -130,8 +130,12 @@ export interface ChatCardContext {
130
130
  export interface ChatAttachmentKind {
131
131
  title(attachment: ChatAttachment, ctx?: ChatCardContext): string;
132
132
  subtitle?(attachment: ChatAttachment, ctx?: ChatCardContext): string | null | undefined;
133
- /** The card's button: a link (`href`) or a handler (`onPress`). */
134
- action?(attachment: ChatAttachment, ctx?: ChatCardContext): { label: string; href?: string; onPress?: () => void } | null | undefined;
133
+ /**
134
+ * The card's button: a link (`href`) or a handler (`onPress`). On native the button is also the
135
+ * bubble's VoiceOver action, and a handler that returns a promise shows the card as busy until
136
+ * it settles (and a short error if it rejects).
137
+ */
138
+ action?(attachment: ChatAttachment, ctx?: ChatCardContext): { label: string; href?: string; onPress?: () => void | Promise<unknown> } | null | undefined;
135
139
  /** What previews call it (the inbox's last message, "Check-in"); the title by default. */
136
140
  label?: string;
137
141
  }
package/src/web/cards.tsx CHANGED
@@ -96,7 +96,7 @@ export interface ChatAttachmentCardProps {
96
96
  /** An icon component (lucide); a clipboard by default. */
97
97
  icon?: ComponentType<{ className?: string }>;
98
98
  /** One button: a link (`href`, safe schemes only) or a handler. */
99
- action?: { label: string; href?: string; onPress?: () => void } | null;
99
+ action?: { label: string; href?: string; onPress?: () => void | Promise<unknown> } | null;
100
100
  tone?: "default" | "primary";
101
101
  className?: string;
102
102
  }
@@ -126,7 +126,7 @@ export function ChatAttachmentCard({ title, subtitle, icon: Icon = ClipboardList
126
126
  </a>
127
127
  </Button>
128
128
  ) : (
129
- <Button type="button" size="sm" variant={primary ? "secondary" : "default"} className="shrink-0" onClick={action.onPress}>
129
+ <Button type="button" size="sm" variant={primary ? "secondary" : "default"} className="shrink-0" onClick={() => void Promise.resolve(action.onPress?.()).catch((e) => console.error("genesis-chat: a card's action failed", e))}>
130
130
  {action.label}
131
131
  </Button>
132
132
  )
@@ -1,33 +1,5 @@
1
1
  /**
2
2
  * @feat:chat
3
- * `ChatErrorBoundary`: what one message (or one app attachment card) draws can't take the whole
4
- * thread down. Messages carry what other people sent, and app renderers get sender-controlled
5
- * data, so a render that throws shows its fallback instead, and the error goes to `onError`
6
- * (console.error by default) so it is never silent.
3
+ * `ChatErrorBoundary` lives in `/react` (no DOM), so the native renderer shares it.
7
4
  */
8
- import { Component, type ErrorInfo, type ReactNode } from "react";
9
-
10
- export interface ChatErrorBoundaryProps {
11
- /** Drawn instead of the children once they throw. */
12
- fallback: ReactNode;
13
- /** Told about the error; console.error by default. */
14
- onError?: (error: unknown, info: ErrorInfo) => void;
15
- children: ReactNode;
16
- }
17
-
18
- export class ChatErrorBoundary extends Component<ChatErrorBoundaryProps, { failed: boolean }> {
19
- override state = { failed: false };
20
-
21
- static getDerivedStateFromError(): { failed: boolean } {
22
- return { failed: true };
23
- }
24
-
25
- override componentDidCatch(error: unknown, info: ErrorInfo) {
26
- if (this.props.onError) this.props.onError(error, info);
27
- else console.error("genesis-chat: a message couldn't be drawn", error);
28
- }
29
-
30
- override render() {
31
- return this.state.failed ? this.props.fallback : this.props.children;
32
- }
33
- }
5
+ export { ChatErrorBoundary, type ChatErrorBoundaryProps } from "../react/error-boundary";
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/react/previews.ts"],"names":[],"mappings":";;;AAWO,IAAM,kBAAA,GAAqB,CAA4C,IAAA,EAAsC,GAAA,EAAQ,CAAA,EAAmB,GAAA,KAC5I,IAAA,GAAO,GAAG,CAAA,EAAoG,IAAA,CAAK,IAAA,EAAO,CAAA,EAAG,GAAG;AAK5H,SAAS,UAAU,IAAA,EAAsC,CAAA,EAAmB,MAAuB,EAAE,IAAA,EAAM,OAAM,EAAW;AACjI,EAAA,IAAI;AACF,IAAA,OAAO,kBAAA,CAAmB,MAAM,OAAA,EAAS,CAAA,EAAG,GAAG,CAAA,IAAK,CAAA,CAAE,QAAQ,CAAA,CAAE,IAAA;AAAA,EAClE,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,CAAA,CAAE,QAAQ,CAAA,CAAE,IAAA;AAAA,EACrB;AACF;AAGA,SAAS,eAAe,IAAA,EAA8B;AACpD,EAAA,OAAO,EAAE,IAAI,EAAA,EAAI,SAAA,EAAW,IAAI,QAAA,EAAU,CAAA,EAAG,MAAM,GAAA,EAAK,IAAI,QAAA,EAAU,MAAA,EAAQ,MAAM,IAAA,EAAM,UAAA,EAAY,MAAM,UAAA,EAAY,IAAA,EAAM,KAAK,IAAA,EAAM,MAAA,EAAQ,SAAS,IAAA,EAAM,IAAA,EAAM,MAAM,IAAA,EAAM,KAAA,EAAO,MAAM,KAAA,EAAO,IAAA,EAAM,QAAQ,IAAA,EAAM,UAAA,EAAY,MAAM,QAAA,EAAU,IAAA,EAAM,YAAY,IAAA,EAAM,SAAA,EAAW,MAAM,UAAA,EAAY,IAAA,EAAM,kBAAkB,IAAA,EAAK;AAC1U;AAOO,SAAS,iBAAA,CAAkB,IAAA,EAAc,UAAA,EAAwD,KAAA,EAAuD,UAAU,KAAA,EAAe;AACtL,EAAA,MAAM,IAAA,GAAO,OAAO,UAAA,KAAe,QAAA,GAAW,aAAa,UAAA,EAAY,IAAA;AACvE,EAAA,MAAM,IAAA,GAAO,WAAA,CAAY,IAAA,EAAM,IAAA,EAAM,OAAO,CAAA;AAE5C,EAAA,IAAI,WAAW,CAAC,IAAA,IAAQ,CAAC,KAAA,IAAS,CAAC,MAAA,CAAO,SAAA,CAAU,cAAA,CAAe,IAAA,CAAK,OAAO,IAAI,CAAA,IAAK,YAAY,IAAA,EAAM,IAAI,GAAG,OAAO,IAAA;AACxH,EAAA,MAAM,IAAI,OAAO,UAAA,KAAe,QAAA,GAAW,cAAA,CAAe,UAAU,CAAA,GAAI,UAAA;AACxE,EAAA,MAAM,CAAA,GAAI,MAAM,IAAI,CAAA;AACpB,EAAA,MAAM,KAAA,GAAQ,OAAO,CAAA,EAAG,KAAA,KAAU,YAAY,CAAA,CAAE,KAAA,CAAM,IAAA,EAAK,GAAI,EAAE,KAAA,CAAM,IAAA,EAAK,GAAI,SAAA,CAAU,GAAG,CAAC,CAAA;AAC9F,EAAA,OAAO,KAAA,IAAS,KAAA,KAAU,IAAA,GAAO,KAAA,GAAQ,IAAA;AAC3C;AAGO,SAAS,cAAA,CAAe,SAAsB,KAAA,EAA+D;AAClH,EAAA,OAAO,iBAAA,CAAkB,OAAA,CAAQ,IAAA,EAAM,OAAA,CAAQ,WAAA,CAAY,CAAC,CAAA,EAAG,KAAA,EAAO,OAAA,CAAQ,OAAA,CAAQ,SAAS,CAAC,CAAA;AAClG","file":"chunk-AZS3HNLM.js","sourcesContent":["/**\n * @feat:chat\n * One-line previews of a message (the inbox, the long-press sheet) where an app attachment kind\n * reads as its `label`, else its title (\"Check-in\"), not \"Attachment\". No React or DOM: web and\n * native both draw from it.\n */\nimport { previewText } from \"../core/thread\";\nimport type { ChatAttachment, ChatMessage } from \"../core/types\";\nimport type { ChatAttachmentKind, ChatCardContext } from \"./index\";\n\n/** Calls a kind's `title`, `subtitle` or `action` with the card context. */\nexport const callAttachmentKind = <K extends \"title\" | \"subtitle\" | \"action\">(kind: ChatAttachmentKind | undefined, key: K, a: ChatAttachment, ctx: ChatCardContext) =>\n (kind?.[key] as ((this: ChatAttachmentKind, a: ChatAttachment, ctx: ChatCardContext) => unknown) | undefined)?.call(kind!, a, ctx) as\n | ReturnType<NonNullable<ChatAttachmentKind[K]>>\n | undefined;\n\n/** A card's title (the kind's, else the file's name, else the kind); an app kind that throws reads as its name. */\nexport function cardTitle(kind: ChatAttachmentKind | undefined, a: ChatAttachment, ctx: ChatCardContext = { mine: false }): string {\n try {\n return callAttachmentKind(kind, \"title\", a, ctx) || a.name || a.kind;\n } catch {\n return a.name || a.kind;\n }\n}\n\n/** An attachment with only its kind, for a preview that knows no more (the inbox's last message). */\nfunction bareAttachment(kind: string): ChatAttachment {\n return { id: \"\", messageId: \"\", position: 0, kind, ref: {}, provider: \"none\", path: null, providerId: null, playbackId: null, url: null, status: \"ready\", name: null, mime: null, bytes: null, width: null, height: null, durationMs: null, waveform: null, posterPath: null, thumbhash: null, transcript: null, transcriptStatus: null };\n}\n\n/**\n * A message's one-line preview: its text, else what it carries, where an app kind reads as its\n * title (\"Check-in\") instead of \"Attachment\". `attachment` is the kind alone (the inbox) or the\n * attachment itself.\n */\nexport function attachmentPreview(body: string, attachment: string | ChatAttachment | null | undefined, kinds: Record<string, ChatAttachmentKind> | undefined, deleted = false): string {\n const kind = typeof attachment === \"string\" ? attachment : attachment?.kind;\n const text = previewText(body, kind, deleted);\n // Text that reads as something (not only markdown marks) is the preview, as previewText has it.\n if (deleted || !kind || !kinds || !Object.prototype.hasOwnProperty.call(kinds, kind) || previewText(body, null)) return text;\n const a = typeof attachment === \"string\" ? bareAttachment(attachment) : attachment!;\n const k = kinds[kind];\n const title = typeof k?.label === \"string\" && k.label.trim() ? k.label.trim() : cardTitle(k, a);\n return title && title !== kind ? title : text;\n}\n\n/** The long-press sheet's caption for a message. */\nexport function messagePreview(message: ChatMessage, kinds: Record<string, ChatAttachmentKind> | undefined): string {\n return attachmentPreview(message.body, message.attachments[0], kinds, Boolean(message.deletedAt));\n}\n\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/native/push.ts"],"names":[],"mappings":"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","file":"chunk-VEPZROQB.js","sourcesContent":["/**\n * @feat:chat\n * Push glue for genesis-native's push block, data only: the `messages`\n * category (a switch in Notification settings and an Android channel), its\n * notification actions (Reply with a text field, Mark read), what a tap opens\n * (`data.url` = `/messages/<conversationId>`), the reply and mark-read\n * handlers for the block's `onResponse`, and the match that keeps a thread's\n * own pushes out of the foreground while it is open (the block's `suppress`).\n *\n * const push = createPush({ …, categories: [...yours, chatPushCategory()],\n * onResponse: (r) => void handleChatPushResponse(r, { client: chatClient, notifications: Notifications }) });\n * void registerChatNotificationActions(Notifications); // once, at start\n * useSuppressNotifications(chatThreadMatch(conversationId)); // in the thread screen\n *\n * The sender sets `categoryId: \"messages\"` (iOS) / the `messages` channel\n * (Android) and `data: { url: \"/messages/<id>\", conversationId, seq, messageId }`;\n * `chatPushPayload` builds those and the fields iOS rich push reads (the\n * sender's name and photo, the group, a photo), and it is server safe: import\n * it from `@marktiderman/genesis-chat/push`.\n *\n * A reply goes through the outbox, so a phone that is offline keeps it: pass\n * the live one (`useChat().outbox`) when the chat screens are mounted, and the\n * persisted store and user id (`ChatProvider outboxStore`) for when they\n * aren't. A reply that can't be sent then waits there, failed, with Retry in\n * its thread.\n */\nimport type { ChatClient } from \"../core/client\";\nimport type { Outbox, OutboxStore, QueuedMessage } from \"../core/outbox\";\n\nexport const CHAT_PUSH_CATEGORY = \"messages\";\nexport const CHAT_REPLY_ACTION = \"reply\";\nexport const CHAT_MARK_READ_ACTION = \"mark-read\";\n\nexport interface ChatPushCopy {\n title: string;\n description: string;\n reply: string;\n replySubmit: string;\n replyPlaceholder: string;\n markRead: string;\n}\n\nexport const chatPushCopy: ChatPushCopy = {\n title: \"Messages\",\n description: \"New messages in your conversations.\",\n reply: \"Reply\",\n replySubmit: \"Send\",\n replyPlaceholder: \"Message\",\n markRead: \"Mark read\",\n};\n\n/** The path a message's push opens. */\nexport function chatPath(conversationId: string): string {\n return `/messages/${conversationId}`;\n}\n\n/** The category for createPush's `categories` (genesis-native's NotificationCategory). */\nexport function chatPushCategory(copy: Partial<ChatPushCopy> = {}) {\n const c = { ...chatPushCopy, ...copy };\n return { id: CHAT_PUSH_CATEGORY, title: c.title, description: c.description, importance: \"high\" as const };\n}\n\n/** expo-notifications' `setNotificationCategoryAsync`, the one call this needs. */\nexport interface NotificationCategoriesLike {\n setNotificationCategoryAsync(\n identifier: string,\n actions: {\n identifier: string;\n buttonTitle: string;\n textInput?: { submitButtonTitle: string; placeholder: string };\n options?: { opensAppToForeground?: boolean; isDestructive?: boolean; isAuthenticationRequired?: boolean };\n }[],\n options?: Record<string, unknown>,\n ): Promise<unknown>;\n dismissNotificationAsync?(identifier: string): Promise<void>;\n}\n\n/** The actions on a message's notification: Reply (a text field) and Mark read, neither opening the app. */\nexport function chatNotificationActions(copy: Partial<ChatPushCopy> = {}) {\n const c = { ...chatPushCopy, ...copy };\n return [\n {\n identifier: CHAT_REPLY_ACTION,\n buttonTitle: c.reply,\n textInput: { submitButtonTitle: c.replySubmit, placeholder: c.replyPlaceholder },\n options: { opensAppToForeground: false },\n },\n { identifier: CHAT_MARK_READ_ACTION, buttonTitle: c.markRead, options: { opensAppToForeground: false } },\n ];\n}\n\n/** Registers the actions with the OS (once, at start; it is idempotent). */\nexport function registerChatNotificationActions(\n notifications: NotificationCategoriesLike,\n copy?: Partial<ChatPushCopy>,\n onError?: (error: unknown) => void,\n): Promise<unknown> {\n // Never throws, at module load least of all: a module without categories (web, a test's mock) is reported.\n try {\n if (typeof notifications?.setNotificationCategoryAsync !== \"function\") {\n onError?.(new Error(\"chat push: setNotificationCategoryAsync is missing, so Reply and Mark read aren't on message notifications\"));\n return Promise.resolve(undefined);\n }\n return Promise.resolve(notifications.setNotificationCategoryAsync(CHAT_PUSH_CATEGORY, chatNotificationActions(copy))).catch((error: unknown) => {\n onError?.(error);\n });\n } catch (error) {\n onError?.(error);\n return Promise.resolve(undefined);\n }\n}\n\nconst UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** The conversation a push is about: `data.conversationId`, else the id in `data.url`. */\nexport function conversationIdFromData(data: Record<string, unknown> | null | undefined): string | null {\n if (!data) return null;\n const direct = typeof data.conversationId === \"string\" ? data.conversationId : typeof data.conversation_id === \"string\" ? data.conversation_id : null;\n if (direct && UUID.test(direct)) return direct.toLowerCase();\n const url = typeof data.url === \"string\" ? data.url : null;\n const m = url ? /^\\/messages\\/([0-9a-f-]{36})(?:[/?#]|$)/i.exec(url) : null;\n return m && UUID.test(m[1]!) ? m[1]!.toLowerCase() : null;\n}\n\n/**\n * The client id a reply from this notification sends with: the notification's\n * own id, as a uuid (it already is one on iOS; anything else is hashed into\n * one), so the same reply delivered twice posts once.\n */\nexport function replyClientId(notificationId: string): string {\n if (UUID.test(notificationId)) return notificationId.toLowerCase();\n // Four 32-bit FNV-1a hashes with different seeds: 128 stable bits.\n const words = [0x811c9dc5, 0x01000193, 0x050c5d1f, 0x6f4f2a3b].map((seed) => {\n let h = seed >>> 0;\n for (let i = 0; i < notificationId.length; i++) {\n h ^= notificationId.charCodeAt(i);\n h = Math.imul(h, 0x01000193) >>> 0;\n }\n return h.toString(16).padStart(8, \"0\");\n });\n const hex = words.join(\"\");\n // Version 8 (custom) and the RFC 4122 variant, so it is a valid uuid.\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-8${hex.slice(13, 16)}-${((parseInt(hex[16]!, 16) & 0x3) | 0x8).toString(16)}${hex.slice(17, 20)}-${hex.slice(20, 32)}`;\n}\n\n/**\n * The client id for one reply: from the notification's id, the message it is\n * about (`data.messageId`) and the reply's text. The same reply delivered\n * twice posts once; two replies to one conversation never share an id, even\n * when iOS reuses the notification's id for a collapsed push.\n */\nexport function replyClientIdFor(notificationId: string, data: Record<string, unknown> | null | undefined, body: string): string {\n const messageId = typeof data?.messageId === \"string\" ? data.messageId : typeof data?.message_id === \"string\" ? data.message_id : \"\";\n return replyClientId(`${notificationId}|m:${messageId.toLowerCase()}|t:${body}`);\n}\n\n/** The part of a notification response this reads (expo-notifications' `NotificationResponse`). */\nexport interface ChatPushResponseLike {\n actionIdentifier: string;\n /** The text typed into Reply. */\n userText?: string;\n notification: { request: { identifier: string; content: { data?: Record<string, unknown> | null } } };\n}\n\n/** \"queued\": the reply couldn't go now and waits in the outbox, failed, for Retry. */\nexport type ChatPushOutcome = \"replied\" | \"queued\" | \"marked-read\" | \"ignored\" | \"failed\";\n\ntype LiveOutbox = Pick<Outbox, \"send\" | \"pending\"> & Partial<Pick<Outbox, \"load\">>;\n\n/** How long a reply queued in the live outbox is waited for before it counts as queued, not sent. */\nexport const PUSH_REPLY_WAIT_MS = 15_000;\n\n/** Where a reply waits when it can't go now. */\nexport interface ChatPushOutboxDeps {\n /** The live outbox (`useChat().outbox`), or a getter for it (read again after a slow failure): the reply is queued there and sent in order. */\n outbox?: LiveOutbox | null | (() => LiveOutbox | null | undefined);\n /** Without a live outbox: the persisted store (`ChatProvider outboxStore`) a reply that failed is added to. */\n store?: OutboxStore | null;\n /** Whose store it is (the signed-in user's id). */\n userId?: string | null;\n}\n\n/** A reply that couldn't be sent, as the outbox keeps it: shown failed in its thread, with Retry. */\nexport function failedReply(input: { conversationId: string; clientId: string; body: string }, userId: string, error = \"Not sent. Check your connection.\"): QueuedMessage {\n return {\n clientId: input.clientId,\n input: { conversationId: input.conversationId, clientId: input.clientId, body: input.body },\n senderId: userId,\n createdAt: new Date().toISOString(),\n status: \"failed\",\n error,\n previews: {},\n files: [],\n pendingFiles: [],\n attempts: 1,\n };\n}\n\n/**\n * Handles Reply and Mark read from a message's notification (pass it as the\n * push block's `onResponse`). Taps on the notification itself are the block's\n * routing, not this. Never throws: a failure is the outcome \"failed\" (and\n * `onError`).\n */\nexport async function handleChatPushResponse(\n response: ChatPushResponseLike,\n deps: {\n client: Pick<ChatClient, \"send\" | \"markRead\">;\n notifications?: Pick<NotificationCategoriesLike, \"dismissNotificationAsync\">;\n onError?: (error: unknown) => void;\n /** How long to wait for a reply queued in the live outbox to go. Default PUSH_REPLY_WAIT_MS. */\n waitMs?: number;\n } & ChatPushOutboxDeps,\n): Promise<ChatPushOutcome> {\n const action = response.actionIdentifier;\n if (action !== CHAT_REPLY_ACTION && action !== CHAT_MARK_READ_ACTION) return \"ignored\";\n const data = response.notification.request.content.data ?? {};\n const conversationId = conversationIdFromData(data);\n if (!conversationId) return \"ignored\";\n const notificationId = response.notification.request.identifier;\n try {\n let outcome: ChatPushOutcome;\n if (action === CHAT_REPLY_ACTION) {\n const body = (response.userText ?? \"\").trim();\n if (!body) return \"ignored\";\n const input = { conversationId, clientId: replyClientIdFor(notificationId, data, body), body };\n outcome = await sendReply(input, deps);\n // A reply reads the conversation too, as answering in the app would.\n if (outcome === \"replied\") await deps.client.markRead(conversationId).catch(() => undefined);\n } else {\n const seq = typeof data.seq === \"number\" ? data.seq : typeof data.seq === \"string\" ? Number(data.seq) : undefined;\n await deps.client.markRead(conversationId, Number.isFinite(seq) ? seq : undefined);\n outcome = \"marked-read\";\n }\n await deps.notifications?.dismissNotificationAsync?.(notificationId).catch(() => undefined);\n return outcome;\n } catch (error) {\n deps.onError?.(error);\n return \"failed\";\n }\n}\n\nasync function sendReply(\n input: { conversationId: string; clientId: string; body: string },\n deps: { client: Pick<ChatClient, \"send\">; onError?: (error: unknown) => void; waitMs?: number } & ChatPushOutboxDeps,\n): Promise<ChatPushOutcome> {\n const liveOutbox = () => (typeof deps.outbox === \"function\" ? deps.outbox() : deps.outbox) ?? null;\n const live = liveOutbox();\n if (live) return queueLive(live, input, deps.waitMs);\n try {\n await deps.client.send(input);\n return \"replied\";\n } catch (error) {\n // The chat screens may have opened while the send failed: their outbox owns the saved copy now.\n const opened = liveOutbox();\n if (opened) return queueLive(opened, input, 0);\n if (!deps.store || !deps.userId) throw error;\n deps.onError?.(error);\n const queue = await deps.store.load();\n if (!queue.some((q) => q.clientId === input.clientId)) await deps.store.save([...queue, failedReply(input, deps.userId)]);\n return \"queued\";\n }\n}\n\n/**\n * Queues the reply in the live outbox (once) and waits, briefly, for it to go: \"replied\" once it\n * has left the queue, \"queued\" while it is still there or failed (it waits there for Retry).\n */\nasync function queueLive(live: LiveOutbox, input: { conversationId: string; clientId: string; body: string }, waitMs = PUSH_REPLY_WAIT_MS): Promise<ChatPushOutcome> {\n await live.load?.();\n const mine = () => live.pending(input.conversationId).find((p) => p.clientId === input.clientId);\n if (!mine()) await live.send(input);\n const until = Date.now() + waitMs;\n for (;;) {\n const p = mine();\n if (!p) return \"replied\";\n if (p.status === \"failed\" || Date.now() >= until) return \"queued\";\n await new Promise((r) => setTimeout(r, 100));\n }\n}\n\n/** For the push block's `suppress` / `useSuppressNotifications`: this conversation's pushes, while its thread is open. */\nexport function chatThreadMatch(conversationId: string | null | undefined): ((data: Record<string, unknown>) => boolean) | null {\n if (!conversationId) return null;\n const id = conversationId.toLowerCase();\n return (data) => conversationIdFromData(data) === id;\n}\n\n/** What a message's push says about itself, for `chatPushPayload`. */\nexport interface ChatPushPayloadInput {\n conversationId: string;\n messageId: string;\n seq?: number | null | undefined;\n /** Who sent it: an opaque id (never an email or phone), the name to show and an https photo. */\n sender?: { id: string; name: string; avatarUrl?: string | null | undefined } | null | undefined;\n /** The conversation's name when it is a group; leave it out for a direct conversation. */\n groupName?: string | null | undefined;\n /** An https photo in the message, attached to the notification. */\n imageUrl?: string | null | undefined;\n /**\n * The recipient hides previews: the push carries the link and ids only (no\n * sender, group or photo) and the service extension doesn't run. Send neutral\n * title and body text with it.\n */\n hidePreviews?: boolean | undefined;\n}\n\n/** The `data` of a message's push. Flat strings and numbers, as genesis-notifications' `PushMessage.data` takes. */\nexport interface ChatPushData {\n [key: string]: string | number;\n url: string;\n conversationId: string;\n messageId: string;\n}\n\n/** The fields to spread into an Expo push message (with `to`, `title`, `body`, `badge`, `channelId`). */\nexport interface ChatPushPayload {\n categoryId: typeof CHAT_PUSH_CATEGORY;\n /** Groups the conversation's notifications, and is the conversation iOS files a Communication Notification under. */\n threadId: string;\n /** iOS runs genesis-native's Notification Service Extension only when this is true (`mutable-content: 1`). */\n mutableContent: boolean;\n data: ChatPushData;\n}\n\n/** The extension fetches only https URLs (no credentials, at most 1024 characters): genesis-native's `isRichPushUrl`. */\nfunction httpsUrl(value: unknown): string | null {\n if (typeof value !== \"string\" || value.length > 1024) return null;\n try {\n const url = new URL(value);\n return url.protocol === \"https:\" && url.hostname.length > 0 && !url.username && !url.password ? value : null;\n } catch {\n return null;\n }\n}\n\nfunction shortText(value: unknown): string | null {\n if (typeof value !== \"string\") return null;\n const t = value.trim();\n return t ? t.slice(0, 200) : null;\n}\n\n/**\n * The category, thread, `mutableContent` and `data` of a message's push, for\n * the server that sends it:\n *\n * const message = { to, title, body, badge, channelId: \"messages\", ...chatPushPayload({ conversationId, messageId, seq, sender, groupName, imageUrl, hidePreviews }) };\n *\n * On iOS, with the rich push plugin, a push with a sender is a Communication\n * Notification (the sender's photo; Focus treats it as a message) and `imageUrl`\n * is attached. Fields that aren't usable (a URL that isn't https, an empty\n * name) are left out rather than sent.\n */\nexport function chatPushPayload(input: ChatPushPayloadInput): ChatPushPayload {\n const conversationId = input.conversationId.toLowerCase();\n if (!UUID.test(conversationId)) throw new Error(\"chatPushPayload: conversationId must be a uuid\");\n const data: ChatPushData = { url: chatPath(conversationId), conversationId, messageId: input.messageId };\n if (typeof input.seq === \"number\" && Number.isFinite(input.seq)) data.seq = input.seq;\n if (input.hidePreviews) {\n return { categoryId: CHAT_PUSH_CATEGORY, threadId: conversationId, mutableContent: false, data };\n }\n const senderName = shortText(input.sender?.name);\n const senderId = shortText(input.sender?.id);\n if (senderName && senderId) {\n data.senderId = senderId;\n data.senderName = senderName;\n const avatar = httpsUrl(input.sender?.avatarUrl);\n if (avatar) data.senderAvatar = avatar;\n }\n const groupName = shortText(input.groupName);\n if (groupName) data.groupName = groupName;\n const image = httpsUrl(input.imageUrl);\n if (image) data.image = image;\n return { categoryId: CHAT_PUSH_CATEGORY, threadId: conversationId, mutableContent: true, data };\n}\n"]}