@marktiderman/genesis-chat 0.2.0 → 0.2.2
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 +82 -7
- package/dist/{chunk-5D5A7I4Q.js → chunk-A4YJX7KT.js} +130 -16
- 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-7S6HYNWW.js → chunk-GBJ3YI4E.js} +72 -5
- package/dist/chunk-GBJ3YI4E.js.map +1 -0
- package/dist/{chunk-PZTOY2LY.js → chunk-JU2FAPBI.js} +46 -13
- package/dist/chunk-JU2FAPBI.js.map +1 -0
- package/dist/{client-DMa2j7kz.d.ts → client-DUIG1hum.d.ts} +36 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/native.d.ts +36 -7
- package/dist/native.js +239 -116
- package/dist/native.js.map +1 -1
- package/dist/{outbox-B0y3phE0.d.ts → outbox-B9NAWQ58.d.ts} +74 -6
- package/dist/push.d.ts +2 -2
- package/dist/react.d.ts +48 -21
- package/dist/react.js +3 -3
- package/dist/server.d.ts +43 -2
- package/dist/server.js +115 -15
- package/dist/server.js.map +1 -1
- package/dist/{thread-DWXHKPdw.d.ts → thread-oPra7pkW.d.ts} +1 -1
- package/dist/web.d.ts +31 -11
- package/dist/web.js +97 -48
- package/dist/web.js.map +1 -1
- package/migrations/0003_genesis_chat_search_threads.sql +85 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +7 -3
- package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
- package/package.json +13 -9
- package/src/__tests__/coaching-core.test.ts +3 -1
- package/src/__tests__/coaching-react.test.tsx +73 -2
- package/src/__tests__/coaching-web.test.tsx +78 -6
- package/src/__tests__/fake-xhr.ts +54 -0
- package/src/__tests__/outbox-hosted.test.ts +269 -0
- package/src/__tests__/review-279.test.ts +41 -0
- package/src/__tests__/review-fixes-web.test.tsx +13 -10
- package/src/__tests__/server-fake-video.test.ts +81 -0
- package/src/__tests__/server-fakes.ts +7 -1
- package/src/__tests__/server-safety.test.ts +17 -4
- package/src/__tests__/web.test.tsx +16 -0
- package/src/core/client.ts +28 -2
- package/src/core/outbox.ts +222 -16
- package/src/core/transfer.ts +71 -0
- package/src/core/types.ts +17 -0
- package/src/index.ts +1 -0
- package/src/native/__tests__/emoji.test.tsx +16 -0
- package/src/native/__tests__/organise-screens.test.tsx +80 -0
- package/src/native/__tests__/video-media.test.tsx +81 -4
- package/src/native/attachment-cards.tsx +22 -6
- package/src/native/inbox-screen.tsx +12 -6
- package/src/native/index.ts +2 -2
- package/src/native/message-actions-sheet.tsx +10 -5
- package/src/native/message-bubble.tsx +3 -11
- package/src/native/modules.ts +2 -0
- package/src/native/schedule-sheet.tsx +23 -9
- package/src/native/search-screen.tsx +26 -5
- package/src/native/thread-screen.tsx +30 -3
- package/src/native/topics.tsx +70 -15
- package/src/native/video-player.tsx +46 -6
- package/src/native/video-recorder.tsx +6 -3
- package/src/native/voice-player.tsx +18 -0
- package/src/react/index.tsx +103 -19
- package/src/react/previews.ts +51 -0
- package/src/server/fake-video.ts +171 -0
- package/src/server/index.ts +1 -0
- package/src/web/attachments.tsx +6 -6
- package/src/web/bubble.tsx +22 -0
- package/src/web/inbox.tsx +7 -6
- package/src/web/memory-client.ts +11 -3
- package/src/web/panel.tsx +2 -2
- package/src/web/schedule.tsx +14 -2
- package/src/web/thread.tsx +36 -8
- package/src/web/video.tsx +28 -34
- package/dist/chunk-5D5A7I4Q.js.map +0 -1
- package/dist/chunk-7S6HYNWW.js.map +0 -1
- package/dist/chunk-PZTOY2LY.js.map +0 -1
package/src/core/outbox.ts
CHANGED
|
@@ -8,11 +8,19 @@
|
|
|
8
8
|
* and only sent again by hand. `clear()` (sign-out) stops sends in flight: none
|
|
9
9
|
* posts, or saves anything, afterwards. Storage is the platform's (memory by default;
|
|
10
10
|
* localStorage on web, MMKV or SQLite on native), behind OutboxStore.
|
|
11
|
+
*
|
|
12
|
+
* A hosted video (`LocalFile.hosted`, with `hostedUpload`) doesn't wait for its bytes: the
|
|
13
|
+
* upload is started (the provider's upload id minted), the message is sent in its place in the
|
|
14
|
+
* order with the attachment `pending`, and the bytes carry on going up; the provider's webhook
|
|
15
|
+
* marks it ready. The message stays here (hidden from the thread once the server has it) until
|
|
16
|
+
* its bytes are up, so a relaunch carries the upload on (`resume`) and sends the message again
|
|
17
|
+
* with the same clientId, which posts nothing twice.
|
|
11
18
|
*/
|
|
12
19
|
import type { ChatClient } from "./client";
|
|
13
20
|
import { ChatError } from "./client";
|
|
14
21
|
import { attachmentKindFor, randomId } from "./media";
|
|
15
|
-
import type {
|
|
22
|
+
import type { UploadProgress } from "./transfer";
|
|
23
|
+
import type { AttachmentInput, ChatMessage, HostedAttachment, PendingFile, PendingMessage, SendInput } from "./types";
|
|
16
24
|
|
|
17
25
|
/** A file still on the device, uploaded when its message is sent. */
|
|
18
26
|
export interface LocalFile {
|
|
@@ -24,8 +32,50 @@ export interface LocalFile {
|
|
|
24
32
|
previewUrl?: string;
|
|
25
33
|
/** Known up front: a voice note's duration and waveform, a photo's size. */
|
|
26
34
|
meta?: Partial<Omit<AttachmentInput, "path" | "kind">> & { kind?: AttachmentInput["kind"] };
|
|
35
|
+
/**
|
|
36
|
+
* Send it to the hosted video provider (the outbox's `hostedUpload`) instead of storage: the
|
|
37
|
+
* message goes at once, in order, with the attachment pending, while the bytes go up. Without
|
|
38
|
+
* a `hostedUpload` it goes to storage like any file.
|
|
39
|
+
*/
|
|
40
|
+
hosted?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A hosted upload as the outbox keeps it (saved with the message), so it can be carried on. */
|
|
44
|
+
export interface HostedUploadRecord {
|
|
45
|
+
provider: HostedAttachment["provider"];
|
|
46
|
+
providerId: string;
|
|
47
|
+
/** Where the bytes go, when the hook returned it: what a resume needs. */
|
|
48
|
+
url?: string;
|
|
49
|
+
/** Every byte is there. */
|
|
50
|
+
uploaded?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** What `hostedUpload` resolves with as soon as the upload exists (before the bytes are up). */
|
|
54
|
+
export interface HostedUploadStart extends HostedAttachment {
|
|
55
|
+
/** Where the bytes go; kept with the message, handed back as `resume.url`. */
|
|
56
|
+
url?: string;
|
|
57
|
+
/** Settles when every byte is there (rejects when the transfer fails). Absent: already there. */
|
|
58
|
+
done?: Promise<void>;
|
|
27
59
|
}
|
|
28
60
|
|
|
61
|
+
export interface HostedUploadOptions {
|
|
62
|
+
/** Aborts when the message is discarded or the outbox cleared. */
|
|
63
|
+
signal?: AbortSignal;
|
|
64
|
+
onProgress?: UploadProgress;
|
|
65
|
+
/**
|
|
66
|
+
* The upload started before (a relaunch, a retry after the transfer failed): carry it on and
|
|
67
|
+
* resolve with the same `providerId`. A message may already be posted with it, so mint a new
|
|
68
|
+
* one only when the old upload is gone; that message's video then stays pending.
|
|
69
|
+
*/
|
|
70
|
+
resume?: HostedUploadRecord;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Starts (or carries on) a hosted upload; resolves once the provider's upload id exists. */
|
|
74
|
+
export type HostedUpload = (conversationId: string, file: LocalFile, opts: HostedUploadOptions) => Promise<HostedUploadStart>;
|
|
75
|
+
|
|
76
|
+
/** Uploads a file to storage and resolves with its path. */
|
|
77
|
+
export type StorageUpload = (conversationId: string, file: LocalFile, opts?: { signal?: AbortSignal; onProgress?: UploadProgress }) => Promise<string>;
|
|
78
|
+
|
|
29
79
|
export interface QueuedMessage extends Omit<PendingMessage, "files"> {
|
|
30
80
|
files: LocalFile[];
|
|
31
81
|
/** What each file is, kept with the saved copy (PendingMessage.files). */
|
|
@@ -33,10 +83,20 @@ export interface QueuedMessage extends Omit<PendingMessage, "files"> {
|
|
|
33
83
|
/** Files lost on a relaunch (bytes that weren't persisted). */
|
|
34
84
|
lostFiles?: number;
|
|
35
85
|
attempts: number;
|
|
86
|
+
/** Hosted uploads started, by file slot. */
|
|
87
|
+
hosted?: Record<number, HostedUploadRecord>;
|
|
88
|
+
/** Posted (this message's id): it waits here only for its hosted bytes to finish. */
|
|
89
|
+
sentMessageId?: string;
|
|
36
90
|
}
|
|
37
91
|
|
|
38
92
|
function describe(f: LocalFile): PendingFile {
|
|
39
|
-
return {
|
|
93
|
+
return {
|
|
94
|
+
name: f.name,
|
|
95
|
+
contentType: f.contentType,
|
|
96
|
+
kind: f.meta?.kind ?? attachmentKindFor(f.contentType),
|
|
97
|
+
meta: f.meta,
|
|
98
|
+
...(f.hosted ? { hosted: true } : {}),
|
|
99
|
+
};
|
|
40
100
|
}
|
|
41
101
|
|
|
42
102
|
export interface OutboxStore {
|
|
@@ -55,8 +115,18 @@ export interface OutboxOptions {
|
|
|
55
115
|
store?: OutboxStore;
|
|
56
116
|
/** Reads a native file URI into bytes for upload. */
|
|
57
117
|
readFile?: (uri: string) => Promise<Blob | ArrayBuffer>;
|
|
58
|
-
/**
|
|
59
|
-
|
|
118
|
+
/**
|
|
119
|
+
* Upload hook, e.g. resumable uploads on native. Defaults to client.upload. `signal` aborts
|
|
120
|
+
* when the message is discarded or the outbox cleared; call `onProgress(sent, total)` as bytes
|
|
121
|
+
* go, and the pending bubble shows it (`PendingMessage.files[i].progress`).
|
|
122
|
+
*/
|
|
123
|
+
upload?: StorageUpload;
|
|
124
|
+
/**
|
|
125
|
+
* Starts a hosted video upload for a file marked `hosted` and resolves once the provider's
|
|
126
|
+
* upload id exists, with `done` for the bytes. See HostedUpload; without it, hosted files go
|
|
127
|
+
* to storage.
|
|
128
|
+
*/
|
|
129
|
+
hostedUpload?: HostedUpload;
|
|
60
130
|
/** Called with every change, for the UI. */
|
|
61
131
|
onChange?: (queue: QueuedMessage[]) => void;
|
|
62
132
|
/** Called when a message lands. */
|
|
@@ -95,6 +165,9 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
95
165
|
const running = new Map<string, number>(); // conversationId -> the generation running it
|
|
96
166
|
const aborts = new Map<string, AbortController>(); // clientId -> its uploads' controller
|
|
97
167
|
const uploaded = new Map<string, string>(); // clientId:index -> path
|
|
168
|
+
// Hosted bytes still going up, by clientId:index, and the controller that stops them.
|
|
169
|
+
const transfers = new Map<string, Promise<void>>();
|
|
170
|
+
const hostedAborts = new Map<string, AbortController>();
|
|
98
171
|
|
|
99
172
|
const release = opts.releasePreview ?? releaseObjectUrl;
|
|
100
173
|
const releasePreviews = (q: QueuedMessage) => {
|
|
@@ -128,6 +201,23 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
128
201
|
void store.save(queue.map(saveable));
|
|
129
202
|
};
|
|
130
203
|
|
|
204
|
+
/** A file's upload progress, for the UI only (not saved). */
|
|
205
|
+
const setProgress = (q: QueuedMessage, index: number, sent: number, total: number) => {
|
|
206
|
+
const slots = q.pendingFiles;
|
|
207
|
+
if (!slots?.[index] || !queue.includes(q)) return;
|
|
208
|
+
// Whole percents: the bar shows no finer, and each change re-renders every chat hook.
|
|
209
|
+
const progress = total > 0 ? Math.round(Math.min(1, Math.max(0, sent / total)) * 100) / 100 : 0;
|
|
210
|
+
if (slots[index]!.progress === progress) return;
|
|
211
|
+
q.pendingFiles = slots.map((slot, j) => (j === index ? { ...slot, progress } : slot));
|
|
212
|
+
opts.onChange?.([...queue]);
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
const stopHosted = (clientId: string) => {
|
|
216
|
+
hostedAborts.get(clientId)?.abort();
|
|
217
|
+
hostedAborts.delete(clientId);
|
|
218
|
+
for (const k of [...transfers.keys()]) if (k.startsWith(`${clientId}:`)) transfers.delete(k);
|
|
219
|
+
};
|
|
220
|
+
|
|
131
221
|
/** Swaps a byte file for a saved URI, so it survives a relaunch. */
|
|
132
222
|
async function persist(q: QueuedMessage) {
|
|
133
223
|
if (!opts.persistFile) return;
|
|
@@ -165,8 +255,12 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
165
255
|
.filter((q) => !live.has(q.clientId))
|
|
166
256
|
.map((q) => {
|
|
167
257
|
const lost = (q.pendingFiles ?? []).filter((f) => f.lost).length;
|
|
258
|
+
// Posted, its hosted bytes still going up when the app closed: it is sent again (the
|
|
259
|
+
// same clientId posts nothing twice) after its upload is carried on.
|
|
260
|
+
const rest: QueuedMessage = { ...q };
|
|
261
|
+
delete rest.sentMessageId;
|
|
168
262
|
return {
|
|
169
|
-
...
|
|
263
|
+
...rest,
|
|
170
264
|
status: q.status === "sending" || lost > 0 ? "failed" : q.status,
|
|
171
265
|
error: lost > 0 ? "Attach the file again to send" : (q.error ?? "Not sent"),
|
|
172
266
|
files: q.files ?? [],
|
|
@@ -182,10 +276,74 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
182
276
|
return loading;
|
|
183
277
|
}
|
|
184
278
|
|
|
185
|
-
async function uploadFile(conversationId: string, file: LocalFile, signal?: AbortSignal): Promise<string> {
|
|
186
|
-
if (opts.upload) return opts.upload(conversationId, file, { signal });
|
|
279
|
+
async function uploadFile(conversationId: string, file: LocalFile, signal?: AbortSignal, onProgress?: UploadProgress): Promise<string> {
|
|
280
|
+
if (opts.upload) return opts.upload(conversationId, file, { signal, onProgress });
|
|
187
281
|
const data = typeof file.data === "string" ? await (opts.readFile ?? notReadable)(file.data) : file.data;
|
|
188
|
-
return opts.client.upload(conversationId, opts.userId, data, { name: file.name, contentType: file.contentType });
|
|
282
|
+
return opts.client.upload(conversationId, opts.userId, data, { name: file.name, contentType: file.contentType, onProgress, signal });
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const isHosted = (f: LocalFile) => Boolean(f.hosted && opts.hostedUpload);
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The hosted upload for one file slot: the one going up, or a new (or carried-on) one. Resolves
|
|
289
|
+
* once the provider's upload id exists; its bytes go on in `transfers`.
|
|
290
|
+
*/
|
|
291
|
+
async function startHosted(q: QueuedMessage, index: number, file: LocalFile): Promise<HostedUploadRecord> {
|
|
292
|
+
const key = `${q.clientId}:${index}`;
|
|
293
|
+
const known = q.hosted?.[index];
|
|
294
|
+
if (known && (known.uploaded || transfers.has(key))) return known;
|
|
295
|
+
let controller = hostedAborts.get(q.clientId);
|
|
296
|
+
if (!controller && typeof AbortController !== "undefined") {
|
|
297
|
+
controller = new AbortController();
|
|
298
|
+
hostedAborts.set(q.clientId, controller);
|
|
299
|
+
}
|
|
300
|
+
const started = await opts.hostedUpload!(q.input.conversationId, file, {
|
|
301
|
+
signal: controller?.signal,
|
|
302
|
+
onProgress: (sent, total) => setProgress(q, index, sent, total),
|
|
303
|
+
...(known ? { resume: known } : {}),
|
|
304
|
+
});
|
|
305
|
+
const record: HostedUploadRecord = { provider: started.provider, providerId: started.providerId, ...(started.url ? { url: started.url } : {}) };
|
|
306
|
+
q.hosted = { ...q.hosted, [index]: record };
|
|
307
|
+
// Saved at once: a relaunch before the message goes carries this upload on, not a new one.
|
|
308
|
+
if (queue.includes(q)) emit();
|
|
309
|
+
const transfer = Promise.resolve(started.done).then(
|
|
310
|
+
() => {
|
|
311
|
+
record.uploaded = true;
|
|
312
|
+
if (transfers.get(key) === transfer) transfers.delete(key);
|
|
313
|
+
setProgress(q, index, 1, 1);
|
|
314
|
+
},
|
|
315
|
+
(e: unknown) => {
|
|
316
|
+
if (transfers.get(key) === transfer) transfers.delete(key);
|
|
317
|
+
throw e;
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
transfer.catch(() => undefined);
|
|
321
|
+
transfers.set(key, transfer);
|
|
322
|
+
return record;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** A posted message's hosted bytes: once they are all up, it leaves; a failed transfer marks it failed (Retry carries it on). */
|
|
326
|
+
async function settleHosted(q: QueuedMessage, gen: number) {
|
|
327
|
+
const waiting = Object.entries(q.hosted ?? {})
|
|
328
|
+
.filter(([, r]) => !r.uploaded)
|
|
329
|
+
.map(([i]) => transfers.get(`${q.clientId}:${i}`) ?? Promise.reject(new ChatError("The upload stopped", undefined, "upload")));
|
|
330
|
+
try {
|
|
331
|
+
await Promise.all(waiting);
|
|
332
|
+
} catch (e) {
|
|
333
|
+
if (gen !== generation || !queue.includes(q) || q.status !== "sending") return;
|
|
334
|
+
q.attempts += 1;
|
|
335
|
+
q.status = "failed";
|
|
336
|
+
q.error = e instanceof ChatError && e.code === "GSABORT" ? "The upload was cancelled" : "The video didn't finish uploading";
|
|
337
|
+
emit();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
if (gen !== generation || !queue.includes(q)) return;
|
|
341
|
+
// A retry may have started more meanwhile: wait for those too.
|
|
342
|
+
if (Object.values(q.hosted ?? {}).some((r) => !r.uploaded)) return void settleHosted(q, gen);
|
|
343
|
+
queue = queue.filter((x) => x !== q);
|
|
344
|
+
hostedAborts.delete(q.clientId);
|
|
345
|
+
emit();
|
|
346
|
+
releasePreviews(q);
|
|
189
347
|
}
|
|
190
348
|
|
|
191
349
|
async function run(conversationId: string) {
|
|
@@ -199,7 +357,8 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
199
357
|
if (gen !== generation) return;
|
|
200
358
|
// In the order written: the first message still here decides. One that failed (a lost
|
|
201
359
|
// file, a refusal, no connection) holds back the ones after it until it is retried.
|
|
202
|
-
|
|
360
|
+
// A posted message waiting only for its hosted bytes holds nothing back.
|
|
361
|
+
const next = queue.find((q) => q.input.conversationId === conversationId && !q.sentMessageId);
|
|
203
362
|
if (!next || next.status !== "sending") break;
|
|
204
363
|
if ((next.lostFiles ?? 0) > 0) {
|
|
205
364
|
next.status = "failed";
|
|
@@ -213,10 +372,25 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
213
372
|
const attachments: AttachmentInput[] = [...(next.input.attachments ?? [])];
|
|
214
373
|
for (let i = 0; i < next.files.length; i++) {
|
|
215
374
|
const file = next.files[i]!;
|
|
375
|
+
if (isHosted(file)) {
|
|
376
|
+
const hosted = await startHosted(next, i, file);
|
|
377
|
+
if (stale(next)) return;
|
|
378
|
+
attachments.push({
|
|
379
|
+
...file.meta,
|
|
380
|
+
kind: file.meta?.kind ?? attachmentKindFor(file.contentType),
|
|
381
|
+
provider: hosted.provider,
|
|
382
|
+
providerId: hosted.providerId,
|
|
383
|
+
status: "pending",
|
|
384
|
+
name: file.meta?.name ?? file.name,
|
|
385
|
+
mime: file.contentType,
|
|
386
|
+
});
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
216
389
|
const key = `${next.clientId}:${i}`;
|
|
217
390
|
let path = uploaded.get(key);
|
|
218
391
|
if (!path) {
|
|
219
|
-
|
|
392
|
+
const index = i;
|
|
393
|
+
path = await uploadFile(conversationId, file, controller?.signal, (sent, total) => setProgress(next, index, sent, total));
|
|
220
394
|
if (stale(next)) return;
|
|
221
395
|
uploaded.set(key, path);
|
|
222
396
|
}
|
|
@@ -232,8 +406,18 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
232
406
|
const message = await opts.client.send({ ...next.input, attachments });
|
|
233
407
|
// Posted, but the person signed out meanwhile: the next session must not hear of it.
|
|
234
408
|
if (gen !== generation) return;
|
|
235
|
-
queue = queue.filter((q) => q.clientId !== next.clientId);
|
|
236
409
|
for (let i = 0; i < next.files.length; i++) uploaded.delete(`${next.clientId}:${i}`);
|
|
410
|
+
if (Object.values(next.hosted ?? {}).some((r) => !r.uploaded)) {
|
|
411
|
+
// Posted with its video pending: it stays (out of the thread, which has the server's
|
|
412
|
+
// copy) until the bytes are up, so a relaunch can carry the upload on.
|
|
413
|
+
next.sentMessageId = message.id;
|
|
414
|
+
emit();
|
|
415
|
+
opts.onSent?.(message, next.clientId);
|
|
416
|
+
void settleHosted(next, gen);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
queue = queue.filter((q) => q.clientId !== next.clientId);
|
|
420
|
+
hostedAborts.delete(next.clientId);
|
|
237
421
|
emit();
|
|
238
422
|
opts.onSent?.(message, next.clientId);
|
|
239
423
|
// The bubble now draws the stored copy: the local preview can go.
|
|
@@ -259,7 +443,15 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
259
443
|
|
|
260
444
|
/** Can this failed message go without the person's help (retryAll, coming back online)? */
|
|
261
445
|
const canAutoRetry = (x: QueuedMessage) =>
|
|
262
|
-
!x.permanent &&
|
|
446
|
+
!x.permanent &&
|
|
447
|
+
!(x.lostFiles ?? 0) &&
|
|
448
|
+
x.files.every((f) => typeof f.data !== "string" || Boolean(isHosted(f) ? opts.hostedUpload : opts.readFile || opts.upload));
|
|
449
|
+
|
|
450
|
+
/** A failed message goes again: a posted one (its transfer failed) re-sends, idempotently, after carrying its upload on. */
|
|
451
|
+
const requeue = (x: QueuedMessage) => {
|
|
452
|
+
x.status = "sending";
|
|
453
|
+
delete x.sentMessageId;
|
|
454
|
+
};
|
|
263
455
|
|
|
264
456
|
return {
|
|
265
457
|
/** Queues a message; it shows at once and sends in order. Returns its clientId. */
|
|
@@ -301,7 +493,7 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
301
493
|
for (const x of queue) {
|
|
302
494
|
if (x.input.conversationId !== q.input.conversationId || x.status !== "failed") continue;
|
|
303
495
|
if ((x.lostFiles ?? 0) > 0) break;
|
|
304
|
-
x
|
|
496
|
+
requeue(x);
|
|
305
497
|
x.permanent = false;
|
|
306
498
|
}
|
|
307
499
|
emit();
|
|
@@ -354,7 +546,7 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
354
546
|
const gen = generation;
|
|
355
547
|
await persist(q);
|
|
356
548
|
if (gen !== generation || !queue.includes(q)) return;
|
|
357
|
-
q
|
|
549
|
+
requeue(q);
|
|
358
550
|
emit();
|
|
359
551
|
void run(q.input.conversationId);
|
|
360
552
|
},
|
|
@@ -372,10 +564,11 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
372
564
|
const c = x.input.conversationId;
|
|
373
565
|
if (held.has(c) || x.status !== "failed") continue;
|
|
374
566
|
if (!canAutoRetry(x)) {
|
|
375
|
-
|
|
567
|
+
// A posted one (only its upload failed) holds nothing back.
|
|
568
|
+
if (!x.sentMessageId) held.add(c);
|
|
376
569
|
continue;
|
|
377
570
|
}
|
|
378
|
-
x
|
|
571
|
+
requeue(x);
|
|
379
572
|
conversations.add(c);
|
|
380
573
|
}
|
|
381
574
|
emit();
|
|
@@ -388,6 +581,9 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
388
581
|
generation += 1;
|
|
389
582
|
for (const c of aborts.values()) c.abort();
|
|
390
583
|
aborts.clear();
|
|
584
|
+
for (const c of hostedAborts.values()) c.abort();
|
|
585
|
+
hostedAborts.clear();
|
|
586
|
+
transfers.clear();
|
|
391
587
|
running.clear();
|
|
392
588
|
const gone = queue;
|
|
393
589
|
queue = [];
|
|
@@ -404,10 +600,20 @@ export function createOutbox(opts: OutboxOptions) {
|
|
|
404
600
|
queue = queue.filter((q) => q.clientId !== clientId);
|
|
405
601
|
aborts.get(clientId)?.abort();
|
|
406
602
|
aborts.delete(clientId);
|
|
603
|
+
stopHosted(clientId);
|
|
407
604
|
if (gone) releasePreviews(gone);
|
|
408
605
|
emit();
|
|
409
606
|
},
|
|
410
607
|
|
|
608
|
+
/**
|
|
609
|
+
* Uploads one file to storage the way the outbox does (the `upload` hook, or `readFile` then
|
|
610
|
+
* the client's upload) and resolves with its path: for files sent outside the queue, such as
|
|
611
|
+
* a scheduled message's.
|
|
612
|
+
*/
|
|
613
|
+
uploadFile(conversationId: string, file: LocalFile, o: { signal?: AbortSignal; onProgress?: UploadProgress } = {}): Promise<string> {
|
|
614
|
+
return uploadFile(conversationId, file, o.signal, o.onProgress);
|
|
615
|
+
},
|
|
616
|
+
|
|
411
617
|
pending(conversationId?: string): PendingMessage[] {
|
|
412
618
|
return queue
|
|
413
619
|
.filter((q) => !conversationId || q.input.conversationId === conversationId)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Sending bytes with progress, the same on every platform. fetch can't report how much of a
|
|
4
|
+
* request body has gone, so where XMLHttpRequest exists (browsers, React Native) the body goes
|
|
5
|
+
* through it and `onProgress(sent, total)` follows `upload.onprogress`; elsewhere it goes
|
|
6
|
+
* through fetch and reports once, at the end.
|
|
7
|
+
*/
|
|
8
|
+
import { ChatError } from "./client";
|
|
9
|
+
|
|
10
|
+
export type UploadProgress = (sent: number, total: number) => void;
|
|
11
|
+
|
|
12
|
+
export interface SendBytesOptions {
|
|
13
|
+
method?: "PUT" | "POST";
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
onProgress?: UploadProgress;
|
|
17
|
+
/** For tests and runtimes without XMLHttpRequest. */
|
|
18
|
+
fetch?: typeof fetch;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sizeOf = (body: Blob | ArrayBuffer) => (typeof Blob !== "undefined" && body instanceof Blob ? body.size : (body as ArrayBuffer).byteLength);
|
|
22
|
+
|
|
23
|
+
/** A failed answer's code: a 5xx, 408 or no answer is worth retrying, 429 waits, any other 4xx is a refusal. */
|
|
24
|
+
const refusal = (status: number) => (status >= 500 || status === 0 || status === 408 ? undefined : status === 429 ? "GS429" : "22023");
|
|
25
|
+
|
|
26
|
+
const aborted = () => new ChatError("The upload was cancelled", "GSABORT", "upload");
|
|
27
|
+
|
|
28
|
+
/** Sends `body` to `url`; resolves with the response text, throws a ChatError on a non-2xx answer. */
|
|
29
|
+
export function sendBytes(url: string, body: Blob | ArrayBuffer, opts: SendBytesOptions = {}): Promise<string> {
|
|
30
|
+
const method = opts.method ?? "PUT";
|
|
31
|
+
const total = sizeOf(body);
|
|
32
|
+
if (opts.signal?.aborted) return Promise.reject(aborted());
|
|
33
|
+
if (typeof XMLHttpRequest === "undefined" || opts.fetch) {
|
|
34
|
+
const f = opts.fetch ?? fetch;
|
|
35
|
+
return f(url, { method, body, headers: opts.headers, signal: opts.signal }).then(async (res) => {
|
|
36
|
+
if (!res.ok) throw new ChatError(`The upload failed (${res.status})`, refusal(res.status), "upload");
|
|
37
|
+
opts.onProgress?.(total, total);
|
|
38
|
+
return res.text().catch(() => "");
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return new Promise<string>((resolve, reject) => {
|
|
42
|
+
const xhr = new XMLHttpRequest();
|
|
43
|
+
const onAbort = () => xhr.abort();
|
|
44
|
+
const done = () => opts.signal?.removeEventListener("abort", onAbort);
|
|
45
|
+
xhr.open(method, url);
|
|
46
|
+
for (const [k, v] of Object.entries(opts.headers ?? {})) xhr.setRequestHeader(k, v);
|
|
47
|
+
if (opts.onProgress && xhr.upload) {
|
|
48
|
+
xhr.upload.onprogress = (e: ProgressEvent) => opts.onProgress?.(e.loaded, e.lengthComputable ? e.total : total);
|
|
49
|
+
}
|
|
50
|
+
xhr.onload = () => {
|
|
51
|
+
done();
|
|
52
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
53
|
+
opts.onProgress?.(total, total);
|
|
54
|
+
resolve(typeof xhr.responseText === "string" ? xhr.responseText : "");
|
|
55
|
+
} else {
|
|
56
|
+
reject(new ChatError(`The upload failed (${xhr.status})`, refusal(xhr.status), "upload"));
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
// No answer at all: the connection, worth retrying.
|
|
60
|
+
xhr.onerror = () => {
|
|
61
|
+
done();
|
|
62
|
+
reject(new ChatError("The upload didn't reach the server", undefined, "upload"));
|
|
63
|
+
};
|
|
64
|
+
xhr.onabort = () => {
|
|
65
|
+
done();
|
|
66
|
+
reject(aborted());
|
|
67
|
+
};
|
|
68
|
+
opts.signal?.addEventListener("abort", onAbort, { once: true });
|
|
69
|
+
xhr.send(body);
|
|
70
|
+
});
|
|
71
|
+
}
|
package/src/core/types.ts
CHANGED
|
@@ -204,6 +204,21 @@ export interface PendingFile {
|
|
|
204
204
|
meta?: Partial<AttachmentInput>;
|
|
205
205
|
/** Not kept between launches (bytes with nowhere to save them): attach it again. */
|
|
206
206
|
lost?: boolean;
|
|
207
|
+
/** How much of it has uploaded, 0 to 1, once the upload reports progress. */
|
|
208
|
+
progress?: number;
|
|
209
|
+
/** Going to the hosted video provider (Mux, Cloudflare) rather than storage. */
|
|
210
|
+
hosted?: boolean;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A hosted video upload that has started: the provider's upload id, sent with the message as
|
|
215
|
+
* `{ provider, providerId, status: "pending" }` while the bytes still go up. The provider's
|
|
216
|
+
* webhook marks the attachment ready.
|
|
217
|
+
*/
|
|
218
|
+
export interface HostedAttachment {
|
|
219
|
+
provider: "mux" | "cloudflare";
|
|
220
|
+
providerId: string;
|
|
221
|
+
status: "pending";
|
|
207
222
|
}
|
|
208
223
|
|
|
209
224
|
/** A live hint from Realtime: something changed; fetch to see what. */
|
|
@@ -296,6 +311,8 @@ export interface ChatSearchHit {
|
|
|
296
311
|
snippet: string;
|
|
297
312
|
/** Found in a voice note's or video's transcript, not the text. */
|
|
298
313
|
inTranscript: boolean;
|
|
314
|
+
/** The thread the message is a reply in (open that thread to show it); null on the main timeline. */
|
|
315
|
+
threadRootId: string | null;
|
|
299
316
|
}
|
|
300
317
|
|
|
301
318
|
/** Open Graph data for a link (fetched by the server's runner). Plain text: never render it as HTML. */
|
package/src/index.ts
CHANGED
|
@@ -154,6 +154,22 @@ describe("the long-press sheet's +", () => {
|
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
describe("the long-press sheet's caption", () => {
|
|
158
|
+
const context = { me: "u1", role: "member" as const, canPost: true, pinned: false, canPin: false, canCopy: false };
|
|
159
|
+
it("has room under the sheet's top edge, and names an app card by its kind's title", async () => {
|
|
160
|
+
const message = { ...textMessage(), body: "", attachments: [{ id: "a1", kind: "check_in", ref: {} }] } as unknown as ChatMessage;
|
|
161
|
+
render(
|
|
162
|
+
<ChatNativeProvider>
|
|
163
|
+
<MessageActionsSheet message={message} context={context} onClose={vi.fn()} onReact={vi.fn()} onAction={vi.fn()} attachmentKinds={{ check_in: { title: () => "Check-in" } }} testID="acts" />
|
|
164
|
+
</ChatNativeProvider>,
|
|
165
|
+
);
|
|
166
|
+
const caption = await screen.findByTestId("acts-caption");
|
|
167
|
+
expect(caption.textContent).toBe("Check-in");
|
|
168
|
+
const body = JSON.parse(caption.parentElement!.getAttribute("data-style")!) as { paddingTop?: number };
|
|
169
|
+
expect(body.paddingTop).toBeGreaterThanOrEqual(16);
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
157
173
|
function ComposerHarness() {
|
|
158
174
|
const composer = useChatComposer("c1", null, ME);
|
|
159
175
|
return <ChatComposer composer={composer} canPost disabledReason={null} role="member" onSend={vi.fn()} userId="u-composer" testID="cmp" />;
|
|
@@ -13,6 +13,8 @@ import { ChatNativeProvider } from "../context";
|
|
|
13
13
|
import { ChatScheduledList, ChatScheduleSheet } from "../schedule-sheet";
|
|
14
14
|
import { ChatSearchScreen } from "../search-screen";
|
|
15
15
|
import { ChatThreadScreen } from "../thread-screen";
|
|
16
|
+
import { scrollToShow } from "../topics";
|
|
17
|
+
import { activePlaybackId, claimPlayback } from "../voice-player";
|
|
16
18
|
|
|
17
19
|
vi.mock("react-native", async (original) => ({ ...(await original<object>()), useWindowDimensions: () => ({ width: 390, height: 844, scale: 3, fontScale: 1 }) }));
|
|
18
20
|
|
|
@@ -342,3 +344,81 @@ describe("ChatAvailabilityScreen", () => {
|
|
|
342
344
|
await waitFor(() => expect(called(client, "setAvailability")[0]!.args[0]).toMatchObject({ awayMessage: null, awayUntil: null, hours: null }));
|
|
343
345
|
});
|
|
344
346
|
});
|
|
347
|
+
|
|
348
|
+
describe("simulator check leftovers", () => {
|
|
349
|
+
const cohort = (closed: boolean, messages: MemoryConversationSeed["messages"] = []): MemoryConversationSeed => ({
|
|
350
|
+
id: "cohort",
|
|
351
|
+
kind: "cohort",
|
|
352
|
+
members: [{ userId: ME, role: "member" }, { userId: SAM }, { userId: LEE }],
|
|
353
|
+
topics: [{ topic: "week-1", title: "Week 1", closed }],
|
|
354
|
+
messages,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("says a closed topic is closed when it has no messages, instead of inviting a hello", async () => {
|
|
358
|
+
const client = memory([cohort(true)]);
|
|
359
|
+
render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
|
|
360
|
+
fireEvent.click(await screen.findByTestId("t-topics-week-1"));
|
|
361
|
+
await waitFor(() => expect(screen.getByTestId("t-empty").textContent).toContain("This topic is closed."));
|
|
362
|
+
expect(screen.getByTestId("t-empty").textContent).not.toContain("Say hello below.");
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("still invites a hello in an open topic with no messages", async () => {
|
|
366
|
+
const client = memory([cohort(false)]);
|
|
367
|
+
render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
|
|
368
|
+
fireEvent.click(await screen.findByTestId("t-topics-week-1"));
|
|
369
|
+
await waitFor(() => expect(screen.getByTestId("t-empty").textContent).toContain("Say hello below."));
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("pauses what this thread is playing when the screen loses focus, and nothing of another's", async () => {
|
|
373
|
+
const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }], messages: [{ id: "m1", senderId: SAM, body: "", attachments: [{ id: "att-1", kind: "voice", path: "dm/x/a.m4a" }] }] }]);
|
|
374
|
+
const view = render(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
|
|
375
|
+
await screen.findByTestId("t-message-m1");
|
|
376
|
+
const other = vi.fn();
|
|
377
|
+
claimPlayback("elsewhere", other);
|
|
378
|
+
view.rerender(wrap(client, <ChatThreadScreen conversationId="dm" focused={false} testID="t" />));
|
|
379
|
+
expect(other).not.toHaveBeenCalled();
|
|
380
|
+
|
|
381
|
+
view.rerender(wrap(client, <ChatThreadScreen conversationId="dm" testID="t" />));
|
|
382
|
+
const mine = vi.fn();
|
|
383
|
+
claimPlayback("att-1", mine);
|
|
384
|
+
view.rerender(wrap(client, <ChatThreadScreen conversationId="dm" focused={false} testID="t" />));
|
|
385
|
+
expect(mine).toHaveBeenCalledTimes(1);
|
|
386
|
+
expect(activePlaybackId()).toBeNull();
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("names the message in each scheduled row's Cancel, so two at the same time differ", async () => {
|
|
390
|
+
const client = memory([{ id: "dm", members: [{ userId: ME }, { userId: SAM }] }]);
|
|
391
|
+
const at = new Date(Date.now() + 3600_000);
|
|
392
|
+
await client.schedule({ conversationId: "dm", clientId: "c1", body: "first one", sendAt: at });
|
|
393
|
+
await client.schedule({ conversationId: "dm", clientId: "c2", body: "second one", sendAt: at });
|
|
394
|
+
render(wrap(client, <ChatScheduledList conversationId="dm" testID="l" />));
|
|
395
|
+
const cancels = await screen.findAllByText("Cancel");
|
|
396
|
+
const labels = cancels.map((c) => c.closest("button")!.getAttribute("aria-label"));
|
|
397
|
+
expect(new Set(labels).size).toBe(2);
|
|
398
|
+
expect(labels.some((l) => l?.startsWith("Cancel, first one, Scheduled for"))).toBe(true);
|
|
399
|
+
expect(labels.some((l) => l?.startsWith("Cancel, second one, Scheduled for"))).toBe(true);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("sizes the schedule and topic sheets to their content", async () => {
|
|
403
|
+
const client = memory([{ ...cohort(false), members: [{ userId: ME, role: "owner" }, { userId: SAM }, { userId: LEE }] }]);
|
|
404
|
+
render(wrap(client, <ChatScheduleSheet open onOpenChange={vi.fn()} onSchedule={vi.fn()} testID="s" />));
|
|
405
|
+
const schedule = (await screen.findByTestId("s-pick-0")).closest("[role=dialog]")!;
|
|
406
|
+
expect(schedule.getAttribute("data-presentation-style")).toBeNull();
|
|
407
|
+
expect(schedule.getAttribute("data-transparent")).toBe("true");
|
|
408
|
+
cleanup();
|
|
409
|
+
|
|
410
|
+
render(wrap(client, <ChatThreadScreen conversationId="cohort" testID="t" />));
|
|
411
|
+
fireEvent.click(await screen.findByTestId("t-topics-menu"));
|
|
412
|
+
const topics = (await screen.findByTestId("t-topics-new")).closest("[role=dialog]")!;
|
|
413
|
+
expect(topics.getAttribute("data-transparent")).toBe("true");
|
|
414
|
+
});
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
describe("scrollToShow", () => {
|
|
418
|
+
it("brings a chip past either edge into view, and leaves one in view alone", () => {
|
|
419
|
+
expect(scrollToShow({ x: 400, width: 80 }, { offset: 0, width: 300 })).toBe(400 + 80 + 8 - 300);
|
|
420
|
+
expect(scrollToShow({ x: 20, width: 60 }, { offset: 100, width: 300 })).toBe(12);
|
|
421
|
+
expect(scrollToShow({ x: 120, width: 60 }, { offset: 100, width: 300 })).toBeNull();
|
|
422
|
+
expect(scrollToShow({ x: 120, width: 60 }, { offset: 0, width: 0 })).toBeNull();
|
|
423
|
+
});
|
|
424
|
+
});
|