@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.
- package/README.md +379 -13
- package/dist/chunk-A4YJX7KT.js +544 -0
- package/dist/chunk-A4YJX7KT.js.map +1 -0
- package/dist/chunk-AZS3HNLM.js +30 -0
- package/dist/chunk-AZS3HNLM.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-GBJ3YI4E.js +966 -0
- package/dist/chunk-GBJ3YI4E.js.map +1 -0
- package/dist/chunk-JU2FAPBI.js +609 -0
- package/dist/chunk-JU2FAPBI.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1939 -1
- package/dist/native.js +5023 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B9NAWQ58.d.ts +219 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +176 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +551 -21
- package/dist/server.js +976 -34
- package/dist/server.js.map +1 -1
- package/dist/thread-oPra7pkW.d.ts +57 -0
- package/dist/web.d.ts +335 -12
- package/dist/web.js +1784 -64
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/0003_genesis_chat_search_threads.sql +85 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
- package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
- package/package.json +78 -6
- package/src/__tests__/coaching-core.test.ts +334 -0
- package/src/__tests__/coaching-react.test.tsx +319 -0
- package/src/__tests__/coaching-web.test.tsx +521 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/fake-xhr.ts +54 -0
- package/src/__tests__/outbox-hosted.test.ts +269 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-279.test.ts +41 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +405 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fake-video.test.ts +81 -0
- package/src/__tests__/server-fakes.ts +96 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +303 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +349 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +783 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +635 -0
- package/src/core/thread.ts +171 -0
- package/src/core/transfer.ts +71 -0
- package/src/core/types.ts +328 -0
- package/src/index.ts +14 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +254 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +424 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +498 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +132 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +122 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +126 -0
- package/src/native/message-bubble.tsx +366 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +277 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +328 -0
- package/src/native/search-screen.tsx +189 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +647 -0
- package/src/native/topics.tsx +234 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +410 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +274 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +301 -0
- package/src/react/index.tsx +894 -0
- package/src/react/previews.ts +51 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/fake-video.ts +171 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +58 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +585 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +196 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +714 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +204 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +830 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +516 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js +0 -525
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js +0 -309
- 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`, `
|
|
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
|
|
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
|
|
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 }
|
|
112
|
-
`
|
|
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
|
|
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 (`
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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).
|