@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.
- package/README.md +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.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-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -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 +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -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 +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -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 +238 -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 +344 -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 +421 -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 +116 -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 +116 -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 +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -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 +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -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 +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -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/handler.ts +32 -0
- package/src/server/index.ts +57 -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 +563 -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 +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -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.map +0 -1
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Small pure helpers for migration 0002's features, shared by every platform: the links a
|
|
4
|
+
* message's text carries (the same rule the database uses to queue previews), https checks
|
|
5
|
+
* for preview images, search snippets split at their « » marks, scheduling quick picks, and
|
|
6
|
+
* topic keys.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** A query that looks like a credential (a reset token, a signed URL): never previewed. Mirrors genesis_chat.message_links. */
|
|
10
|
+
const CREDENTIAL = /[?&#;](access_token|id_token|refresh_token|token|auth|code|key|api_key|apikey|sig|signature|x-amz-signature|x-amz-credential|x-goog-signature|x-goog-credential|otp|secret|password|session|sessionid)=/i;
|
|
11
|
+
|
|
12
|
+
/** The https links in a message's text that get a preview, at most three, as genesis_chat.message_links finds them. */
|
|
13
|
+
export function extractLinks(body: string, max = 3): string[] {
|
|
14
|
+
const out: string[] = [];
|
|
15
|
+
for (const m of body.matchAll(/(https:\/\/[^\s<>()"']{4,})/g)) {
|
|
16
|
+
const url = m[1]!;
|
|
17
|
+
if (url.length <= 2000 && !CREDENTIAL.test(url) && !out.includes(url)) out.push(url);
|
|
18
|
+
if (out.length >= max) break;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** An absolute https URL with a host: the only kind a preview image may load from. */
|
|
24
|
+
export function isHttpsUrl(url: string | null | undefined): url is string {
|
|
25
|
+
if (!url) return false;
|
|
26
|
+
try {
|
|
27
|
+
const u = new URL(url);
|
|
28
|
+
return u.protocol === "https:" && Boolean(u.hostname);
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SnippetPart {
|
|
35
|
+
text: string;
|
|
36
|
+
match: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** "a «word» here" → [{a }, {word, match}, { here}]. Unbalanced marks are kept as text. */
|
|
40
|
+
export function snippetParts(snippet: string): SnippetPart[] {
|
|
41
|
+
const parts: SnippetPart[] = [];
|
|
42
|
+
let rest = snippet;
|
|
43
|
+
for (;;) {
|
|
44
|
+
const open = rest.indexOf("«");
|
|
45
|
+
const close = open >= 0 ? rest.indexOf("»", open + 1) : -1;
|
|
46
|
+
if (open < 0 || close < 0) break;
|
|
47
|
+
if (open > 0) parts.push({ text: rest.slice(0, open), match: false });
|
|
48
|
+
parts.push({ text: rest.slice(open + 1, close), match: true });
|
|
49
|
+
rest = rest.slice(close + 1);
|
|
50
|
+
}
|
|
51
|
+
if (rest) parts.push({ text: rest, match: false });
|
|
52
|
+
return parts;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface QuickPick {
|
|
56
|
+
label: string;
|
|
57
|
+
at: Date;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** At 09:00 local on the day `days` after `from`. */
|
|
61
|
+
function nineAm(from: Date, days: number): Date {
|
|
62
|
+
const d = new Date(from.getFullYear(), from.getMonth(), from.getDate() + days, 9, 0, 0, 0);
|
|
63
|
+
return d;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** "Tomorrow, 9:00 AM" and "Monday, 9:00 AM" (the next Monday after tomorrow; one pick when they're the same day). */
|
|
67
|
+
export function scheduleQuickPicks(now: Date = new Date(), locale?: string): QuickPick[] {
|
|
68
|
+
const time = (d: Date) => d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
|
|
69
|
+
const tomorrow = nineAm(now, 1);
|
|
70
|
+
const picks: QuickPick[] = [{ label: `Tomorrow, ${time(tomorrow)}`, at: tomorrow }];
|
|
71
|
+
const toMonday = ((8 - now.getDay()) % 7) || 7;
|
|
72
|
+
const monday = nineAm(now, toMonday);
|
|
73
|
+
if (monday.getTime() !== tomorrow.getTime()) {
|
|
74
|
+
picks.push({ label: `${monday.toLocaleDateString(locale, { weekday: "long" })}, ${time(monday)}`, at: monday });
|
|
75
|
+
}
|
|
76
|
+
return picks;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** When a scheduled message goes: "Tomorrow, 9:00 AM", "Mon 12 Oct, 9:00 AM". */
|
|
80
|
+
export function scheduleLabel(iso: string | Date, now: Date = new Date(), locale?: string): string {
|
|
81
|
+
const d = typeof iso === "string" ? new Date(iso) : iso;
|
|
82
|
+
const time = d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
|
|
83
|
+
const start = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
84
|
+
const days = Math.round((start(d) - start(now)) / 86_400_000);
|
|
85
|
+
if (days === 0) return `Today, ${time}`;
|
|
86
|
+
if (days === 1) return `Tomorrow, ${time}`;
|
|
87
|
+
return `${d.toLocaleDateString(locale, { weekday: "short", day: "numeric", month: "short" })}, ${time}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The send time the database accepts: at least a minute and at most a year ahead. */
|
|
91
|
+
export function isSchedulable(at: Date, now: Date = new Date()): boolean {
|
|
92
|
+
const t = at.getTime();
|
|
93
|
+
return Number.isFinite(t) && t >= now.getTime() + 60_000 && t <= now.getTime() + 365 * 86_400_000;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A topic's key from its title: "Week 1: Habits" → "week-1-habits" (at most 120 characters). */
|
|
97
|
+
export function topicKey(title: string): string {
|
|
98
|
+
const key = title
|
|
99
|
+
.normalize("NFKD")
|
|
100
|
+
.replace(/[̀-ͯ]/g, "")
|
|
101
|
+
.toLowerCase()
|
|
102
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
103
|
+
.replace(/^-+|-+$/g, "")
|
|
104
|
+
.slice(0, 120);
|
|
105
|
+
return key || title.trim().slice(0, 120);
|
|
106
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Media rules shared by every platform: which files may be sent (the same list
|
|
4
|
+
* migration 0001 checks), what kind an upload becomes, the default limits, and
|
|
5
|
+
* the waveform a voice note carries so its bubble draws before the audio loads.
|
|
6
|
+
*/
|
|
7
|
+
import type { AttachmentKind } from "./types";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A random UUID (v4): `crypto.randomUUID` where the platform has it (some React Native
|
|
11
|
+
* runtimes don't), else `crypto.getRandomValues`, else Math.random as a last resort. Ids
|
|
12
|
+
* only need to be unique per sender: the server checks (sender, client_id).
|
|
13
|
+
*/
|
|
14
|
+
export function randomId(): string {
|
|
15
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string; getRandomValues?: (a: Uint8Array) => Uint8Array } }).crypto;
|
|
16
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
17
|
+
const b = new Uint8Array(16);
|
|
18
|
+
if (c?.getRandomValues) c.getRandomValues(b);
|
|
19
|
+
else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
|
|
20
|
+
b[6] = (b[6]! & 0x0f) | 0x40;
|
|
21
|
+
b[8] = (b[8]! & 0x3f) | 0x80;
|
|
22
|
+
const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
|
|
23
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Mirrors genesis_chat.allowed_extension. Never SVG or HTML, which a browser runs as a page. */
|
|
27
|
+
export const ALLOWED_EXTENSIONS = [
|
|
28
|
+
"png", "jpg", "jpeg", "webp", "gif", "heic", "heif",
|
|
29
|
+
"m4a", "aac", "mp3", "ogg", "oga", "opus", "weba", "webm",
|
|
30
|
+
"mp4", "mov", "pdf", "txt", "csv", "docx", "xlsx", "pptx",
|
|
31
|
+
] as const;
|
|
32
|
+
|
|
33
|
+
const MIME_EXTENSIONS: Record<string, string> = {
|
|
34
|
+
"image/png": "png",
|
|
35
|
+
"image/jpeg": "jpg",
|
|
36
|
+
"image/webp": "webp",
|
|
37
|
+
"image/gif": "gif",
|
|
38
|
+
"image/heic": "heic",
|
|
39
|
+
"image/heif": "heif",
|
|
40
|
+
"audio/mp4": "m4a",
|
|
41
|
+
"audio/x-m4a": "m4a",
|
|
42
|
+
"audio/aac": "aac",
|
|
43
|
+
"audio/mpeg": "mp3",
|
|
44
|
+
"audio/ogg": "ogg",
|
|
45
|
+
"audio/webm": "webm",
|
|
46
|
+
"video/mp4": "mp4",
|
|
47
|
+
"video/quicktime": "mov",
|
|
48
|
+
"video/webm": "webm",
|
|
49
|
+
"application/pdf": "pdf",
|
|
50
|
+
"text/plain": "txt",
|
|
51
|
+
"text/csv": "csv",
|
|
52
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
|
53
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
|
|
54
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** The extension a file is stored under: from its content type, else its name. */
|
|
58
|
+
export function fileExtension(name: string, contentType?: string): string {
|
|
59
|
+
const base = (contentType ?? "").split(";")[0]!.trim().toLowerCase();
|
|
60
|
+
const fromType = MIME_EXTENSIONS[base];
|
|
61
|
+
if (fromType) return fromType;
|
|
62
|
+
const dot = name.lastIndexOf(".");
|
|
63
|
+
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function isAllowedExtension(ext: string): boolean {
|
|
67
|
+
return (ALLOWED_EXTENSIONS as readonly string[]).includes(ext.toLowerCase());
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What an upload becomes in a message. */
|
|
71
|
+
export function attachmentKindFor(contentType: string, opts: { recordedVoice?: boolean } = {}): AttachmentKind {
|
|
72
|
+
const t = contentType.toLowerCase();
|
|
73
|
+
if (opts.recordedVoice || t.startsWith("audio/")) return "voice";
|
|
74
|
+
if (t.startsWith("image/")) return "image";
|
|
75
|
+
if (t.startsWith("video/")) return "video";
|
|
76
|
+
return "file";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Default limits (decision 5): an app may lower them, not raise them past the bucket's 100 MB. */
|
|
80
|
+
export const CHAT_LIMITS = {
|
|
81
|
+
/** Voice notes and videos, for members. */
|
|
82
|
+
recordingMs: 5 * 60_000,
|
|
83
|
+
/** For owners, admins and observers (coaches, staff). */
|
|
84
|
+
staffRecordingMs: 10 * 60_000,
|
|
85
|
+
fileBytes: 50 * 1024 * 1024,
|
|
86
|
+
videoBytes: 100 * 1024 * 1024,
|
|
87
|
+
attachmentsPerMessage: 20,
|
|
88
|
+
bodyChars: 10_000,
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
/** The longest recording this role may make. */
|
|
92
|
+
export function recordingLimitMs(role: string | null | undefined): number {
|
|
93
|
+
return role === "owner" || role === "admin" || role === "observer" ? CHAT_LIMITS.staffRecordingMs : CHAT_LIMITS.recordingMs;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Peaks for a waveform: `count` values 0–100 from raw samples (-1…1), the loudest
|
|
98
|
+
* in each bucket, scaled so the loudest bucket is 100. Quiet recordings still show
|
|
99
|
+
* a shape; silence shows a flat line.
|
|
100
|
+
*/
|
|
101
|
+
export function waveformPeaks(samples: ArrayLike<number>, count = 48): number[] {
|
|
102
|
+
const n = Math.max(1, Math.min(128, Math.floor(count)));
|
|
103
|
+
if (samples.length === 0) return new Array(n).fill(0);
|
|
104
|
+
const size = samples.length / n;
|
|
105
|
+
const raw: number[] = [];
|
|
106
|
+
for (let i = 0; i < n; i++) {
|
|
107
|
+
let peak = 0;
|
|
108
|
+
const end = Math.min(samples.length, Math.floor((i + 1) * size));
|
|
109
|
+
for (let j = Math.floor(i * size); j < end; j++) {
|
|
110
|
+
const v = Math.abs(samples[j]!);
|
|
111
|
+
if (v > peak) peak = v;
|
|
112
|
+
}
|
|
113
|
+
raw.push(peak);
|
|
114
|
+
}
|
|
115
|
+
const max = Math.max(...raw);
|
|
116
|
+
if (max < 0.01) return raw.map(() => 0);
|
|
117
|
+
return raw.map((v) => Math.round((v / max) * 100));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Peaks from a level meter read while recording (expo-audio's `metering`, in dB,
|
|
122
|
+
* about -160…0): resampled to `count` values 0–100.
|
|
123
|
+
*/
|
|
124
|
+
export function waveformFromMetering(levelsDb: number[], count = 48): number[] {
|
|
125
|
+
const linear = levelsDb.map((db) => (Number.isFinite(db) ? Math.pow(10, Math.max(-60, Math.min(0, db)) / 20) : 0));
|
|
126
|
+
return waveformPeaks(linear, count);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 0:42, 1:05:09. */
|
|
130
|
+
export function formatDuration(ms: number | null | undefined): string {
|
|
131
|
+
const total = Math.max(0, Math.round((ms ?? 0) / 1000));
|
|
132
|
+
const h = Math.floor(total / 3600);
|
|
133
|
+
const m = Math.floor((total % 3600) / 60);
|
|
134
|
+
const s = total % 60;
|
|
135
|
+
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Playback speeds offered for voice and video (free for everyone, decision 5). */
|
|
139
|
+
export const PLAYBACK_RATES = [1, 1.5, 2] as const;
|
|
140
|
+
|
|
141
|
+
export function nextPlaybackRate(rate: number): number {
|
|
142
|
+
const i = PLAYBACK_RATES.indexOf(rate as (typeof PLAYBACK_RATES)[number]);
|
|
143
|
+
return PLAYBACK_RATES[(i + 1) % PLAYBACK_RATES.length]!;
|
|
144
|
+
}
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* The send queue: a message shows at once as pending, its files upload, then it
|
|
4
|
+
* is sent with the clientId made when it was written, so a retry (after a lost
|
|
5
|
+
* connection, a closed app) never posts twice. Messages to one conversation go
|
|
6
|
+
* in the order they were written: the first that can't go (a lost file, a refusal)
|
|
7
|
+
* holds back the ones after it. A refusal (not allowed, bad data) is `permanent`
|
|
8
|
+
* and only sent again by hand. `clear()` (sign-out) stops sends in flight: none
|
|
9
|
+
* posts, or saves anything, afterwards. Storage is the platform's (memory by default;
|
|
10
|
+
* localStorage on web, MMKV or SQLite on native), behind OutboxStore.
|
|
11
|
+
*/
|
|
12
|
+
import type { ChatClient } from "./client";
|
|
13
|
+
import { ChatError } from "./client";
|
|
14
|
+
import { attachmentKindFor, randomId } from "./media";
|
|
15
|
+
import type { AttachmentInput, ChatMessage, PendingFile, PendingMessage, SendInput } from "./types";
|
|
16
|
+
|
|
17
|
+
/** A file still on the device, uploaded when its message is sent. */
|
|
18
|
+
export interface LocalFile {
|
|
19
|
+
/** The bytes, or (native) a file URI the platform reads in `readFile`. */
|
|
20
|
+
data: Blob | ArrayBuffer | string;
|
|
21
|
+
name: string;
|
|
22
|
+
contentType: string;
|
|
23
|
+
/** A preview to show while it uploads (an object URL, a file URI). The outbox owns it from here: see `releasePreview`. */
|
|
24
|
+
previewUrl?: string;
|
|
25
|
+
/** Known up front: a voice note's duration and waveform, a photo's size. */
|
|
26
|
+
meta?: Partial<Omit<AttachmentInput, "path" | "kind">> & { kind?: AttachmentInput["kind"] };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface QueuedMessage extends Omit<PendingMessage, "files"> {
|
|
30
|
+
files: LocalFile[];
|
|
31
|
+
/** What each file is, kept with the saved copy (PendingMessage.files). */
|
|
32
|
+
pendingFiles?: PendingFile[];
|
|
33
|
+
/** Files lost on a relaunch (bytes that weren't persisted). */
|
|
34
|
+
lostFiles?: number;
|
|
35
|
+
attempts: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function describe(f: LocalFile): PendingFile {
|
|
39
|
+
return { name: f.name, contentType: f.contentType, kind: f.meta?.kind ?? attachmentKindFor(f.contentType), meta: f.meta };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface OutboxStore {
|
|
43
|
+
load(): Promise<QueuedMessage[]>;
|
|
44
|
+
save(queue: QueuedMessage[]): Promise<void>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const memoryOutboxStore = (): OutboxStore => {
|
|
48
|
+
let saved: QueuedMessage[] = [];
|
|
49
|
+
return { load: async () => saved, save: async (q) => void (saved = q) };
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export interface OutboxOptions {
|
|
53
|
+
client: ChatClient;
|
|
54
|
+
userId: string;
|
|
55
|
+
store?: OutboxStore;
|
|
56
|
+
/** Reads a native file URI into bytes for upload. */
|
|
57
|
+
readFile?: (uri: string) => Promise<Blob | ArrayBuffer>;
|
|
58
|
+
/** Upload hook, e.g. resumable uploads on native. Defaults to client.upload. `signal` aborts when the message is discarded or the outbox cleared. */
|
|
59
|
+
upload?: (conversationId: string, file: LocalFile, opts?: { signal?: AbortSignal }) => Promise<string>;
|
|
60
|
+
/** Called with every change, for the UI. */
|
|
61
|
+
onChange?: (queue: QueuedMessage[]) => void;
|
|
62
|
+
/** Called when a message lands. */
|
|
63
|
+
onSent?: (message: ChatMessage, clientId: string) => void;
|
|
64
|
+
/** Makes client ids. Default: a random UUID (works where crypto.randomUUID is missing). */
|
|
65
|
+
newId?: () => string;
|
|
66
|
+
/**
|
|
67
|
+
* Keeps a file held as bytes (a pasted image, a recording) between launches: write it
|
|
68
|
+
* somewhere and return a URI `readFile` can read (native: the app's cache directory).
|
|
69
|
+
* Without it, such a file isn't saved, and after a relaunch its message asks for it again.
|
|
70
|
+
*/
|
|
71
|
+
persistFile?: (file: LocalFile) => Promise<string>;
|
|
72
|
+
/**
|
|
73
|
+
* Lets go of a file's `previewUrl` once its message has landed, been discarded or cleared.
|
|
74
|
+
* Default: revokes `blob:` object URLs (the web's), and leaves everything else alone.
|
|
75
|
+
*/
|
|
76
|
+
releasePreview?: (url: string) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Revokes a `blob:` object URL; anything else (a native file URI) is left alone. */
|
|
80
|
+
export function releaseObjectUrl(url: string) {
|
|
81
|
+
if (!url.startsWith("blob:") || typeof URL === "undefined" || typeof URL.revokeObjectURL !== "function") return;
|
|
82
|
+
try {
|
|
83
|
+
URL.revokeObjectURL(url);
|
|
84
|
+
} catch {
|
|
85
|
+
// Already gone, or a runtime without object URLs.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
export function createOutbox(opts: OutboxOptions) {
|
|
91
|
+
const store = opts.store ?? memoryOutboxStore();
|
|
92
|
+
let queue: QueuedMessage[] = [];
|
|
93
|
+
// Bumped by clear(): work begun under an older generation stops at its next await.
|
|
94
|
+
let generation = 0;
|
|
95
|
+
const running = new Map<string, number>(); // conversationId -> the generation running it
|
|
96
|
+
const aborts = new Map<string, AbortController>(); // clientId -> its uploads' controller
|
|
97
|
+
const uploaded = new Map<string, string>(); // clientId:index -> path
|
|
98
|
+
|
|
99
|
+
const release = opts.releasePreview ?? releaseObjectUrl;
|
|
100
|
+
const releasePreviews = (q: QueuedMessage) => {
|
|
101
|
+
for (const url of new Set([...Object.values(q.previews ?? {}), ...q.files.map((f) => f.previewUrl)])) {
|
|
102
|
+
if (typeof url === "string" && url) release(url);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const newId = opts.newId ?? randomId;
|
|
107
|
+
|
|
108
|
+
// Saved copies: a file held as bytes is kept only as a URI (persistFile) or marked lost.
|
|
109
|
+
// `files` holds the files still here, in slot order; `pendingFiles` holds every slot.
|
|
110
|
+
const saveable = (q: QueuedMessage): QueuedMessage => {
|
|
111
|
+
const slots = q.pendingFiles?.length ? q.pendingFiles : q.files.map(describe);
|
|
112
|
+
const here = [...q.files];
|
|
113
|
+
const files: LocalFile[] = [];
|
|
114
|
+
const pendingFiles = slots.map((slot) => {
|
|
115
|
+
if (slot.lost) return slot;
|
|
116
|
+
const f = here.shift();
|
|
117
|
+
if (f && typeof f.data === "string") {
|
|
118
|
+
files.push(f);
|
|
119
|
+
return { ...slot, lost: false };
|
|
120
|
+
}
|
|
121
|
+
return { ...slot, lost: true };
|
|
122
|
+
});
|
|
123
|
+
return { ...q, files, pendingFiles };
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const emit = () => {
|
|
127
|
+
opts.onChange?.([...queue]);
|
|
128
|
+
void store.save(queue.map(saveable));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Swaps a byte file for a saved URI, so it survives a relaunch. */
|
|
132
|
+
async function persist(q: QueuedMessage) {
|
|
133
|
+
if (!opts.persistFile) return;
|
|
134
|
+
for (let i = 0; i < q.files.length; i++) {
|
|
135
|
+
const f = q.files[i]!;
|
|
136
|
+
if (typeof f.data === "string") continue;
|
|
137
|
+
try {
|
|
138
|
+
q.files[i] = { ...f, data: await opts.persistFile(f) };
|
|
139
|
+
} catch {
|
|
140
|
+
// Kept in memory only; a relaunch asks for it again.
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* One load for the outbox's life, which every method awaits. What was saved is merged into
|
|
147
|
+
* the live queue (a send made while the load was in flight stays), deduped by clientId.
|
|
148
|
+
*/
|
|
149
|
+
let loading: Promise<void> | null = null;
|
|
150
|
+
function ensureLoaded(): Promise<void> {
|
|
151
|
+
loading ??= (async () => {
|
|
152
|
+
const gen = generation;
|
|
153
|
+
let saved: QueuedMessage[] = [];
|
|
154
|
+
try {
|
|
155
|
+
saved = (await store.load()) ?? [];
|
|
156
|
+
} catch {
|
|
157
|
+
saved = [];
|
|
158
|
+
}
|
|
159
|
+
// Cleared (signed out) while it loaded: what was saved belonged to that session.
|
|
160
|
+
if (gen !== generation) return;
|
|
161
|
+
// A file held as bytes that wasn't persisted doesn't survive a relaunch: its message waits,
|
|
162
|
+
// failed, for the file to be attached again (never sent without it) or discarded.
|
|
163
|
+
const live = new Set(queue.map((q) => q.clientId));
|
|
164
|
+
const restored: QueuedMessage[] = saved
|
|
165
|
+
.filter((q) => !live.has(q.clientId))
|
|
166
|
+
.map((q) => {
|
|
167
|
+
const lost = (q.pendingFiles ?? []).filter((f) => f.lost).length;
|
|
168
|
+
return {
|
|
169
|
+
...q,
|
|
170
|
+
status: q.status === "sending" || lost > 0 ? "failed" : q.status,
|
|
171
|
+
error: lost > 0 ? "Attach the file again to send" : (q.error ?? "Not sent"),
|
|
172
|
+
files: q.files ?? [],
|
|
173
|
+
attempts: q.attempts ?? 0,
|
|
174
|
+
lostFiles: lost,
|
|
175
|
+
};
|
|
176
|
+
});
|
|
177
|
+
if (restored.length) {
|
|
178
|
+
queue = [...restored, ...queue];
|
|
179
|
+
opts.onChange?.([...queue]);
|
|
180
|
+
}
|
|
181
|
+
})();
|
|
182
|
+
return loading;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function uploadFile(conversationId: string, file: LocalFile, signal?: AbortSignal): Promise<string> {
|
|
186
|
+
if (opts.upload) return opts.upload(conversationId, file, { signal });
|
|
187
|
+
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 });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function run(conversationId: string) {
|
|
192
|
+
const gen = generation;
|
|
193
|
+
if (running.get(conversationId) === gen) return;
|
|
194
|
+
running.set(conversationId, gen);
|
|
195
|
+
// Cleared (signed out) or discarded while we waited: stop, sending and saving nothing.
|
|
196
|
+
const stale = (q: QueuedMessage) => gen !== generation || !queue.includes(q);
|
|
197
|
+
try {
|
|
198
|
+
for (;;) {
|
|
199
|
+
if (gen !== generation) return;
|
|
200
|
+
// In the order written: the first message still here decides. One that failed (a lost
|
|
201
|
+
// file, a refusal, no connection) holds back the ones after it until it is retried.
|
|
202
|
+
const next = queue.find((q) => q.input.conversationId === conversationId);
|
|
203
|
+
if (!next || next.status !== "sending") break;
|
|
204
|
+
if ((next.lostFiles ?? 0) > 0) {
|
|
205
|
+
next.status = "failed";
|
|
206
|
+
next.error = "Attach the file again to send";
|
|
207
|
+
emit();
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
const controller = typeof AbortController !== "undefined" ? new AbortController() : null;
|
|
211
|
+
if (controller) aborts.set(next.clientId, controller);
|
|
212
|
+
try {
|
|
213
|
+
const attachments: AttachmentInput[] = [...(next.input.attachments ?? [])];
|
|
214
|
+
for (let i = 0; i < next.files.length; i++) {
|
|
215
|
+
const file = next.files[i]!;
|
|
216
|
+
const key = `${next.clientId}:${i}`;
|
|
217
|
+
let path = uploaded.get(key);
|
|
218
|
+
if (!path) {
|
|
219
|
+
path = await uploadFile(conversationId, file, controller?.signal);
|
|
220
|
+
if (stale(next)) return;
|
|
221
|
+
uploaded.set(key, path);
|
|
222
|
+
}
|
|
223
|
+
attachments.push({
|
|
224
|
+
...file.meta,
|
|
225
|
+
kind: file.meta?.kind ?? attachmentKindFor(file.contentType),
|
|
226
|
+
path,
|
|
227
|
+
name: file.meta?.name ?? file.name,
|
|
228
|
+
mime: file.contentType,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
if (stale(next)) return;
|
|
232
|
+
const message = await opts.client.send({ ...next.input, attachments });
|
|
233
|
+
// Posted, but the person signed out meanwhile: the next session must not hear of it.
|
|
234
|
+
if (gen !== generation) return;
|
|
235
|
+
queue = queue.filter((q) => q.clientId !== next.clientId);
|
|
236
|
+
for (let i = 0; i < next.files.length; i++) uploaded.delete(`${next.clientId}:${i}`);
|
|
237
|
+
emit();
|
|
238
|
+
opts.onSent?.(message, next.clientId);
|
|
239
|
+
// The bubble now draws the stored copy: the local preview can go.
|
|
240
|
+
releasePreviews(next);
|
|
241
|
+
} catch (e) {
|
|
242
|
+
if (stale(next)) return;
|
|
243
|
+
const err = e instanceof ChatError ? e : new ChatError(e instanceof Error ? e.message : String(e), undefined, "send");
|
|
244
|
+
next.attempts += 1;
|
|
245
|
+
next.status = "failed";
|
|
246
|
+
next.permanent = err.permanent;
|
|
247
|
+
next.error = err.forbidden ? "You can't post here" : err.transient ? "Not sent. Check your connection." : err.message;
|
|
248
|
+
emit();
|
|
249
|
+
// Later messages to this conversation wait behind a failed one, so order holds.
|
|
250
|
+
break;
|
|
251
|
+
} finally {
|
|
252
|
+
if (controller && aborts.get(next.clientId) === controller) aborts.delete(next.clientId);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
} finally {
|
|
256
|
+
if (running.get(conversationId) === gen) running.delete(conversationId);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Can this failed message go without the person's help (retryAll, coming back online)? */
|
|
261
|
+
const canAutoRetry = (x: QueuedMessage) =>
|
|
262
|
+
!x.permanent && !(x.lostFiles ?? 0) && x.files.every((f) => typeof f.data !== "string" || Boolean(opts.readFile || opts.upload));
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
/** Queues a message; it shows at once and sends in order. Returns its clientId. */
|
|
266
|
+
async send(input: Omit<SendInput, "clientId"> & { clientId?: string }, files: LocalFile[] = []): Promise<string> {
|
|
267
|
+
await ensureLoaded();
|
|
268
|
+
const gen = generation;
|
|
269
|
+
const clientId = input.clientId ?? newId();
|
|
270
|
+
const previews: Record<number, string> = {};
|
|
271
|
+
files.forEach((f, i) => {
|
|
272
|
+
if (f.previewUrl) previews[i] = f.previewUrl;
|
|
273
|
+
});
|
|
274
|
+
const queued: QueuedMessage = {
|
|
275
|
+
clientId,
|
|
276
|
+
input: { ...input, clientId },
|
|
277
|
+
senderId: opts.userId,
|
|
278
|
+
createdAt: new Date().toISOString(),
|
|
279
|
+
status: "sending",
|
|
280
|
+
previews,
|
|
281
|
+
files: [...files],
|
|
282
|
+
pendingFiles: files.map(describe),
|
|
283
|
+
attempts: 0,
|
|
284
|
+
};
|
|
285
|
+
queue.push(queued);
|
|
286
|
+
opts.onChange?.([...queue]);
|
|
287
|
+
await persist(queued);
|
|
288
|
+
// Cleared or discarded while its files were saved: it goes nowhere.
|
|
289
|
+
if (gen !== generation || !queue.includes(queued)) return clientId;
|
|
290
|
+
emit();
|
|
291
|
+
void run(input.conversationId);
|
|
292
|
+
return clientId;
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
async retry(clientId: string) {
|
|
296
|
+
await ensureLoaded();
|
|
297
|
+
const q = queue.find((x) => x.clientId === clientId);
|
|
298
|
+
if (!q) return;
|
|
299
|
+
// Retrying one retries everything failed in that conversation, in order, refusals too
|
|
300
|
+
// (a person asked), up to one still waiting for a file.
|
|
301
|
+
for (const x of queue) {
|
|
302
|
+
if (x.input.conversationId !== q.input.conversationId || x.status !== "failed") continue;
|
|
303
|
+
if ((x.lostFiles ?? 0) > 0) break;
|
|
304
|
+
x.status = "sending";
|
|
305
|
+
x.permanent = false;
|
|
306
|
+
}
|
|
307
|
+
emit();
|
|
308
|
+
void run(q.input.conversationId);
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Gives a message the files it lost on a relaunch and sends it. `files` fill the lost
|
|
313
|
+
* slots in order (`PendingMessage.files[i].lost`); the files that were kept stay. Pass
|
|
314
|
+
* one per lost slot; with fewer, the message keeps waiting for the rest.
|
|
315
|
+
*/
|
|
316
|
+
async reattach(clientId: string, files: LocalFile[]) {
|
|
317
|
+
await ensureLoaded();
|
|
318
|
+
const q = queue.find((x) => x.clientId === clientId);
|
|
319
|
+
if (!q) return;
|
|
320
|
+
const slots = q.pendingFiles ?? [];
|
|
321
|
+
const kept = [...q.files];
|
|
322
|
+
const given = [...files];
|
|
323
|
+
const merged: LocalFile[] = [];
|
|
324
|
+
const described: PendingFile[] = [];
|
|
325
|
+
for (const slot of slots) {
|
|
326
|
+
if (slot.lost) {
|
|
327
|
+
const f = given.shift();
|
|
328
|
+
if (f) {
|
|
329
|
+
merged.push(f);
|
|
330
|
+
described.push(describe(f));
|
|
331
|
+
} else {
|
|
332
|
+
described.push(slot);
|
|
333
|
+
}
|
|
334
|
+
} else {
|
|
335
|
+
const f = kept.shift();
|
|
336
|
+
if (f) {
|
|
337
|
+
merged.push(f);
|
|
338
|
+
described.push(slot);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
// A message saved before slots were recorded: the given files are all of them.
|
|
343
|
+
if (slots.length === 0) {
|
|
344
|
+
merged.push(...given);
|
|
345
|
+
described.push(...given.map(describe));
|
|
346
|
+
}
|
|
347
|
+
q.files = merged;
|
|
348
|
+
q.pendingFiles = described;
|
|
349
|
+
q.lostFiles = described.filter((d) => d.lost).length;
|
|
350
|
+
if (q.lostFiles > 0) {
|
|
351
|
+
emit();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const gen = generation;
|
|
355
|
+
await persist(q);
|
|
356
|
+
if (gen !== generation || !queue.includes(q)) return;
|
|
357
|
+
q.status = "sending";
|
|
358
|
+
emit();
|
|
359
|
+
void run(q.input.conversationId);
|
|
360
|
+
},
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Retries failed messages (after the connection comes back), in order per conversation,
|
|
364
|
+
* stopping at the first that can't go on its own: one waiting for a lost file, or one the
|
|
365
|
+
* server refused (`permanent`, retried only by hand). The ones after it keep waiting.
|
|
366
|
+
*/
|
|
367
|
+
async retryAll() {
|
|
368
|
+
await ensureLoaded();
|
|
369
|
+
const conversations = new Set<string>();
|
|
370
|
+
const held = new Set<string>();
|
|
371
|
+
for (const x of queue) {
|
|
372
|
+
const c = x.input.conversationId;
|
|
373
|
+
if (held.has(c) || x.status !== "failed") continue;
|
|
374
|
+
if (!canAutoRetry(x)) {
|
|
375
|
+
held.add(c);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
x.status = "sending";
|
|
379
|
+
conversations.add(c);
|
|
380
|
+
}
|
|
381
|
+
emit();
|
|
382
|
+
for (const c of conversations) void run(c);
|
|
383
|
+
},
|
|
384
|
+
|
|
385
|
+
/** Forgets every queued message, here and in the store (call it on sign-out). */
|
|
386
|
+
async clear() {
|
|
387
|
+
// At once, before any await: a send in flight stops at its next step and posts nothing.
|
|
388
|
+
generation += 1;
|
|
389
|
+
for (const c of aborts.values()) c.abort();
|
|
390
|
+
aborts.clear();
|
|
391
|
+
running.clear();
|
|
392
|
+
const gone = queue;
|
|
393
|
+
queue = [];
|
|
394
|
+
uploaded.clear();
|
|
395
|
+
gone.forEach(releasePreviews);
|
|
396
|
+
opts.onChange?.([]);
|
|
397
|
+
await ensureLoaded();
|
|
398
|
+
await store.save([]);
|
|
399
|
+
},
|
|
400
|
+
|
|
401
|
+
async discard(clientId: string) {
|
|
402
|
+
await ensureLoaded();
|
|
403
|
+
const gone = queue.find((q) => q.clientId === clientId);
|
|
404
|
+
queue = queue.filter((q) => q.clientId !== clientId);
|
|
405
|
+
aborts.get(clientId)?.abort();
|
|
406
|
+
aborts.delete(clientId);
|
|
407
|
+
if (gone) releasePreviews(gone);
|
|
408
|
+
emit();
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
pending(conversationId?: string): PendingMessage[] {
|
|
412
|
+
return queue
|
|
413
|
+
.filter((q) => !conversationId || q.input.conversationId === conversationId)
|
|
414
|
+
.map((q) => ({ ...q, files: q.pendingFiles }));
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
/** A new client id, made the way this outbox makes them. */
|
|
418
|
+
newId,
|
|
419
|
+
|
|
420
|
+
load: ensureLoaded,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export type Outbox = ReturnType<typeof createOutbox>;
|
|
425
|
+
|
|
426
|
+
async function notReadable(): Promise<never> {
|
|
427
|
+
// This device's set-up (no readFile), not a refusal: a relaunch that can read it sends it.
|
|
428
|
+
throw new ChatError("This file can't be read on this device", "GSFILE", "upload");
|
|
429
|
+
}
|