@marktiderman/genesis-chat 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.js.map +0 -1
package/README.md CHANGED
@@ -7,11 +7,11 @@ replies, threads, reactions, read state, live updates and push.
7
7
  | Entry | What it is |
8
8
  |---|---|
9
9
  | `@marktiderman/genesis-chat` | The headless core: types, `createChatClient`, the send outbox, thread logic, media rules. No React. |
10
- | `/react` | Hooks for web and native: `ChatProvider`, `useInbox`, `useConversation`, `useSend`, `useMessageActions`. |
11
- | `/web` | Renderers on genesis-ui: inbox, thread, composer (text, photos, voice), media. |
10
+ | `/react` | Hooks for web and native: `ChatProvider`, `useInbox`, `useConversation`, `useMessageActions`, and for 0002 `useTopics`, `useScheduled`, `useAvailability`, `useBroadcast`, `useSearch`, `useLinkPreviews`. |
11
+ | `/web` | Renderers on genesis-ui: inbox, thread, composer (text, photos, voice, video, emoji, scheduled send), media, topics, search, availability, broadcast. |
12
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. |
13
13
  | `/server` | `createChatRunner`: works the outbox (push, transcripts, storage cleanup). |
14
- | `migrations/0001_genesis_chat.sql` | The schema, copied into your app by `genesis add chat`, with its pgTAP test. |
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. |
15
15
 
16
16
  ## On the web
17
17
 
@@ -73,7 +73,7 @@ reaches nothing. The pgTAP test beside the migration proves each rule.
73
73
 
74
74
  New and changed messages, reactions and memberships are broadcast as hints (ids only) to private
75
75
  Realtime topics, `genesis-chat:conv:<id>` and `genesis-chat:user:<id>`; clients fetch through row
76
- level security, and fetch what they missed after a reconnect. Typing and recording are broadcast
76
+ level security, and fetch what they missed every time a channel joins (`onJoin`), the first time included. Typing and recording are broadcast
77
77
  by clients and never stored.
78
78
 
79
79
  ## The outbox
@@ -82,6 +82,215 @@ A message queues a `push` effect, a voice note or video a `transcribe` effect, a
82
82
  `storage.remove` effect. `createChatRunner` (from `/server`) claims and settles them; nothing is sent
83
83
  from a trigger.
84
84
 
