@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
package/README.md CHANGED
@@ -9,9 +9,9 @@ replies, threads, reactions, read state, live updates and push.
9
9
  | `@marktiderman/genesis-chat` | The headless core: types, `createChatClient`, the send outbox, thread logic, media rules. No React. |
10
10
  | `/react` | Hooks for web and native: `ChatProvider`, `useInbox`, `useConversation`, `useMessageActions`, and for 0002 `useTopics`, `useScheduled`, `useAvailability`, `useBroadcast`, `useSearch`, `useLinkPreviews`. |
11
11
  | `/web` | Renderers on genesis-ui: inbox, thread, composer (text, photos, voice, video, emoji, scheduled send), media, topics, search, availability, broadcast. |
12
- | `/native` | Coming in the next release: renderers on genesis-ui-native (`InboxPage` and `ThreadPage` wired to chat, voice notes with expo-audio). Empty in this version. |
12
+ | `/native` | Beta: screens and parts on genesis-ui-native (inbox, thread, search, availability), voice notes, photos and video, and the push block's glue. See "On native" below. |
13
13
  | `/server` | `createChatRunner`: works the outbox (push, transcripts, storage cleanup). |
14
- | `migrations/0001_genesis_chat.sql`, `0002_genesis_chat_coaching.sql` | The schema, copied into your app by `genesis add chat`, each with its pgTAP test. |
14
+ | `migrations/0001_genesis_chat.sql`, `0002_genesis_chat_coaching.sql`, `0003_genesis_chat_search_threads.sql` | The schema, copied into your app by `genesis add chat` in order, each with its pgTAP test. 0003 makes `search_messages` return a hit's `thread_root_id`. |
15
15
 
16
16
  ## On the web
17
17
 
