@marktiderman/genesis-chat 0.1.1 → 0.2.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 (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.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-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @feat:chat
3
+ * The topic bar on native: "All" and each of a conversation's topics as single-choice chips in a
4
+ * row that scrolls sideways, a lock on closed ones, and, for owners and admins, an options button
5
+ * that adds a topic and renames, closes or reopens the one shown (one sheet: the menu, then the
6
+ * name field). `ChatThreadScreen` shows it when the conversation has topics, and reads the
7
+ * messages of the topic chosen. The same rules as the web's `ChatTopicBar`.
8
+ */
9
+ import { useState } from "react";
10
+ import { ScrollView, View } from "react-native";
11
+ import { NativeButton, NativeChip, NativeChipGroup, NativeIcon, NativeInput, NativeListItem, NativeSheet, NativeText, useToast } from "@marktiderman/genesis-ui-native/components";
12
+ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
13
+ import { topicKey } from "../core/extras";
14
+ import type { ChatRole, ChatTopic, TopicInput } from "../core/types";
15
+ import { fill } from "./copy";
16
+ import { useChatNative } from "./context";
17
+ import { ChatIconButton } from "./icon-button";
18
+
19
+ const ALL = "__all__";
20
+
21
+ /** Owners and admins add, rename, close and reopen topics. */
22
+ export function canManageTopics(role: ChatRole | null | undefined): boolean {
23
+ return role === "owner" || role === "admin";
24
+ }
25
+
26
+ /** A closed topic takes posts only from owners, admins and observers. */
27
+ export function topicClosedFor(topic: ChatTopic | null | undefined, role: ChatRole | null | undefined): boolean {
28
+ return Boolean(topic?.closedAt) && role !== "owner" && role !== "admin" && role !== "observer";
29
+ }
30
+
31
+ export type TopicMenuAction = "new" | "rename" | "close" | "reopen";
32
+
33
+ /** What the options sheet offers: always New; Rename and Close or Reopen when a topic is shown. */
34
+ export function topicMenuFor(current: ChatTopic | null | undefined): TopicMenuAction[] {
35
+ if (!current) return ["new"];
36
+ return ["new", "rename", current.closedAt ? "reopen" : "close"];
37
+ }
38
+
39
+ /**
40
+ * Checks a topic's name: its key, or why it can't be saved. Renaming keeps the key (messages
41
+ * carry it), so only a new topic can clash with another's key.
42
+ */
43
+ export function checkTopicName(title: string, topics: readonly ChatTopic[], renaming?: string | null): { key: string } | { error: "empty" | "exists" } {
44
+ const name = title.trim();
45
+ if (!name) return { error: "empty" };
46
+ if (renaming) return { key: renaming };
47
+ const key = topicKey(name);
48
+ return topics.some((t) => t.topic === key) ? { error: "exists" } : { key };
49
+ }
50
+
51
+ export interface ChatTopicBarProps {
52
+ topics: ChatTopic[];
53
+ /** The topic shown; null for "All". */
54
+ value: string | null;
55
+ onValueChange: (topic: string | null) => void;
56
+ /** Owners and admins (`canManageTopics(role)`): the options button. */
57
+ canManage?: boolean;
58
+ /** Saves a topic (`useTopics().setTopic`). */
59
+ onSetTopic?: (topic: string, input: TopicInput) => Promise<void>;
60
+ testID?: string;
61
+ }
62
+
63
+ type SheetState = { step: "menu" } | { step: "name"; renaming: string | null };
64
+
65
+ export function ChatTopicBar({ topics, value, onValueChange, canManage = false, onSetTopic, testID = "chat-topics" }: ChatTopicBarProps) {
66
+ const { copy } = useChatNative();
67
+ const colors = useThemeRoles();
68
+ const toast = useToast();
69
+ const [sheet, setSheet] = useState<SheetState | null>(null);
70
+ const [title, setTitle] = useState("");
71
+ const [busy, setBusy] = useState(false);
72
+ const [error, setError] = useState<string | null>(null);
73
+ const current = value ? (topics.find((t) => t.topic === value) ?? null) : null;
74
+
75
+ const close = () => {
76
+ if (busy) return;
77
+ setSheet(null);
78
+ setError(null);
79
+ };
80
+
81
+ const run = async (action: TopicMenuAction) => {
82
+ if (action === "new" || action === "rename") {
83
+ setTitle(action === "rename" ? (current?.title ?? "") : "");
84
+ setError(null);
85
+ setSheet({ step: "name", renaming: action === "rename" ? (current?.topic ?? null) : null });
86
+ return;
87
+ }
88
+ if (!current || !onSetTopic) return;
89
+ setSheet(null);
90
+ try {
91
+ await onSetTopic(current.topic, { closed: action === "close" });
92
+ toast.show({ title: fill(action === "close" ? copy.topicClosedDone : copy.topicReopenedDone, { name: current.title }), variant: "success", duration: 1500 });
93
+ } catch (e) {
94
+ toast.show({ title: e instanceof Error ? e.message : copy.actionFailed, variant: "destructive" });
95
+ }
96
+ };
97
+
98
+ const save = async (renaming: string | null) => {
99
+ if (!onSetTopic) return;
100
+ const checked = checkTopicName(title, topics, renaming);
101
+ if ("error" in checked) {
102
+ setError(checked.error === "exists" ? copy.topicExists : null);
103
+ return;
104
+ }
105
+ setBusy(true);
106
+ try {
107
+ await onSetTopic(checked.key, renaming ? { title: title.trim() } : { title: title.trim(), position: topics.length });
108
+ setSheet(null);
109
+ setTitle("");
110
+ if (!renaming) onValueChange(checked.key);
111
+ } catch (e) {
112
+ setError(e instanceof Error ? e.message : copy.actionFailed);
113
+ } finally {
114
+ setBusy(false);
115
+ }
116
+ };
117
+
118
+ return (
119
+ <View testID={testID} style={{ flexDirection: "row", alignItems: "center", backgroundColor: colors.groupedBackground, paddingLeft: 8 }}>
120
+ <ScrollView horizontal showsHorizontalScrollIndicator={false} accessibilityLabel={copy.topics} contentContainerStyle={{ paddingVertical: 4, paddingRight: 8 }} style={{ flex: 1 }}>
121
+ <NativeChipGroup mode="single" value={value ?? ALL} onChange={(v) => onValueChange(v === ALL ? null : (v as string))} className="flex-nowrap" testID={`${testID}-group`}>
122
+ <NativeChip value={ALL} testID={`${testID}-all`}>
123
+ {copy.topicAll}
124
+ </NativeChip>
125
+ {topics.map((t) => (
126
+ <NativeChip key={t.topic} value={t.topic} testID={`${testID}-${t.topic}`}>
127
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
128
+ {t.closedAt ? <NativeIcon name="lock" size={13} color={colors.secondaryLabel} label={copy.topicClosed} /> : null}
129
+ <NativeText preset="body-sm" numberOfLines={1} style={{ maxWidth: 180, fontWeight: value === t.topic ? "600" : "400", color: value === t.topic ? colors.tint : colors.label }}>
130
+ {t.title}
131
+ </NativeText>
132
+ </View>
133
+ </NativeChip>
134
+ ))}
135
+ </NativeChipGroup>
136
+ </ScrollView>
137
+ {canManage && onSetTopic ? <ChatIconButton testID={`${testID}-menu`} icon="more" label={copy.topicOptions} onPress={() => setSheet({ step: "menu" })} /> : null}
138
+ <NativeSheet open={sheet !== null} onOpenChange={(o) => (o ? undefined : close())} testID={`${testID}-sheet`}>
139
+ {sheet?.step === "name" ? (
140
+ <View style={{ padding: 16, gap: 12 }}>
141
+ <NativeText preset="h4" accessibilityRole="header">
142
+ {sheet.renaming ? copy.topicRename : copy.topicNew}
143
+ </NativeText>
144
+ <NativeInput
145
+ testID={`${testID}-name`}
146
+ label={copy.topicName}
147
+ value={title}
148
+ onChangeText={(v) => {
149
+ setTitle(v);
150
+ setError(null);
151
+ }}
152
+ placeholder={copy.topicNamePlaceholder}
153
+ maxLength={160}
154
+ autoFocus
155
+ returnKeyType="done"
156
+ onSubmitEditing={() => void save(sheet.renaming)}
157
+ error={error ?? undefined}
158
+ />
159
+ <NativeButton testID={`${testID}-save`} onPress={() => void save(sheet.renaming)} loading={busy} disabled={!title.trim()}>
160
+ {sheet.renaming ? copy.topicSave : copy.topicAdd}
161
+ </NativeButton>
162
+ </View>
163
+ ) : (
164
+ <View>
165
+ {topicMenuFor(current).map((action) => (
166
+ <NativeListItem
167
+ key={action}
168
+ testID={`${testID}-${action}`}
169
+ title={action === "new" ? copy.topicNew : action === "rename" ? copy.topicRename : action === "close" ? copy.topicClose : copy.topicReopen}
170
+ leading={<NativeIcon name={action === "new" ? "plus" : action === "rename" ? "edit" : "lock"} />}
171
+ onPress={() => void run(action)}
172
+ />
173
+ ))}
174
+ </View>
175
+ )}
176
+ </NativeSheet>
177
+ </View>
178
+ );
179
+ }
@@ -0,0 +1,282 @@
1
+ /**
2
+ * @feat:chat
3
+ * Resumable uploads for the native outbox: Supabase Storage's TUS endpoint
4
+ * (`/storage/v1/upload/resumable`), with the person's own token, so storage's
5
+ * row level security (genesis_chat.can_upload) judges each file as it does a
6
+ * plain upload. A dropped connection resumes from the last chunk the server
7
+ * has (HEAD for the offset), within the session; a retry of the same file
8
+ * reuses its path, so the message's attachment never points at a second copy.
9
+ * A file on the phone is read one chunk at a time when the app gives
10
+ * `openFile` (a 100 MB video never sits whole in memory); without it, it is
11
+ * read whole. A create the server refuses as already there (409) counts as
12
+ * done only when the object is really there.
13
+ *
14
+ * <ChatProvider upload={createTusUpload({ supabaseUrl, userId, getAccessToken })} …>
15
+ *
16
+ * The path is `<conversation>/<uploader>/<uuid>.<ext>`, as `send_message`
17
+ * expects; the upload finishes before the send (the outbox orders that).
18
+ */
19
+ import { CHAT_BUCKET, ChatError } from "../core/client";
20
+ import { fileExtension, isAllowedExtension, randomId } from "../core/media";
21
+ import type { LocalFile } from "../core/outbox";
22
+
23
+ /** Supabase requires 6 MB chunks for TUS (every chunk but the last). */
24
+ export const TUS_CHUNK_BYTES = 6 * 1024 * 1024;
25
+
26
+ export interface TusUploadOptions {
27
+ /** The project URL (`https://<ref>.supabase.co`, or the local stack's). */
28
+ supabaseUrl: string;
29
+ /** The signed-in user's id (auth.users), the second folder of the path. */
30
+ userId: string | (() => string);
31
+ /** The signed-in user's access token (`(await supabase.auth.getSession()).data.session?.access_token`). */
32
+ getAccessToken: () => Promise<string | null | undefined>;
33
+ /** The publishable key, sent as `apikey` (the API gateway asks for it on hosted projects). */
34
+ apikey?: string;
35
+ /**
36
+ * Opens a file URI for reading one chunk at a time (expo-file-system's `new File(uri).open()`),
37
+ * so a big video is never read whole into memory. Without it, `readFile` reads the whole file.
38
+ */
39
+ openFile?: (uri: string) => Promise<TusFileHandle> | TusFileHandle;
40
+ /** Reads a file URI into bytes, whole: used without `openFile`. Default `fetch(uri).arrayBuffer()`. */
41
+ readFile?: (uri: string) => Promise<ArrayBuffer>;
42
+ fetch?: typeof fetch;
43
+ chunkBytes?: number;
44
+ /** Network failures retried per chunk before the send fails (it can be retried). Default 3. */
45
+ retries?: number;
46
+ /** A uuid for each file's name. Default core's `randomId` (crypto.randomUUID where there is one, else getRandomValues). */
47
+ newId?: () => string;
48
+ bucket?: string;
49
+ }
50
+
51
+ /** An open file, read a chunk at a time. */
52
+ export interface TusFileHandle {
53
+ /** Bytes in the file. */
54
+ size: number;
55
+ /** Up to `length` bytes from `offset`. */
56
+ read(offset: number, length: number): Promise<ArrayBuffer | Uint8Array>;
57
+ close?(): void;
58
+ }
59
+
60
+ /** What upload reads its bytes from: the file in chunks, or bytes already in memory. */
61
+ interface Source {
62
+ size: number;
63
+ read(from: number, to: number): Promise<ArrayBuffer>;
64
+ close(): void;
65
+ }
66
+
67
+ const exact = (bytes: ArrayBuffer | Uint8Array): ArrayBuffer =>
68
+ bytes instanceof ArrayBuffer ? bytes : (bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer);
69
+
70
+ const inMemory = (buffer: ArrayBuffer): Source => ({ size: buffer.byteLength, read: async (from, to) => buffer.slice(from, to), close: () => undefined });
71
+
72
+ interface UploadRecord {
73
+ path: string;
74
+ location?: string;
75
+ done?: boolean;
76
+ }
77
+
78
+ /** Base64 of a UTF-8 string, for TUS's Upload-Metadata (no `btoa`, which isn't on every Hermes build). */
79
+ export function base64Utf8(text: string): string {
80
+ const bytes = new TextEncoder().encode(text);
81
+ const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
82
+ let out = "";
83
+ for (let i = 0; i < bytes.length; i += 3) {
84
+ const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
85
+ out += A[(n >> 18) & 63]! + A[(n >> 12) & 63]! + (i + 1 < bytes.length ? A[(n >> 6) & 63]! : "=") + (i + 2 < bytes.length ? A[n & 63]! : "=");
86
+ }
87
+ return out;
88
+ }
89
+
90
+ const transientStatus = (status: number) => status === 0 || status === 408 || status === 429 || status >= 500;
91
+
92
+ export function createTusUpload(options: TusUploadOptions): (conversationId: string, file: LocalFile) => Promise<string> {
93
+ const doFetch = options.fetch ?? fetch;
94
+ const chunk = options.chunkBytes ?? TUS_CHUNK_BYTES;
95
+ const retries = options.retries ?? 3;
96
+ const bucket = options.bucket ?? CHAT_BUCKET;
97
+ const endpoint = `${options.supabaseUrl.replace(/\/+$/, "")}/storage/v1/upload/resumable`;
98
+ const newId = options.newId ?? randomId;
99
+ const readFile = options.readFile ?? (async (uri: string) => (await doFetch(uri)).arrayBuffer());
100
+ // By file object: a retry of the same message in this session resumes it. Two messages never
101
+ // share an object, so deleting one never breaks the other.
102
+ const byFile = new WeakMap<LocalFile, UploadRecord>();
103
+
104
+ async function headers(extra: Record<string, string> = {}) {
105
+ const token = await options.getAccessToken();
106
+ if (!token) throw new ChatError("Sign in to send files", "42501", "upload");
107
+ return {
108
+ authorization: `Bearer ${token}`,
109
+ ...(options.apikey ? { apikey: options.apikey } : {}),
110
+ "tus-resumable": "1.0.0",
111
+ ...extra,
112
+ };
113
+ }
114
+
115
+ async function sourceOf(file: LocalFile): Promise<Source> {
116
+ const data = file.data;
117
+ if (typeof data === "string") {
118
+ if (!options.openFile) return inMemory(await readFile(data));
119
+ const handle = await options.openFile(data);
120
+ if (!Number.isFinite(handle.size) || handle.size <= 0) {
121
+ try {
122
+ handle.close?.();
123
+ } catch {
124
+ // Already closed.
125
+ }
126
+ throw new ChatError(`${file.name} can't be read on this device`, "22023", "upload");
127
+ }
128
+ return {
129
+ size: handle.size,
130
+ read: async (from, to) => {
131
+ const bytes = await handle.read(from, to - from).catch((e: unknown) => {
132
+ throw new ChatError(`${file.name} can't be read on this device${e instanceof Error ? `: ${e.message}` : ""}`, "22023", "upload");
133
+ });
134
+ // A short read would send a hole in the file: fail instead.
135
+ if (bytes.byteLength !== to - from) throw new ChatError(`${file.name} changed while it was sent`, "22023", "upload");
136
+ return exact(bytes);
137
+ },
138
+ close: () => {
139
+ try {
140
+ handle.close?.();
141
+ } catch {
142
+ // Already closed.
143
+ }
144
+ },
145
+ };
146
+ }
147
+ if (data instanceof ArrayBuffer) return inMemory(data);
148
+ return { size: data.size, read: (from, to) => data.slice(from, to).arrayBuffer(), close: () => undefined };
149
+ }
150
+
151
+ /**
152
+ * Whether the object is really in the bucket (a 409 on create says so, and may be wrong).
153
+ * Storage answers a missing object with 400 (or 404); a refusal is a refusal, anything else is
154
+ * asked again.
155
+ */
156
+ async function objectExists(path: string): Promise<boolean> {
157
+ const base = options.supabaseUrl.replace(/\/+$/, "");
158
+ const encoded = path.split("/").map(encodeURIComponent).join("/");
159
+ const res = await doFetch(`${base}/storage/v1/object/authenticated/${bucket}/${encoded}`, { method: "HEAD", headers: await headers() });
160
+ if (res.ok) return true;
161
+ if (res.status === 400 || res.status === 404) return false;
162
+ return refuse(res, "upload");
163
+ }
164
+
165
+ async function withRetry<T>(op: string, run: () => Promise<T>): Promise<T> {
166
+ let last: unknown;
167
+ for (let attempt = 0; attempt <= retries; attempt++) {
168
+ try {
169
+ return await run();
170
+ } catch (e) {
171
+ last = e;
172
+ if (e instanceof ChatError && !e.transient) throw e;
173
+ await new Promise((r) => setTimeout(r, Math.min(4000, 250 * 2 ** attempt)));
174
+ }
175
+ }
176
+ throw last instanceof ChatError ? last : new ChatError(last instanceof Error ? last.message : String(last), undefined, op);
177
+ }
178
+
179
+ const refuse = async (res: Response, op: string): Promise<never> => {
180
+ const text = await res.text().catch(() => "");
181
+ // A network-ish status is transient (no code); the rest are refusals.
182
+ throw new ChatError(text || `Upload failed (${res.status})`, transientStatus(res.status) ? undefined : res.status === 403 || res.status === 401 ? "42501" : "22023", op);
183
+ };
184
+
185
+ async function upload(conversationId: string, file: LocalFile): Promise<string> {
186
+ const ext = fileExtension(file.name, file.contentType);
187
+ if (!isAllowedExtension(ext)) throw new ChatError(`Files of type .${ext} can't be sent`, "22023", "upload");
188
+ let record = byFile.get(file);
189
+ if (!record) {
190
+ const userId = typeof options.userId === "function" ? options.userId() : options.userId;
191
+ record = { path: `${conversationId}/${userId}/${newId()}.${ext}` };
192
+ }
193
+ byFile.set(file, record);
194
+ if (record.done) return record.path;
195
+
196
+ const source = await sourceOf(file);
197
+ try {
198
+ return await send(file, record, source);
199
+ } finally {
200
+ source.close();
201
+ }
202
+ };
203
+
204
+ async function send(file: LocalFile, rec: UploadRecord, source: Source): Promise<string> {
205
+ const length = source.size;
206
+
207
+ // Create the upload (once), or find how far an earlier attempt got.
208
+ let offset = 0;
209
+ if (rec.location) {
210
+ offset = await withRetry("upload", async () => {
211
+ const res = await doFetch(rec.location!, { method: "HEAD", headers: await headers() });
212
+ if (res.status === 404 || res.status === 410) {
213
+ rec.location = undefined;
214
+ return 0;
215
+ }
216
+ if (!res.ok) return refuse(res, "upload");
217
+ return Number(res.headers.get("upload-offset") ?? 0);
218
+ });
219
+ }
220
+ // A 409 at a path whose object isn't there (another upload of it never finished): one new path.
221
+ let fresh = false;
222
+ while (!rec.location) {
223
+ const metadata = [
224
+ `bucketName ${base64Utf8(bucket)}`,
225
+ `objectName ${base64Utf8(rec.path)}`,
226
+ `contentType ${base64Utf8(file.contentType)}`,
227
+ `cacheControl ${base64Utf8("3600")}`,
228
+ ].join(",");
229
+ const created = await withRetry("upload", async () => {
230
+ const res = await doFetch(endpoint, {
231
+ method: "POST",
232
+ headers: await headers({ "upload-length": String(length), "upload-metadata": metadata, "x-upsert": "false" }),
233
+ });
234
+ // Already there, says the server: an earlier attempt finished but its answer was lost, or
235
+ // another upload of this path is under way. Only the object itself settles which.
236
+ if (res.status === 409) return (await objectExists(rec.path)) ? "done" : "conflict";
237
+ if (res.status !== 201) return refuse(res, "upload");
238
+ const location = res.headers.get("location");
239
+ if (!location) throw new ChatError("The upload server gave no location", "22023", "upload");
240
+ return new URL(location, endpoint).toString();
241
+ });
242
+ if (created === "done") {
243
+ rec.done = true;
244
+ return rec.path;
245
+ }
246
+ if (created === "conflict") {
247
+ if (fresh) throw new ChatError("The upload is out of step", undefined, "upload");
248
+ fresh = true;
249
+ rec.path = `${rec.path.slice(0, rec.path.lastIndexOf("/") + 1)}${newId()}.${rec.path.slice(rec.path.lastIndexOf(".") + 1)}`;
250
+ continue;
251
+ }
252
+ rec.location = created;
253
+ offset = 0;
254
+ }
255
+
256
+ while (offset < length) {
257
+ const from = offset;
258
+ const body = await source.read(from, Math.min(length, from + chunk));
259
+ offset = await withRetry("upload", async () => {
260
+ const res = await doFetch(rec.location!, {
261
+ method: "PATCH",
262
+ headers: await headers({ "upload-offset": String(from), "content-type": "application/offset+octet-stream" }),
263
+ body,
264
+ });
265
+ if (res.status === 409) {
266
+ // Out of step (an earlier chunk did land): ask where the server is.
267
+ const head = await doFetch(rec.location!, { method: "HEAD", headers: await headers() });
268
+ const at = Number(head.headers.get("upload-offset"));
269
+ // No answer: a transient failure, so the backoff applies before the next try.
270
+ if (!head.ok || !Number.isFinite(at) || at === from) throw new ChatError("The upload is out of step", undefined, "upload");
271
+ return at;
272
+ }
273
+ if (res.status !== 204 && !res.ok) return refuse(res, "upload");
274
+ return Number(res.headers.get("upload-offset") ?? from + body.byteLength);
275
+ });
276
+ }
277
+ rec.done = true;
278
+ return rec.path;
279
+ }
280
+
281
+ return upload;
282
+ }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @feat:chat
3
+ * The composer's state for one thread, with its draft (per signed-in person):
4
+ * restored when the thread opens, saved a moment after typing stops and when
5
+ * the screen closes or moves to another thread, cleared on send. The thread screen owns it, so a swipe or the long-press
6
+ * sheet can set what the next message replies to.
7
+ */
8
+ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
9
+ import { composerReducer, createDraftSaver, draftKey, emptyComposer, loadDraft, memoryDraftStorage, toSend, type ComposerAction, type ComposerState, type ReplyTarget } from "./composer-state";
10
+ import { useChatNative } from "./context";
11
+
12
+ const memoryDrafts = memoryDraftStorage();
13
+
14
+ /** `userId` is the signed-in person's (`useChat().userId`): drafts are kept per person. */
15
+ export function useChatComposer(conversationId: string, threadRootId: string | null | undefined, userId: string) {
16
+ const { modules } = useChatNative();
17
+ const storage = modules.drafts ?? memoryDrafts;
18
+ const key = draftKey(userId, conversationId, threadRootId);
19
+ const [state, dispatch] = useReducer(composerReducer, emptyComposer);
20
+ const saver = useMemo(() => createDraftSaver(storage, key), [storage, key]);
21
+ const restored = useRef<string | null>(null);
22
+ // The key the state belongs to. Another conversation in the same screen starts empty, so one
23
+ // thread's text, reply or files never show, save or send in the next (the draft flushed for the
24
+ // old key first, in the cleanup below).
25
+ // Kept in state, not a ref written during render (React's "storing information from previous
26
+ // renders"): a render React drops would leave a ref on the new key with the reset never applied.
27
+ const [stateKey, setStateKey] = useState(key);
28
+ if (stateKey !== key) {
29
+ setStateKey(key);
30
+ dispatch({ type: "reset" });
31
+ }
32
+
33
+ useEffect(() => {
34
+ let live = true;
35
+ restored.current = null;
36
+ void loadDraft(storage, key).then((draft) => {
37
+ if (!live) return;
38
+ if (draft) dispatch({ type: "restore", draft });
39
+ // Only this key's own load: a late answer for a thread left behind must not stop this one saving.
40
+ restored.current = key;
41
+ });
42
+ return () => {
43
+ live = false;
44
+ saver.flush();
45
+ };
46
+ }, [storage, key, saver]);
47
+
48
+ useEffect(() => {
49
+ // Don't overwrite the stored draft with the empty state before it has been read.
50
+ if (restored.current !== key) return;
51
+ saver.save({ text: state.text, replyTo: state.replyTo });
52
+ }, [state.text, state.replyTo, saver, key]);
53
+
54
+ const setReply = useCallback((to: ReplyTarget | null) => dispatch({ type: "reply", to }), []);
55
+
56
+ // A second tap before the re-render must not send the same message again.
57
+ const taken = useRef<ComposerState | null>(null);
58
+ /** What to send (null when this state was already sent), and empties the composer and its draft. */
59
+ const take = useCallback(() => {
60
+ if (taken.current === state) return null;
61
+ taken.current = state;
62
+ const payload = toSend(state);
63
+ dispatch({ type: "sent" });
64
+ saver.clear();
65
+ return payload;
66
+ }, [state, saver]);
67
+
68
+ return { state, dispatch: dispatch as (action: ComposerAction) => void, setReply, take };
69
+ }
70
+
71
+ export type ChatComposerHandle = ReturnType<typeof useChatComposer>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * @feat:chat
3
+ * Going to one message in a thread (a search result, a pinned message): earlier pages load until
4
+ * it is held (`useConversation().loadUntil(seq)`), then the list scrolls to it and it is
5
+ * highlighted for two seconds. A message that can't be found (deleted, or out of reach) says so.
6
+ */
7
+ import { useCallback, useEffect, useRef, useState } from "react";
8
+ import type { ThreadRow } from "./rows";
9
+
10
+ export interface JumpTarget {
11
+ messageId: string;
12
+ seq?: number;
13
+ }
14
+
15
+ /** The row a message is drawn in, or -1. */
16
+ export function rowIndexOf(rows: readonly ThreadRow[], messageId: string): number {
17
+ return rows.findIndex((r) => r.type === "message" && r.item.type === "message" && r.item.message.id === messageId);
18
+ }
19
+
20
+ export interface UseJumpOptions {
21
+ /** What to go to; a new target (another id or seq) starts again. */
22
+ target: JumpTarget | null | undefined;
23
+ rows: readonly ThreadRow[];
24
+ /** The thread's first page is still loading. */
25
+ loading: boolean;
26
+ has: (messageId: string) => boolean;
27
+ loadUntil: (seq: number) => Promise<boolean>;
28
+ /** Scroll the list to this row (centred); the message id, for a retry once rows have moved. */
29
+ scrollTo: (index: number, messageId: string) => void;
30
+ /** Called before loading or scrolling (stop following the bottom). */
31
+ onStart?: () => void;
32
+ /** It was scrolled to (say so to VoiceOver). */
33
+ onLanded?: (messageId: string) => void;
34
+ /** It isn't there any more (go back to where the thread was). */
35
+ onGone: () => void;
36
+ onError: (e: unknown) => void;
37
+ }
38
+
39
+ type Pending = JumpTarget & { phase: "new" | "loading" | "loaded" };
40
+
41
+ export function useJumpToMessage({ target, rows, loading, has, loadUntil, scrollTo, onStart, onLanded, onGone, onError }: UseJumpOptions) {
42
+ const [pending, setPending] = useState<Pending | null>(target ? { ...target, phase: "new" } : null);
43
+ const [highlightedId, setHighlightedId] = useState<string | null>(null);
44
+ useEffect(() => {
45
+ if (target) setPending({ ...target, phase: "new" });
46
+ }, [target?.messageId, target?.seq]); // eslint-disable-line react-hooks/exhaustive-deps
47
+
48
+ const latest = useRef({ rows, scrollTo, onStart, onLanded, onGone, onError });
49
+ latest.current = { rows, scrollTo, onStart, onLanded, onGone, onError };
50
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
51
+ useEffect(() => () => {
52
+ if (timer.current) clearTimeout(timer.current);
53
+ }, []);
54
+
55
+ const go = useCallback((messageId: string) => {
56
+ const index = rowIndexOf(latest.current.rows, messageId);
57
+ if (index < 0) return;
58
+ latest.current.onStart?.();
59
+ latest.current.scrollTo(index, messageId);
60
+ latest.current.onLanded?.(messageId);
61
+ setHighlightedId(messageId);
62
+ if (timer.current) clearTimeout(timer.current);
63
+ timer.current = setTimeout(() => setHighlightedId((h) => (h === messageId ? null : h)), 2000);
64
+ }, []);
65
+
66
+ useEffect(() => {
67
+ if (!pending || loading) return;
68
+ const { messageId, seq, phase } = pending;
69
+ if (has(messageId)) {
70
+ setPending(null);
71
+ go(messageId);
72
+ return;
73
+ }
74
+ if (phase === "loading") return;
75
+ if (phase === "loaded" || seq == null) {
76
+ setPending(null);
77
+ latest.current.onGone();
78
+ return;
79
+ }
80
+ latest.current.onStart?.();
81
+ setPending({ messageId, seq, phase: "loading" });
82
+ const settle = () => setPending((p) => (p && p.messageId === messageId ? { ...p, phase: "loaded" } : p));
83
+ void loadUntil(seq).then(settle, (e) => {
84
+ latest.current.onError(e);
85
+ // Not found after all: the "loaded" step says so and puts the thread back.
86
+ settle();
87
+ });
88
+ }, [pending, loading, has, loadUntil, go]);
89
+
90
+ return { highlightedId, jumping: pending !== null };
91
+ }