85
+ ## Topics, scheduled send, availability, broadcast, search, previews (migration 0002)
86
+
87
+ Everything here is in the core and `/react`, so web and native share it; `/web` draws it.
88
+
89
+ ### Core client
90
+
91
+ ```ts
92
+ // Topics: named sub-threads; messages join one through `topic`.
93
+ chat.topics(conversationId): Promise<ChatTopic[]>
94
+ chat.setTopic(conversationId, topic, { title?, position?, closed? }): Promise<void> // owners and admins
95
+
96
+ // Scheduled send: a minute to a year ahead, posted as you by the runner.
97
+ chat.schedule({ conversationId, clientId, sendAt, body?, attachments?, replyToId?, topic?, mentions?, silent? }): Promise<string>
98
+ chat.scheduled(conversationId?): Promise<ScheduledMessage[]> // waiting or failed, soonest first
99
+ chat.cancelScheduled(id): Promise<void>
100
+
101
+ // Availability: away message, until when, hours per weekday, time zone.
102
+ chat.availabilityFor(conversationId): Promise<ChatAvailability[]>
103
+ chat.myAvailability(): Promise<ChatAvailability | null>
104
+ chat.setAvailability({ awayMessage?, awayUntil?, timeZone?, hours? }): Promise<void> // replaces all four
105
+
106
+ // Broadcast: one text message into each conversation you may post in; replies come back privately.
107
+ chat.broadcast(conversationIds, clientId, body, { silent? }): Promise<string[]>
108
+
109
+ // Search: text and transcripts, newest first; snippets mark matches with « ».
110
+ // A hit that is a reply in a thread has `threadRootId`: open that thread to show it.
111
+ chat.search(query, conversationId?, { limit? }): Promise<ChatSearchHit[]>
112
+
113
+ // Storage upload; with onProgress, bytes go to a signed upload URL through XMLHttpRequest
114
+ // (browsers, React Native) so progress is reported; elsewhere it reports once, at the end.
115
+ chat.upload(conversationId, userId, bytes, { name, contentType, onProgress?(sent, total), signal? }): Promise<string>
116
+
117
+ // Link previews the runner fetched (status ok only), by message id, through link_previews_for
118
+ // (one call per message, six at a time): only for messages you can read. The table is closed.
119
+ chat.linkPreviewsFor(messageIds): Promise<Record<string, ChatLinkPreview[]>>
120
+ ```
121
+
122
+ `ChatMessage.moderation` is `"flagged" | "hidden" | null`: a hidden message reaches only its
123
+ sender and the conversation's observers. Pure helpers: `isWithinWorkingHours(hours, timeZone,
124
+ now)` (an end at or before the start runs past midnight), `isAway(a, now)`, `hoursSummary(hours)`
125
+ ("Mon–Fri, 9–5"), `awaySummary(people, { now })` (a group: "Sam, Lee and 2 others are away"),
126
+ `awayLine(a, name, { now })` ("Mark replies Mon–Fri, 9–5 (Eastern)", "Away until
127
+ Monday: Back Monday", or null when they're around), `extractLinks(body)` (the database's `message_links` rule, which skips credential-looking queries),
128
+ `isHttpsUrl`, `snippetParts(snippet)`, `scheduleQuickPicks(now)`, `scheduleLabel`, `isSchedulable`,
129
+ `topicKey(title)`.
130
+
131
+ ```ts
132
+ const line = awayLine(await chat.availabilityFor(id).then((a) => a[0]), "Mark");
133
+ ```
134
+
135
+ ### React hooks
136
+
137
+ ```tsx
138
+ const { topics, setTopic } = useTopics(conversationId); // live: set_topic's hint refreshes it
139
+ const thread = useConversation(conversationId, { topic: "week-1" }); // one topic; sends go into it
140
+ const { items, schedule, cancel } = useScheduled(conversationId); // schedule(input, files?) uploads files first, as send does; cancel also removes a failed one
141
+ const { mine, setMine, members, others, forUser } = useAvailability(conversationId);
142
+ const { send, sending } = useBroadcast(); // send(ids, body) -> message ids
143
+ const { hits, isSearching, query } = useSearch(text, { conversationId, debounceMs: 250 });
144
+ const previews = useLinkPreviews(messageIds); // Record<messageId, ChatLinkPreview[]>, in link order
145
+ ```
146
+
147
+ `useConversation` also returns `signalRecordingVideo()` (throttled, like `signalRecording`), `loadUntil(seq)` (loads earlier pages until that message is here,
148
+ for a search result), `accessError` and `refetchAccess` (the role check failed: offer a retry, not
149
+ "can't post"). It catches up (messages after the newest one held) every time its live channel
150
+ joins, the first time included, and keeps `hasEarlier` in the cached page. Catch-up pages 200 at a
151
+ time until a short page; after 10 full pages it shows the newest page instead, with `hasEarlier`.
152
+ Only the whole timeline marks the conversation read: a topic or thread view doesn't (the read
153
+ place is one number per conversation, and a topic's newest message would pass unseen messages in
154
+ other topics).
155
+
156
+ `useChat()` adds `retryAll()` (call it from AppState or NetInfo on native; the web provider retries
157
+ on `online` and once after loading the saved outbox) and `clearOutbox()` (call it on sign-out).
158
+ `retryAll` goes through each conversation in order and stops at the first message that can't go on
159
+ its own: one waiting for a lost file, or one the server refused (`permanent`: 42501, 22xxx, 23xxx,
160
+ 42xxx, P0001), which only a manual `retry` sends again. `clearOutbox` stops sends in flight at once:
161
+ uploads get an aborted `signal` (the `upload` prop's third argument), and nothing posts or is saved
162
+ afterwards, so a send can't go out under the next account. The outbox owns a file's `previewUrl`
163
+ once queued and lets go of it (`releasePreview`, default: revoke `blob:` URLs) when the message
164
+ lands or is discarded.
165
+
166
+ A pending message's files carry their upload progress: `PendingMessage.files[i].progress` (0 to 1),
167
+ reported by `client.upload`'s `onProgress` or by your `upload` prop calling `opts.onProgress(sent,
168
+ total)`. The web bubble draws it as a bar.
169
+
170
+ ### Hosted video in the outbox
171
+
172
+ A file marked `hosted: true` goes to the hosted video provider instead of storage, through the
173
+ outbox, in its place in the order:
174
+
175
+ 1. The outbox calls `hostedUpload(conversationId, file, { signal, onProgress, resume? })`, which
176
+ mints the upload and resolves at once with `{ provider: "mux" | "cloudflare", providerId,
177
+ status: "pending", url?, done? }`; `done` settles when the bytes are up.
178
+ 2. The message is sent straight away with `{ kind: "video", provider, providerId, status:
179
+ "pending" }`, so text written after it lands after it. The provider's webhook flips it ready.
180
+ 3. The message stays in the outbox (hidden from the thread, which has the server's copy) until
181
+ `done` settles, with the upload saved as `QueuedMessage.hosted[i]` (`provider`, `providerId`,
182
+ `url`). After a relaunch the outbox calls `hostedUpload` again with `resume` set to that record,
183
+ then sends the message again with the same clientId (posting nothing twice). A failed transfer
184
+ marks it failed without holding later messages; Retry (or `retryAll`) resumes it the same way.
185
+
186
+ On the web, `ChatProvider videoUpload` gives the default `hostedUpload` (`hostedUploadFrom`: one PUT
187
+ of the whole file, with progress) and the composer's video recorder sends `hosted` files. Without
188
+ `videoUpload` (or `hostedUpload`), hosted files go to storage like any other.
189
+
190
+ **Native contract.** Pass your own `hostedUpload` to `ChatProvider` (it wins over `videoUpload`),
191
+ and send recordings through `useChat().outbox.send(input, [{ data: uri, name, contentType, hosted:
192
+ true, meta: { kind: "video", durationMs } }])` instead of uploading first:
193
+
194
+ ```ts
195
+ hostedUpload={async (conversationId, file, { signal, onProgress, resume }) => {
196
+ // Resuming (a relaunch, a retry): keep the providerId; the message may already carry it.
197
+ const minted = resume?.url ? { url: resume.url, providerId: resume.providerId, provider: resume.provider }
198
+ : await videoUpload.create(conversationId);
199
+ const done = putChunks(minted.url, file.data as string, { signal, onProgress, resume: Boolean(resume) });
200
+ return { provider: minted.provider, providerId: minted.providerId, status: "pending", url: minted.url, done };
201
+ }}
202
+ ```
203
+
204
+ Resolve as soon as the provider id exists; never wait for the bytes. On `resume`, carry the same
205
+ upload on (ask the server how much it has) and return the same `providerId`. Mint a new one only
206
+ when the old upload is gone: a message already posted keeps the old id, and its video stays
207
+ pending. Persist the file (`persistFile`) so a relaunch still has its bytes.
208
+
209
+ `ChatProvider` takes two more props:
210
+
211
+ ```tsx
212
+ <ChatProvider
213
+ client={chat}
214
+ userId={user.id}
215
+ // Mux direct uploads: your server makes the upload; the composer's recording goes through the
216
+ // outbox as a hosted file, sent at once as { kind: "video", provider: "mux", providerId,
217
+ // status: "pending" } while its bytes are PUT there. Without it, videos upload to storage.
218
+ // Pairs with createVideoUploadHandler from /server, which mints the upload and its passthrough;
219
+ // the client sends only the upload id (no attachment id).
220
+ videoUpload={{
221
+ create: (conversationId) =>
222
+ fetch("/api/chat-video-upload", {
223
+ method: "POST",
224
+ headers: { "content-type": "application/json", authorization: `Bearer ${session.access_token}` },
225
+ body: JSON.stringify({ conversationId }),
226
+ }).then((r) => (r.ok ? r.json() : Promise.reject(new Error("Couldn't start the upload")))),
227
+ }}
228
+ // Cards for your own attachment kinds (no forms dependency): a title, a subtitle, one action.
229
+ attachmentKinds={{
230
+ form: {
231
+ title: (a) => String(a.ref.title ?? "Form"),
232
+ subtitle: (a) => (a.ref.dueAt ? `Due ${a.ref.dueAt}` : null),
233
+ action: (a) => ({ label: "Fill in", href: `/forms/${a.ref.formId}` }),
234
+ },
235
+ }}
236
+ >
237
+ ```
238
+
239
+ A registered kind draws as `ChatAttachmentCard`; any other app kind goes to `ChatThread`'s
240
+ `attachment` slot (a generic card by default). For anything richer than a card, render the kind
241
+ yourself in that slot:
242
+
243
+ ```tsx
244
+ <ChatThread slots={{ attachment: (props) =>
245
+ props.attachment.kind === "check-in"
246
+ ? <CheckInCard id={String(props.attachment.ref.checkInId)} mine={props.mine} />
247
+ : <GenericAttachment {...props} />
248
+ }} />
249
+ ```
250
+
251
+ ### Web components
252
+
253
+ One message can't break a thread: each row renders inside `ChatErrorBoundary` ("This message
254
+ couldn't be shown"), an `attachment` slot that throws falls back to `GenericAttachment`, and an
255
+ `attachmentKinds` entry is used only for its own key (never `constructor` and the like) when its
256
+ `title` returns a string; a kind whose functions throw or return the wrong shapes gets the
257
+ generic card. Errors go to the console.
258
+
259
+ - **Topics**: `ChatThread` shows `ChatTopicBar` (All plus each topic, a lock on closed ones) when
260
+ the conversation has topics (`showTopics` shows it before the first, for owners). Owners and
261
+ admins get "New topic" and close or reopen. In a closed topic members see "This topic is closed".
262
+ - **Scheduled send**: the arrow beside Send offers tomorrow 9 AM, Monday 9 AM, or a date and time;
263
+ "N scheduled messages" above the box opens `ChatScheduledList` with Cancel, or Remove for one that
264
+ failed when due (`scheduling={false}` turns it off). `ChatScheduledList` also works on its own, for all conversations.
265
+ - **Availability**: `ChatAwayBanner` above the messages when the other person is away or outside
266
+ their hours, or in a group everyone who is (two names, then "and N others"); `ChatAvailabilitySettings` for your own.
267
+ - **Broadcast**: `<ChatBroadcast resolveUser={…} kind="direct" />` picks conversations from the inbox
268
+ and sends once, saying replies come back privately.
269
+ - **Search**: `<ChatSearch resolveUser onOpen={(hit) => …} conversationId? />`; the thread header has
270
+ it for one conversation and jumps to the message (loading earlier pages); `ChatPanel` has it
271
+ across all (`search={false}` hides it) and opens the conversation at the message
272
+ (`ChatThread jumpTo={{ messageId, seq, threadRootId }}`). A hit that is a reply in a thread
273
+ calls `onOpenThread(root, { jumpTo: { messageId, seq } })` (pass `jumpTo` to the thread's own
274
+ `ChatThread`) and scrolls the timeline to the thread's first message.
275
+ - **Link previews**: `ChatLinkPreview` under a message with a link: plain text only, no referrer,
276
+ and **no image by default**. An og:image lives on the linked site, so loading it in readers'
277
+ browsers tells that site (which the sender may control) each reader's IP and when they read it.
278
+ `ChatThread linkPreviewImages`: `"off"` (default), `"proxy"` (loads `linkPreviewProxyUrl(url)`,
279
+ your own image proxy; no image without it), or `"direct"` (from the linked site, https only, if
280
+ you accept that). `ChatLinkPreview` takes the same as `images` and `proxyUrl`.
281
+ - **Moderation**: the sender sees "Hidden by moderation. Only you can see it."; observers see a
282
+ "Hidden by moderation" or "Flagged" badge.
283
+ - **Emoji**: `ChatEmojiPicker` (frimousse) behind "+" in the reaction bar and the composer's smiley,
284
+ offered only when you pass `emojibaseUrl` (`ChatThread`, `ChatComposer`): host the
285
+ `emojibase-data` package's `en/data.json` and `en/messages.json` there (frimousse fetches
286
+ `${emojibaseUrl}/en/data.json`). Without it nothing is fetched and only the quick reactions show;
287
+ frimousse's own default, jsDelivr's unpinned `emojibase-data@latest`, is never used. If you point
288
+ it at a CDN instead, add that host to your CSP's `connect-src`.
289
+ - **Video**: the composer's camera button opens `ChatVideoRecorder` (preview, a limit by role,
290
+ retake, send). `ChatVideoPlayer` streams Mux (`https://stream.mux.com/<playbackId>.m3u8`) with
291
+ native HLS where the browser has it and `hls.js` (an optional peer, imported only then)
292
+ elsewhere, with Mux's thumbnail as the poster and "Processing…" while pending.
293
+
85
294
  ## Server: the runner