@@ -38,6 +38,118 @@ for your own attachment kinds, `composerAccessory`). Mount a Genesis `<Toaster /
38
38
  (`@source "../node_modules/@marktiderman/genesis-chat/dist"`). `createMemoryChatClient` runs it
39
39
  all with no backend, for demos, stories and tests.
40
40
 
41
+ ## On native
42
+
43
+ `@marktiderman/genesis-chat/native` (Beta) draws chat on genesis-ui-native, over the same
44
+ `/react` hooks. Mount `ChatProvider` (from `/react`) and `ChatNativeProvider` (the native modules,
45
+ copy and people) inside `GenesisNativeApp`, then put the screens in your routes:
46
+
47
+ | Screen | What it is | Main props |
48
+ |---|---|---|
49
+ | `ChatInboxScreen` | The conversations on `InboxPage`: avatars, unread counts, the last message, pull to refresh, Mark read, Pin and Archive. | `onOpen(conversationId)`, `onStart`, `kind` / `kinds` / `excludeKinds`, `archived`, `pageProps` |
50
+ | `ChatThreadScreen` | One conversation: grouped bubbles, day lines, replies, reactions, the composer (text, photos, voice notes, video, emoji, scheduled send), topics and the away line. | `conversationId`, `threadRootId`, `jumpTo` (open at a search result), `topic` / `showTopics`, `scheduling`, `availability`, `onOpenAttachment`, `focused` (the navigator's `useIsFocused()`), `keyboardVerticalOffset` |
51
+ | `ChatSearchScreen` | Search across your conversations or in one, with matches highlighted and transcripts marked. | `onOpen(hit)`, `conversationId`, `conversationTitle` |
52
+ | `ChatAvailabilityScreen` | Your away message, its end, working hours and time zone. | `onSaved` |
53
+
54
+ `ChatNativeProvider` takes `modules` (expo-audio, expo-image-picker, expo-haptics, expo-clipboard,
55
+ expo-camera, expo-video, expo-web-browser and the rest are passed in, never imported), `copy`
56
+ overrides and `getPerson(userId)` for names and avatars. `useChatThreadStackOptions` gives the
57
+ thread's header options.
58
+
59
+ The parts (`ChatComposer`, `MessageBubble`, `MessageActionsSheet`, `VoiceNotePlayer`,
60
+ `ChatPhotoViewer`, `ChatEmojiPicker`, `ChatStartSheet`, `ChatBroadcastSheet`) build your own
61
+ screen, and the logic under them (`composerReducer`, `buildThreadRows`, `useChatComposer`,
62
+ `useVoiceRecorder`) is exported too. Glue: `createTusUpload` (resumable uploads for
63
+ `ChatProvider upload`), and for genesis-native's push block the `messages` category with Reply and
64
+ Mark read (`registerChatNotificationActions`, `handleChatPushResponse`, `chatThreadMatch`, also on
65
+ `/push` alone).
66
+
67
+ Voice notes show their transcript under the player once the server has made one ("Transcribing…"
68
+ until then), folded to three lines with "Show more"; the bubble reads the whole transcript to
69
+ VoiceOver.
70
+
71
+ ### Your own attachment kinds on native
72
+
73
+ A kind registered in `ChatProvider attachmentKinds` draws as `ChatAttachmentCard` (its title,
74
+ subtitle and one button). For more than a card, pass `ChatThreadScreen slots={{ attachment }}`
75
+ (`MessageBubble attachmentSlot` in your own screen): the native twin of the web `attachment` slot.
76
+ It draws every kind the package doesn't draw itself (anything but image, voice, video, file and
77
+ link), registered or not; `null` hides the kinds that aren't registered. Its props
78
+ (`ChatAttachmentSlotProps`):
79
+
80
+ | Prop | What it is |
81
+ |---|---|
82
+ | `attachment`, `message`, `url`, `mine` | As on the web: the attachment (`ref` holds what the sender put there), its message, its signed URL when it has a file, and whether you sent it. `url` is http(s) only (null for any other scheme), since the sender controls it. |
83
+ | `kind` | Its `attachmentKinds` entry, when registered. |
84
+ | `action` | The kind's `action`, ready to call (`{ label, run }`), or null. |
85
+ | `run(task)` | Runs an app action. A task that returns a promise makes the card `pending` until it settles; if it throws or rejects, `error` holds a short message (copy `cardActionFailed`). One still running after 30 seconds stops being busy and reads `cardActionTimedOut` (`ChatThreadScreen cardActionTimeoutMs` changes it, 0 for never). A second run while one is pending is ignored. `action.run` goes through it. |
86
+ | `pending`, `error` | That state, for drawing a busy control and the error. |
87
+ | `fallback` | The card the package would have drawn: return it for kinds the slot doesn't draw. |
88
+ | `testID` | The bubble's id for this card. |
89
+
90
+ The bubble is one VoiceOver element, so the controls inside your card are hidden from VoiceOver:
91
+ give the kind an `action` and call `action.run` from your control, and the bubble offers the same
92
+ action (named by its `label`) to VoiceOver and Switch Control. Keep tap targets at least 44 points.
93
+ A slot that throws shows the fallback card until its attachment, message, kind or slot changes,
94
+ then tries again. A slot that returns null draws nothing for that attachment, and it leaves the
95
+ bubble's label and actions; a message left with nothing to show is not drawn at all. Reply quotes, the reply bar, the long-press sheet and
96
+ the inbox's last message name the kind by its `label`. Build the kinds where the app's live data
97
+ is (a hook, memoized on it), so the title and action follow it; define the slot at module scope.
98
+
99
+ A task with a checkbox:
100
+
101
+ ```tsx
102
+ import { Pressable, View } from "react-native";
103
+ import { NativeText } from "@marktiderman/genesis-ui-native/components";
104
+ import { ChatThreadScreen, type ChatAttachmentSlotProps } from "@marktiderman/genesis-chat/native";
105
+
106
+ // In the component that mounts ChatProvider, so the kind reads the tasks' live state.
107
+ const tasks = useTasks(); // your own query: { [taskId]: { done } }
108
+ const toggle = useToggleTask(); // returns a promise
109
+ const attachmentKinds = useMemo(
110
+ () => ({
111
+ task: {
112
+ label: "Task",
113
+ title: (a) => String(a.ref.title ?? "Task"),
114
+ subtitle: (a) => (tasks[String(a.ref.taskId)]?.done ? "Done" : "To do"),
115
+ // The checkbox's action, and the bubble's VoiceOver action.
116
+ action: (a) => {
117
+ const done = Boolean(tasks[String(a.ref.taskId)]?.done);
118
+ return { label: done ? "Mark not done" : "Mark done", onPress: () => toggle(String(a.ref.taskId), !done) };
119
+ },
120
+ },
121
+ }),
122
+ [tasks, toggle],
123
+ );
124
+
125
+ function TaskCard({ attachment, action, pending, error, fallback }: ChatAttachmentSlotProps) {
126
+ const task = useTask(String(attachment.ref.taskId ?? "")); // your own hook, before any return
127
+ if (attachment.kind !== "task") return fallback;
128
+ const done = Boolean(task?.done);
129
+ return (
130
+ <View style={{ minWidth: 220, gap: 4 }}>
131
+ <Pressable
132
+ accessibilityRole="checkbox"
133
+ accessibilityState={{ checked: done, busy: pending }}
134
+ disabled={pending || !action}
135
+ onPress={action?.run}
136
+ style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 44 }}
137
+ >
138
+ <View style={{ width: 24, height: 24, borderRadius: 6, borderWidth: 2, opacity: pending ? 0.5 : 1 }}>{done ? <NativeText>✓</NativeText> : null}</View>
139
+ <NativeText>{String(attachment.ref.title)}</NativeText>
140
+ </Pressable>
141
+ {error ? <NativeText preset="caption">{error}</NativeText> : null}
142
+ </View>
143
+ );
144
+ }
145
+
146
+ <ChatThreadScreen conversationId={id} slots={{ attachment: TaskCard }} />
147
+ ```
148
+
149
+ The same slot draws a win card or a worksheet with its progress; `run` is there for actions that
150
+ aren't the kind's button (save an answer). `useCardRuns`, `AppAttachment` and `CardError` are
151
+ exported for your own bubble.
152
+
41
153
  ## Kinds and rules
42
154
 
43
155
  A kind of conversation carries its rules (`genesis_chat.register_kind`): how many members, who
@@ -238,7 +350,8 @@ pending. Persist the file (`persistFile`) so a relaunch still has its bytes.
238
350
 
239
351
  A registered kind draws as `ChatAttachmentCard`; any other app kind goes to `ChatThread`'s
240
352
  `attachment` slot (a generic card by default). For anything richer than a card, render the kind
241
- yourself in that slot:
353
+ yourself in that slot (on native the slot draws registered kinds too; see "Your own attachment
354
+ kinds on native"):
242
355
 
243
356
  ```tsx
244
357
  <ChatThread slots={{ attachment: (props) =>
@@ -338,7 +451,7 @@ each removing up to `retentionBatch`, default 1 000; never in a conversation on
338
451
  the collapse id becomes the notification's identifier, so replies from two notifications of one
339
452
  conversation would collide. Throw from `deliver` to retry. `createExpoDeliver` sends
340
453
  through Expo in requests of 100 (`threadId`, `collapseId`, `badge`, channel `messages`, category
341
- `chat-message`) and returns the tickets: forget `deadTokens(tickets)`. It throws only when
454
+ `messages`, the one `/native` registers Reply and Mark read under) and returns the tickets: forget `deadTokens(tickets)`. It throws only when
342
455
  nothing was sent. Note that `collapseId` replaces a conversation's earlier notification on iOS.
343
456
  - **transcribe**: a 10 minute signed URL to the transcriber, then `transcript_status: ready`. With no
344
457
  transcriber the status is `off`; on the last attempt a failure sets `failed`. Hosted videos get
@@ -399,8 +512,8 @@ import { CHAT_NOTIFICATION_CATEGORIES, CHAT_NOTIFICATION_TYPES, notificationsDel
399
512
 
400
513
  export const notifications = defineNotifications({
401
514
  categories: { ...CHAT_NOTIFICATION_CATEGORIES },
402
- actions: { message: MESSAGE_ACTIONS },
403
- types: { "chat.message": { ...CHAT_NOTIFICATION_TYPES["chat.message"], actions: "message" } },
515
+ actions: { messages: MESSAGE_ACTIONS },
516
+ types: { "chat.message": { ...CHAT_NOTIFICATION_TYPES["chat.message"], actions: "messages" } },
404
517
  });
405
518
  const deliver = notificationsDeliver({ notify, client: supabase, registry: notifications });
406
519
  ```
@@ -1,7 +1,8 @@
1
1
  // src/native/push.ts
2
2
  var CHAT_PUSH_CATEGORY = "messages";
3
3
  var CHAT_REPLY_ACTION = "reply";
4
- var CHAT_MARK_READ_ACTION = "mark-read";
4
+ var CHAT_MARK_READ_ACTION = "mark_read";
5
+ var LEGACY_MARK_READ_ACTION = "mark-read";
5
6
  var chatPushCopy = {
6
7
  title: "Messages",
7
8
  description: "New messages in your conversations.",
@@ -85,7 +86,7 @@ function failedReply(input, userId, error = "Not sent. Check your connection.")
85
86
  };
86
87
  }
87
88
  async function handleChatPushResponse(response, deps) {
88
- const action = response.actionIdentifier;
89
+ const action = response.actionIdentifier === LEGACY_MARK_READ_ACTION ? CHAT_MARK_READ_ACTION : response.actionIdentifier;
89
90
  if (action !== CHAT_REPLY_ACTION && action !== CHAT_MARK_READ_ACTION) return "ignored";
90
91
  const data = response.notification.request.content.data ?? {};
91
92
  const conversationId = conversationIdFromData(data);
@@ -183,5 +184,5 @@ function chatPushPayload(input) {
183
184
  }
184
185
 
185
186
  export { CHAT_MARK_READ_ACTION, CHAT_PUSH_CATEGORY, CHAT_REPLY_ACTION, PUSH_REPLY_WAIT_MS, chatNotificationActions, chatPath, chatPushCategory, chatPushCopy, chatPushPayload, chatThreadMatch, conversationIdFromData, failedReply, handleChatPushResponse, registerChatNotificationActions, replyClientId, replyClientIdFor };
186
- //# sourceMappingURL=chunk-VEPZROQB.js.map
187
- //# sourceMappingURL=chunk-VEPZROQB.js.map
187
+ //# sourceMappingURL=chunk-BTG4JWLF.js.map
188
+ //# sourceMappingURL=chunk-BTG4JWLF.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/native/push.ts"],"names":[],"mappings":"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","file":"chunk-BTG4JWLF.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\";\n/** The same id as genesis-notifications' `MESSAGE_ACTIONS` (device ids take no \"-\" or \":\"). */\nexport const CHAT_MARK_READ_ACTION = \"mark_read\";\n/** The id before 0.2.2: a notification already on a phone still carries it. */\nconst LEGACY_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 === LEGACY_MARK_READ_ACTION ? CHAT_MARK_READ_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"]}
@@ -1,4 +1,5 @@
1
1
  import { previewText } from './chunk-A4YJX7KT.js';
2
+ import { Component } from 'react';
2
3
 
3
4
  // src/react/previews.ts
4
5
  var callAttachmentKind = (kind, key, a, ctx) => kind?.[key]?.call(kind, a, ctx);
@@ -24,7 +25,28 @@ function attachmentPreview(body, attachment, kinds, deleted = false) {
24
25
  function messagePreview(message, kinds) {
25
26
  return attachmentPreview(message.body, message.attachments[0], kinds, Boolean(message.deletedAt));
26
27
  }
28
+ function changed(a, b) {
29
+ if (a === b) return false;
30
+ if (!a || !b || a.length !== b.length) return true;
31
+ return a.some((v, i) => !Object.is(v, b[i]));
32
+ }
33
+ var ChatErrorBoundary = class extends Component {
34
+ state = { failed: false };
35
+ static getDerivedStateFromError() {
36
+ return { failed: true };
37
+ }
38
+ componentDidUpdate(prev) {
39
+ if (this.state.failed && changed(prev.resetKeys, this.props.resetKeys)) this.setState({ failed: false });
40
+ }
41
+ componentDidCatch(error, info) {
42
+ if (this.props.onError) this.props.onError(error, info);
43
+ else console.error("genesis-chat: a message couldn't be drawn", error);
44
+ }
45
+ render() {
46
+ return this.state.failed ? this.props.fallback : this.props.children;
47
+ }
48
+ };
27
49
 
28
- export { attachmentPreview, callAttachmentKind, cardTitle, messagePreview };
29
- //# sourceMappingURL=chunk-AZS3HNLM.js.map
30
- //# sourceMappingURL=chunk-AZS3HNLM.js.map
50
+ export { ChatErrorBoundary, attachmentPreview, callAttachmentKind, cardTitle, messagePreview };
51
+ //# sourceMappingURL=chunk-IEFULAC2.js.map
52
+ //# sourceMappingURL=chunk-IEFULAC2.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react/previews.ts","../src/react/error-boundary.tsx"],"names":[],"mappings":"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","file":"chunk-IEFULAC2.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","/**\n * @feat:chat\n * `ChatErrorBoundary`: what one message (or one app attachment card) draws can't take the whole\n * thread down. Messages carry what other people sent, and app renderers get sender-controlled\n * data, so a render that throws shows its fallback instead, and the error goes to `onError`\n * (console.error by default) so it is never silent. `resetKeys` lets it try again once what it\n * draws changes (the app's data arrived).\n */\nimport { Component, type ErrorInfo, type ReactNode } from \"react\";\n\nexport interface ChatErrorBoundaryProps {\n /** Drawn instead of the children once they throw. */\n fallback: ReactNode;\n /** Told about the error; console.error by default. */\n onError?: (error: unknown, info: ErrorInfo) => void;\n /** When any of these changes (by identity), a boundary showing its fallback tries the children again. */\n resetKeys?: readonly unknown[];\n children: ReactNode;\n}\n\nfunction changed(a: readonly unknown[] | undefined, b: readonly unknown[] | undefined): boolean {\n if (a === b) return false;\n if (!a || !b || a.length !== b.length) return true;\n return a.some((v, i) => !Object.is(v, b[i]));\n}\n\nexport class ChatErrorBoundary extends Component<ChatErrorBoundaryProps, { failed: boolean }> {\n override state = { failed: false };\n\n static getDerivedStateFromError(): { failed: boolean } {\n return { failed: true };\n }\n\n override componentDidUpdate(prev: ChatErrorBoundaryProps) {\n if (this.state.failed && changed(prev.resetKeys, this.props.resetKeys)) this.setState({ failed: false });\n }\n\n override componentDidCatch(error: unknown, info: ErrorInfo) {\n if (this.props.onError) this.props.onError(error, info);\n else console.error(\"genesis-chat: a message couldn't be drawn\", error);\n }\n\n override render() {\n return this.state.failed ? this.props.fallback : this.props.children;\n }\n}\n"]}
@@ -605,5 +605,5 @@ function useLinkPreviews(messageIds) {
605
605
  var EMPTY_PREVIEWS = {};
606
606
 
607
607
  export { CATCH_UP_MAX_PAGES, CATCH_UP_PAGE, ChatProvider, LINK_PREVIEW_CONCURRENCY, belongsTo, chatKeys, hostedUploadFrom, useAvailability, useBroadcast, useChat, useConversation, useDebouncedValue, useFileUrls, useInbox, useLinkPreviews, useMessageActions, usePeople, usePerson, usePins, useScheduled, useSearch, useStartConversation, useTopics, useUnreadTotal };
608
- //# sourceMappingURL=chunk-JU2FAPBI.js.map
609
- //# sourceMappingURL=chunk-JU2FAPBI.js.map
608
+ //# sourceMappingURL=chunk-XQ2ULNHV.js.map
609
+ //# sourceMappingURL=chunk-XQ2ULNHV.js.map