86
295
 
87
296
  `@marktiderman/genesis-chat/server` works the outbox with a service-role client. It runs in Node,
@@ -97,7 +306,7 @@ export const video = muxVideo({
97
306
  });
98
307
  const runner = createChatRunner({
99
308
  supabase,
100
- // Or: (n) => notify({ type: "chat.message", recipients: n.recipients, data: n.data, idempotencyKey: n.idempotencyKey, threadKey: n.threadKey, collapseKey: n.collapseKey })
309
+ // Or through genesis-notifications: notificationsDeliver({ notify, client: supabase, registry: notifications })
101
310
  deliver: createExpoDeliver({ tokensFor: myPushTokens, accessToken: process.env.EXPO_ACCESS_TOKEN }),
102
311
  transcriber: deepgramTranscriber({ apiKey: process.env.DEEPGRAM_API_KEY! }),
103
312
  video,
@@ -108,13 +317,26 @@ export const POST = chatRunnerHandler({ runner, secret: process.env.CHAT_RUNNER_
108
317
 
109
318
  Each `run()` claims up to 50 due effects (`batch.limit`), works up to 4 of a kind at once
110
319
  (`batch.concurrency`), settles each (`complete_effect`; an error backs off 30 s × 2^attempts) and
111
- answers `{ claimed, done, failed }`. It claims only `push`, `transcribe`, `storage.remove` and
112
- `video.remove` (or the `kinds` you pass); after the 8th attempt an effect is left alone.
320
+ answers `{ claimed, done, failed }`, plus `scheduled` (`{ due, posted, skipped, failed }`) unless
321
+ `scheduled: false`, and `purged` and `errors` when there are any. It
322
+ claims `push`, `transcribe`, `storage.remove` and `video.remove`, plus `moderate` with a `moderator`
323
+ and `unfurl` with an `unfurler` (or the `kinds` you pass); after the 8th attempt an effect is left
324
+ alone. Each run also posts due scheduled messages (`due_scheduled`, then `post_scheduled` each, as
325
+ their sender, with every check `send_message` makes; a refusal is recorded on the row at once, a
326
+ passing error such as a deadlock is tried again on the next run, up to five times; `scheduled:
327
+ false` turns it off) and, with
328
+ `retention: true`, removes messages past their kind's retention (up to 10 `purge_expired` calls a run,
329
+ each removing up to `retentionBatch`, default 1 000; never in a conversation on legal hold). A failed
330
+ `due_scheduled` or `purge_expired` is reported in `errors`, never thrown; a scheduled message whose
331
+ `post_scheduled` throws counts in `scheduled.failed` and is logged, not added to `errors`.
113
332
 
114
333
  - **push**: one `deliver(notification)` per message, with every recipient and their badge. The
115
334
  title is the conversation's, or the sender's name (`resolveName`) in a direct conversation; in a
116
335
  group the body is `Name: preview`. `idempotencyKey` is `push:<message id>`, so a sender that
117
- dedupes never pushes a message twice. Throw from `deliver` to retry. `createExpoDeliver` sends
336
+ dedupes never pushes a message twice; `data` always carries `messageId` and `seq` (native derives
337
+ a reply's client id from them). There is no `collapseKey` unless you pass `collapse: true`: on iOS
338
+ the collapse id becomes the notification's identifier, so replies from two notifications of one
339
+ conversation would collide. Throw from `deliver` to retry. `createExpoDeliver` sends
118
340
  through Expo in requests of 100 (`threadId`, `collapseId`, `badge`, channel `messages`, category
119
341
  `chat-message`) and returns the tickets: forget `deadTokens(tickets)`. It throws only when
120
342
  nothing was sent. Note that `collapseId` replaces a conversation's earlier notification on iOS.
@@ -149,16 +371,160 @@ those up.
149
371
  | `MUX_TOKEN_ID`, `MUX_TOKEN_SECRET` | `muxVideo` uploads and removal |
150
372
  | `MUX_WEBHOOK_SECRET` | the Mux webhook's signing secret |
151
373
  | `MUX_SIGNING_KEY_ID`, `MUX_SIGNING_KEY` | only for `playbackPolicy: "signed"` (`signing` option) |
374
+ | `OPENAI_API_KEY` | `openAiModerator` (omni-moderation-latest) |
375
+ | `ANTHROPIC_API_KEY` | `createChatAssistant` on the fetch path (or pass an `@anthropic-ai/sdk` client) |
152
376
 
153
377
  ### Mux
154
378
 
155
- The client asks your server for an upload (`video.createUpload({ corsOrigin, passthrough })`), puts
156
- the file to its `url`, and sends the message with an attachment `{ kind: "video", provider: "mux",
157
- provider_id: uploadId, status: "pending" }`. Point a Mux webhook (Settings → Webhooks) at a route
158
- that calls `video.handleWebhook(request, { supabase })`. It checks `mux-signature` (HMAC-SHA256,
159
- 5 minutes' tolerance), and on `video.asset.ready` sets the attachment ready with its asset id,
379
+ The client asks your server for an upload (`createVideoUploadHandler`, below), puts the file to its
380
+ `url`, and sends the message with an attachment `{ kind: "video", provider: "mux", provider_id:
381
+ uploadId, status: "pending" }`. Point a Mux webhook (Settings → Webhooks) at a route that calls
382
+ `video.handleWebhook(request, { supabase })`. It checks `mux-signature` (HMAC-SHA256, 5 minutes'
383
+ tolerance) and finds the attachment only by that upload id (unique per attachment). It applies an
384
+ asset event only when the upload's passthrough, which the upload handler sets, names the
385
+ attachment's conversation (and its sender, when `authorize` named the uploader); anything else
386
+ answers 409 and changes nothing. On `video.asset.ready` it sets the attachment ready with its asset id,
160
387
  playback id and duration; on `video.asset.errored` failed; on the English auto-generated caption
161
388
  track's `video.asset.track.ready` it stores the caption text as the transcript. An event for an
162
389
  attachment not there yet answers 404, so Mux retries it. Playback is public by default
163
390
  (`https://stream.mux.com/<playback id>.m3u8`); with `playbackPolicy: "signed"` and `signing`,
164
391
  `video.playbackUrl(id)` (or `muxPlaybackUrl`) returns a URL with an RS256 token.
392
+
393
+ ### Push through genesis-notifications
394
+
395
+ ```ts
396
+ import { defineNotifications, MESSAGE_ACTIONS } from "@marktiderman/genesis-notifications";
397
+ import { notify } from "@marktiderman/genesis-notifications/server";
398
+ import { CHAT_NOTIFICATION_CATEGORIES, CHAT_NOTIFICATION_TYPES, notificationsDeliver } from "@marktiderman/genesis-chat/server";
399
+
400
+ export const notifications = defineNotifications({
401
+ categories: { ...CHAT_NOTIFICATION_CATEGORIES },
402
+ actions: { message: MESSAGE_ACTIONS },
403
+ types: { "chat.message": { ...CHAT_NOTIFICATION_TYPES["chat.message"], actions: "message" } },
404
+ });
405
+ const deliver = notificationsDeliver({ notify, client: supabase, registry: notifications });
406
+ ```
407
+
408
+ One `notify()` per message: type `chat.message` (category `messages`, in the feed and pushed),
409
+ every recipient with their own badge, `idempotencyKey: push:<message id>`, `threadKey` the
410
+ conversation, `data` `{ url, conversationId, messageId, seq, title, body, senderId }`, and
411
+ `collapseKey` only when the runner's `collapse` is on. Preferences, quiet hours and devices are
412
+ genesis-notifications'.
413
+
414
+ ### Moderation
415
+
416
+ `set_kind_options('cohort', p_moderate => true)` (a migration of yours) queues a `moderate` effect
417
+ for each new or edited text message in that kind, and holds the message's push until the verdict:
418
+ clean or flagged sends it, hidden never does (nor counts the message in a badge or shows it in the
419
+ inbox). So a runner without a moderator sends no pushes for that kind. Give the runner
420
+ `moderator: openAiModerator({ apiKey: process.env.OPENAI_API_KEY! })`: it sends the text and every
421
+ photo (signed 10 minute URLs; the text with the first, the others one request each) to
422
+ `omni-moderation-latest`. A category score at or over `hide` (default 0.9) hides the message from
423
+ members and files a report for observers; at or over `flag` (default 0.5), or flagged by OpenAI,
424
+ files the report only. `sexual/minors` hides at 0.5; `self-harm/intent` and
425
+ `self-harm/instructions` flag at 0.3 so a coach sees them. Change them with `thresholds` and
426
+ `categories`. The report names the categories and scores, never the text. A verdict never lowers
427
+ one already given (a retry can't un-hide a message), the same verdict on the same text files one
428
+ report, and a moderator still failing on the last of 8 attempts files `flagged` with the reason
429
+ "moderation unavailable", so observers see it and the push goes.
430
+
431
+ ### Link previews
432
+
433
+ `unfurler: createLinkUnfurler()` works `unfurl` effects (a message's first three https links,
434
+ leaving out any whose query carries a credential such as `token=`, `sig=`, `code=` or
435
+ `X-Amz-Signature=`) and stores `ok`, `failed` or `blocked` with `save_link_preview`. Clients read
436
+ previews with `genesis_chat.link_previews_for(message_id)`, which returns them only for a message
437
+ the caller can read; the table itself is not readable, since its URLs come from private messages.
438
+ It fetches https on port 443 only, follows at most 3 redirects and checks each one, refuses private,
439
+ loopback, link-local, carrier-grade NAT and cloud metadata addresses and the IPv6 forms that carry
440
+ them (and `localhost`, `*.local`, `*.internal`, single-label names), holds og:image to the same
441
+ rules (else drops it), reads HTML only, up to 512 KiB, in 5 seconds (the DNS lookup included), and
442
+ runs nothing. It resolves the host first through `node:dns` or `Deno.resolveDns`; where neither
443
+ exists (some edge runtimes) it refuses every named host and logs once, unless you pass `resolve`,
444
+ or `allowUnresolved: true` behind an egress firewall. fetch resolves the name again; on Node, close
445
+ that rebinding gap with `dispatcher: new Agent({ connect: { lookup: guardedLookup } })` (`Agent`
446
+ from `undici`), which checks the address each connection actually uses. Elsewhere, firewall private
447
+ ranges from the runner.
448
+
449
+ ### Video uploads
450
+
451
+ ```ts
452
+ import { createVideoUploadHandler } from "@marktiderman/genesis-chat/server";
453
+ const handler = createVideoUploadHandler({
454
+ video, // muxVideo(...) from above
455
+ // Confirm the session's user may post there (e.g. a select on genesis_chat.members as that
456
+ // user) and name them, so the upload is bound to its uploader.
457
+ authorize: async (request, { conversationId }) => {
458
+ const user = await userFrom(request);
459
+ return user && (await mayPost(user, conversationId)) ? { userId: user.id } : false;
460
+ },
461
+ corsOrigin: ["https://app.example.com"], // required; native apps send no origin and get the first
462
+ rateLimit: async (_request, { userId }) => underUploadCap(userId), // optional: 429 when false
463
+ });
464
+ export const POST = handler;
465
+ export const OPTIONS = handler; // CORS preflight for a web client on another origin
466
+ ```
467
+
468
+ The composer POSTs `{ conversationId }` and gets `{ url, providerId, provider: "mux" }`; it PUTs
469
+ the file to `url` and sends the attachment with `provider_id: providerId`. The client chooses
470
+ nothing the webhook trusts: the server sets the upload's passthrough to the conversation and
471
+ uploader. A refusal answers 403; over the cap, 429.
472
+
473
+ ### A fake video provider for development
474
+
475
+ `fakeVideoProvider` stands in for Mux locally, so the hosted path (pending, upload, ready) runs
476
+ without an account. It throws when `NODE_ENV` is `production` unless you pass `force: true`.
477
+
478
+ ```ts
479
+ import { createVideoUploadHandler, fakeVideoProvider } from "@marktiderman/genesis-chat/server";
480
+ const video = fakeVideoProvider({
481
+ supabase: serviceRoleClient, // writes the bucket, calls update_attachment
482
+ uploadUrl: "http://localhost:3000/api/dev/fake-video", // where you mount video.handler
483
+ });
484
+ export const mint = createVideoUploadHandler({ video, authorize, corsOrigin: "http://localhost:3000" });
485
+ export const PUT = video.handler; // takes the bytes, stores them, marks the attachment ready
486
+ export const OPTIONS = video.handler;
487
+ // Or by hand, as Mux's video.asset.ready webhook would:
488
+ await video.markReady(providerId, { durationMs: 4200, posterPath });
489
+ ```
490
+
491
+ `createUpload` returns `uploadUrl?id=fake_<uuid>`. `handler` stores the PUT body in the genesis-chat
492
+ bucket under the upload's conversation and uploader, then calls `markReady`, waiting up to
493
+ `readyWaitMs` (10 s) for the message carrying the upload; past that it answers 202 and you can call
494
+ `markReady` later. `markReady` calls `genesis_chat.update_attachment` with status ready, playback id
495
+ `fake-<upload id>`, a fake HLS URL (`https://fake-video.invalid/...`) and the duration and poster
496
+ you give. `video.playbackUrl(playbackId)` signs a link to the stored file.
497
+
498
+ ### AI recaps and reply drafts
499
+
500
+ ```ts
501
+ import { createChatAssistant } from "@marktiderman/genesis-chat/server";
502
+ const assistant = createChatAssistant({ apiKey: process.env.ANTHROPIC_API_KEY! }); // or { anthropic: new Anthropic() }
503
+ const { text, model, labelled } = await assistant.recap({ messages, since: lastReadAt, me: userId, resolveName });
504
+ const draft = await assistant.draftReply({ messages, me: coachId, voice: "warm, brief, first names", resolveName });
505
+ ```
506
+
507
+ Default model `claude-sonnet-5-5`, low effort, with `fallbacks: "default"` (a declined request is
508
+ re-run on Anthropic's recommended model; `fallbacks: false` turns it off). Nothing is sent: show the
509
+ text as written by AI (`labelled: true`) and let the person edit and send it. Internal notes and
510
+ hidden messages are left out unless you pass `observer: true` for a staff reader: take it from
511
+ `genesis_chat.i_observe(conversation)` asked as that reader, never from the request. The transcript
512
+ goes in as data, with `<` and `>` in bodies and names replaced so nothing can close its tags, and
513
+ the prompt tells the model to ignore instructions inside it. Call it from your server only, for a
514
+ user you have checked may read the conversation. `fallbacks` is a Claude API feature: Bedrock,
515
+ Vertex AI and Foundry clients reject it, so pass `fallbacks: false` with those.
516
+
517
+ ### Retention once a day
518
+
519
+ Run a second runner daily with retention on (and scheduled posting off, since the minute runner
520
+ does it). `vercel.json`: `{ "crons": [{ "path": "/api/chat-runner", "schedule": "* * * * *" },
521
+ { "path": "/api/chat-retention", "schedule": "30 3 * * *" }] }`, and:
522
+
523
+ ```ts
524
+ // app/api/chat-retention/route.ts
525
+ const retention = createChatRunner({ supabase, deliver, kinds: [], scheduled: false, retention: true });
526
+ export const GET = chatRunnerHandler({ runner: retention, secret: process.env.CHAT_RUNNER_SECRET! });
527
+ ```
528
+
529
+ Set a kind's retention with `set_kind_options('support', p_retention => interval '365 days')`, and
530
+ hold a conversation with `set_legal_hold(id, true)` (service role).