@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
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// @feat:chat
|
|
2
|
+
/**
|
|
3
|
+
* A pretend hosted video service for local development, behind `VideoProvider`, so the hosted
|
|
4
|
+
* path (pending attachment, upload, ready) runs without a Mux account.
|
|
5
|
+
*
|
|
6
|
+
* createUpload a local upload URL (`uploadUrl?id=<upload id>`) and its id, `fake_<uuid>`
|
|
7
|
+
* handler the fetch handler to mount at `uploadUrl`: takes the PUT body, stores it in
|
|
8
|
+
* the genesis-chat bucket, then marks the attachment ready
|
|
9
|
+
* markReady what Mux's `video.asset.ready` webhook does: `update_attachment` with status
|
|
10
|
+
* ready, a playback id (`fake-<upload id>`) and a fake HLS URL
|
|
11
|
+
* playbackUrl a signed link to the stored file (the fake HLS URL when nothing was stored)
|
|
12
|
+
*
|
|
13
|
+
* It refuses to be made when NODE_ENV is `production`, unless `force` is set.
|
|
14
|
+
*/
|
|
15
|
+
import { CHAT_BUCKET, CHAT_SCHEMA, type ChatSupabase } from "../core/client";
|
|
16
|
+
import type { VideoProvider } from "./types";
|
|
17
|
+
import { parseChatUploadPassthrough } from "./upload";
|
|
18
|
+
|
|
19
|
+
export interface FakeVideoProviderOptions {
|
|
20
|
+
/** A service-role client: it writes the bucket and calls `update_attachment`. */
|
|
21
|
+
supabase: ChatSupabase;
|
|
22
|
+
/** Where `handler` is mounted, e.g. `http://localhost:3000/api/chat/fake-video`. */
|
|
23
|
+
uploadUrl: string;
|
|
24
|
+
/** The `provider` value it stands in for. Default `mux`. */
|
|
25
|
+
name?: "mux" | "cloudflare";
|
|
26
|
+
/** Run even when NODE_ENV is `production`. */
|
|
27
|
+
force?: boolean;
|
|
28
|
+
/** NODE_ENV, read from `process.env` by default. */
|
|
29
|
+
env?: string | undefined;
|
|
30
|
+
/** How long `handler` waits for the message carrying the upload to arrive before answering 202 (default 10 s). */
|
|
31
|
+
readyWaitMs?: number;
|
|
32
|
+
newId?: () => string;
|
|
33
|
+
sleep?: (ms: number) => Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface FakeReadyOptions {
|
|
37
|
+
durationMs?: number;
|
|
38
|
+
/** A poster image already in the genesis-chat bucket. */
|
|
39
|
+
posterPath?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface FakeUpload {
|
|
43
|
+
passthrough: string | undefined;
|
|
44
|
+
corsOrigin: string;
|
|
45
|
+
/** Where the body was stored, once it was. */
|
|
46
|
+
path?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const json = (status: number, body: unknown, headers: Record<string, string> = {}) =>
|
|
50
|
+
new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", "cache-control": "no-store", ...headers } });
|
|
51
|
+
|
|
52
|
+
const EXTENSIONS: Record<string, string> = { "video/mp4": "mp4", "video/webm": "webm", "video/quicktime": "mov" };
|
|
53
|
+
|
|
54
|
+
/** No attachment carries the upload yet: its message hasn't been sent. */
|
|
55
|
+
class NotSentYet extends Error {}
|
|
56
|
+
|
|
57
|
+
const nodeEnv = (): string | undefined => (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.NODE_ENV;
|
|
58
|
+
|
|
59
|
+
export function fakeVideoProvider(options: FakeVideoProviderOptions): VideoProvider & {
|
|
60
|
+
markReady(providerId: string, ready?: FakeReadyOptions): Promise<void>;
|
|
61
|
+
handler(request: Request): Promise<Response>;
|
|
62
|
+
playbackUrl(playbackId: string): Promise<string>;
|
|
63
|
+
} {
|
|
64
|
+
const env = "env" in options ? options.env : nodeEnv();
|
|
65
|
+
if (env === "production" && !options.force) {
|
|
66
|
+
throw new Error("fakeVideoProvider is for development: it won't run with NODE_ENV=production (pass `force: true` to override)");
|
|
67
|
+
}
|
|
68
|
+
const name = options.name ?? "mux";
|
|
69
|
+
const db = () => options.supabase.schema(CHAT_SCHEMA);
|
|
70
|
+
const uploads = new Map<string, FakeUpload>();
|
|
71
|
+
const newId = options.newId ?? (() => crypto.randomUUID());
|
|
72
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
|
|
73
|
+
const fakeHls = (providerId: string) => `https://fake-video.invalid/${encodeURIComponent(providerId)}.m3u8`;
|
|
74
|
+
|
|
75
|
+
async function find(providerId: string): Promise<{ id: string } | null> {
|
|
76
|
+
const { data, error } = await db()
|
|
77
|
+
.from("attachments")
|
|
78
|
+
.select("id")
|
|
79
|
+
.eq("provider", name)
|
|
80
|
+
.eq("provider_id", providerId)
|
|
81
|
+
.limit(1)
|
|
82
|
+
.maybeSingle();
|
|
83
|
+
if (error) throw new Error(`attachments: ${error.code ?? "error"}`);
|
|
84
|
+
return (data as { id: string } | null) ?? null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function markReady(providerId: string, ready: FakeReadyOptions = {}): Promise<void> {
|
|
88
|
+
const attachment = await find(providerId);
|
|
89
|
+
if (!attachment) throw new NotSentYet(`fakeVideoProvider: no ${name} attachment with provider_id ${providerId} (is its message sent?)`);
|
|
90
|
+
const { error } = await db().rpc("update_attachment", {
|
|
91
|
+
p_attachment_id: attachment.id,
|
|
92
|
+
p_patch: {
|
|
93
|
+
status: "ready",
|
|
94
|
+
playback_id: `fake-${providerId}`,
|
|
95
|
+
url: fakeHls(providerId),
|
|
96
|
+
transcript_status: "off",
|
|
97
|
+
...(typeof ready.durationMs === "number" ? { duration_ms: Math.round(ready.durationMs) } : {}),
|
|
98
|
+
...(ready.posterPath ? { poster_path: ready.posterPath } : {}),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
if (error) throw new Error(`update_attachment: ${error.code ?? "error"}`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
name,
|
|
106
|
+
|
|
107
|
+
async createUpload({ corsOrigin, passthrough }) {
|
|
108
|
+
const uploadId = `fake_${newId()}`;
|
|
109
|
+
uploads.set(uploadId, { passthrough, corsOrigin });
|
|
110
|
+
const url = new URL(options.uploadUrl);
|
|
111
|
+
url.searchParams.set("id", uploadId);
|
|
112
|
+
return { uploadId, url: url.toString() };
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
async remove(assetId) {
|
|
116
|
+
const id = assetId.replace(/^fake-/, "");
|
|
117
|
+
const path = uploads.get(id)?.path;
|
|
118
|
+
uploads.delete(id);
|
|
119
|
+
if (path) await options.supabase.storage.from(CHAT_BUCKET).remove([path]);
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
/** There is no webhook: `handler` (or `markReady`) does its work. */
|
|
123
|
+
async handleWebhook() {
|
|
124
|
+
return json(200, { ignored: "the fake provider has no webhook; call markReady" });
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
markReady,
|
|
128
|
+
|
|
129
|
+
async playbackUrl(playbackId) {
|
|
130
|
+
const path = uploads.get(playbackId.replace(/^fake-/, ""))?.path;
|
|
131
|
+
if (!path) return fakeHls(playbackId.replace(/^fake-/, ""));
|
|
132
|
+
const { data, error } = await options.supabase.storage.from(CHAT_BUCKET).createSignedUrl(path, 3600);
|
|
133
|
+
if (error || !data?.signedUrl) throw new Error("fakeVideoProvider: couldn't sign the stored file");
|
|
134
|
+
return data.signedUrl;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async handler(request) {
|
|
138
|
+
const id = new URL(request.url).searchParams.get("id") ?? "";
|
|
139
|
+
const upload = uploads.get(id);
|
|
140
|
+
const cors = { "access-control-allow-origin": upload?.corsOrigin ?? "*", vary: "origin" };
|
|
141
|
+
if (request.method === "OPTIONS") {
|
|
142
|
+
return new Response(null, {
|
|
143
|
+
status: 204,
|
|
144
|
+
headers: { ...cors, "access-control-allow-methods": "PUT, OPTIONS", "access-control-allow-headers": "content-type, content-range", "access-control-max-age": "600" },
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (request.method !== "PUT") return json(405, { error: "method not allowed" }, cors);
|
|
148
|
+
if (!upload) return json(404, { error: "no such upload" }, cors);
|
|
149
|
+
const binding = parseChatUploadPassthrough(upload.passthrough);
|
|
150
|
+
const contentType = request.headers.get("content-type") ?? "video/mp4";
|
|
151
|
+
const path = `${binding?.conversationId ?? "fake-video"}/${binding?.userId ?? "fake-video"}/${id}.${EXTENSIONS[contentType] ?? "mp4"}`;
|
|
152
|
+
const body = await request.arrayBuffer();
|
|
153
|
+
const { error } = await options.supabase.storage.from(CHAT_BUCKET).upload(path, body, { contentType, upsert: true });
|
|
154
|
+
if (error) return json(502, { error: `storage: ${error.message}` }, cors);
|
|
155
|
+
upload.path = path;
|
|
156
|
+
// The message carrying it usually lands first; give it a moment when it hasn't.
|
|
157
|
+
const deadline = Date.now() + (options.readyWaitMs ?? 10_000);
|
|
158
|
+
for (;;) {
|
|
159
|
+
try {
|
|
160
|
+
await markReady(id);
|
|
161
|
+
return json(200, { ok: true, providerId: id, path }, cors);
|
|
162
|
+
} catch (e) {
|
|
163
|
+
if (Date.now() >= deadline || !(e instanceof NotSentYet)) {
|
|
164
|
+
return json(202, { stored: true, ready: false, providerId: id, path, error: e instanceof Error ? e.message : String(e) }, cors);
|
|
165
|
+
}
|
|
166
|
+
await sleep(500);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from "./types";
|
|
|
10
10
|
export { createChatRunner, CHAT_MAX_ATTEMPTS, type ChatRunner, type ChatRunnerOptions } from "./runner";
|
|
11
11
|
export { deepgramTranscriber, openAiTranscriber, type DeepgramOptions, type OpenAiTranscriberOptions } from "./transcribers";
|
|
12
12
|
export { muxVideo, verifyMuxSignature, muxPlaybackUrl, muxPlaybackToken, type MuxVideoOptions, type MuxSigningKey } from "./mux";
|
|
13
|
+
export { fakeVideoProvider, type FakeVideoProviderOptions, type FakeReadyOptions } from "./fake-video";
|
|
13
14
|
export {
|
|
14
15
|
createExpoDeliver,
|
|
15
16
|
deadTokens,
|
package/src/web/attachments.tsx
CHANGED
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
cn,
|
|
19
19
|
} from "@marktiderman/genesis-ui";
|
|
20
20
|
import type { ChatAttachment, ChatMessage } from "../core/types";
|
|
21
|
-
import type { ChatAttachmentKind } from "../react/index";
|
|
21
|
+
import type { ChatAttachmentKind, ChatCardContext } from "../react/index";
|
|
22
22
|
import { ChatAttachmentCard, type ChatAttachmentCardProps } from "./cards";
|
|
23
23
|
import { ChatErrorBoundary } from "./error-boundary";
|
|
24
24
|
import { formatBytes } from "./format";
|
|
@@ -301,12 +301,12 @@ export function attachmentKindOf(kinds: Record<string, ChatAttachmentKind> | und
|
|
|
301
301
|
* What the app's kind says to draw for this attachment, or null when its functions throw or
|
|
302
302
|
* return the wrong shapes (the attachment's `ref` is whatever the sender put there).
|
|
303
303
|
*/
|
|
304
|
-
export function attachmentCardOf(k: ChatAttachmentKind, a: ChatAttachment): Pick<ChatAttachmentCardProps, "title" | "subtitle" | "action"> | null {
|
|
304
|
+
export function attachmentCardOf(k: ChatAttachmentKind, a: ChatAttachment, ctx: ChatCardContext = { mine: false }): Pick<ChatAttachmentCardProps, "title" | "subtitle" | "action"> | null {
|
|
305
305
|
try {
|
|
306
|
-
const title = k.title(a);
|
|
306
|
+
const title = k.title(a, ctx);
|
|
307
307
|
if (typeof title !== "string") return null;
|
|
308
|
-
const sub = typeof k.subtitle === "function" ? k.subtitle(a) : null;
|
|
309
|
-
const act = typeof k.action === "function" ? k.action(a) : null;
|
|
308
|
+
const sub = typeof k.subtitle === "function" ? k.subtitle(a, ctx) : null;
|
|
309
|
+
const act = typeof k.action === "function" ? k.action(a, ctx) : null;
|
|
310
310
|
const action =
|
|
311
311
|
act && typeof act === "object" && typeof act.label === "string" && (typeof act.href === "string" || typeof act.onPress === "function")
|
|
312
312
|
? { label: act.label, href: typeof act.href === "string" ? act.href : undefined, onPress: typeof act.onPress === "function" ? act.onPress : undefined }
|
|
@@ -363,7 +363,7 @@ export function MessageAttachments({ views, message, mine, tone, slot, kinds }:
|
|
|
363
363
|
const generic = <GenericAttachment attachment={v.attachment} message={message} url={v.url} mine={mine} />;
|
|
364
364
|
const k = attachmentKindOf(kinds, v.kind);
|
|
365
365
|
if (k) {
|
|
366
|
-
const card = attachmentCardOf(k, v.attachment);
|
|
366
|
+
const card = attachmentCardOf(k, v.attachment, { mine });
|
|
367
367
|
return card ? <ChatAttachmentCard key={v.key} {...card} tone={tone} /> : <GenericAttachment key={v.key} attachment={v.attachment} message={message} url={v.url} mine={mine} />;
|
|
368
368
|
}
|
|
369
369
|
// The app's slot draws sender-controlled data: if it throws, the generic card stands in.
|
package/src/web/bubble.tsx
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
Popover,
|
|
44
44
|
PopoverContent,
|
|
45
45
|
PopoverTrigger,
|
|
46
|
+
Progress,
|
|
46
47
|
Textarea,
|
|
47
48
|
Tooltip,
|
|
48
49
|
UserAvatar,
|
|
@@ -115,6 +116,14 @@ function pendingViews(p: PendingMessage): AttachmentView[] {
|
|
|
115
116
|
}));
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
/** How much of a sending message's files has uploaded (0–100), once any file reports progress. */
|
|
120
|
+
export function pendingProgress(p: PendingMessage): number | null {
|
|
121
|
+
if (p.status !== "sending") return null;
|
|
122
|
+
const files = p.files ?? [];
|
|
123
|
+
if (!files.some((f) => typeof f.progress === "number")) return null;
|
|
124
|
+
return Math.round((files.reduce((sum, f) => sum + (f.progress ?? 0), 0) / files.length) * 100);
|
|
125
|
+
}
|
|
126
|
+
|
|
118
127
|
/** A menu gives focus back to what opened it, unless the action put it somewhere (the composer, the edit box). */
|
|
119
128
|
function returnFocus(e: Event, keepFocus: MutableRefObject<(() => void) | null>) {
|
|
120
129
|
const next = keepFocus.current;
|
|
@@ -153,6 +162,7 @@ export function MessageRow({ item, startsGroup, className }: MessageRowProps) {
|
|
|
153
162
|
const showHeader = ctx.isGroup && !mine && startsGroup;
|
|
154
163
|
const tone: "default" | "primary" = mine && !note && !deleted ? "primary" : "default";
|
|
155
164
|
const views = pending ? pendingViews(pending) : message.attachments.map((a) => toAttachmentView(a, ctx.urls));
|
|
165
|
+
const progress = pending ? pendingProgress(pending) : null;
|
|
156
166
|
const editing = ctx.editingId === message.id;
|
|
157
167
|
const reactions = pending ? [] : summarizeReactions(message, ctx.me);
|
|
158
168
|
const highlighted = ctx.highlightedId === message.id;
|
|
@@ -194,6 +204,18 @@ export function MessageRow({ item, startsGroup, className }: MessageRowProps) {
|
|
|
194
204
|
{views.length ? (
|
|
195
205
|
<div className={cn(message.body ? "mb-1.5" : "")}>
|
|
196
206
|
<MessageAttachments views={views} message={pending ? undefined : message} mine={mine} tone={tone} slot={ctx.attachmentSlot} kinds={ctx.attachmentKinds} />
|
|
207
|
+
{progress !== null ? (
|
|
208
|
+
<Progress
|
|
209
|
+
value={progress}
|
|
210
|
+
className={cn("mt-1.5 h-1", tone === "primary" && "bg-primary-foreground/30 [&>div]:bg-primary-foreground")}
|
|
211
|
+
role="progressbar"
|
|
212
|
+
aria-label="Uploading"
|
|
213
|
+
aria-valuemin={0}
|
|
214
|
+
aria-valuemax={100}
|
|
215
|
+
aria-valuenow={progress}
|
|
216
|
+
data-testid="genesis-chat-upload-progress"
|
|
217
|
+
/>
|
|
218
|
+
) : null}
|
|
197
219
|
</div>
|
|
198
220
|
) : null}
|
|
199
221
|
{message.body ? <ChatMarkdown text={message.body} me={ctx.me} tone={tone} /> : null}
|
package/src/web/inbox.tsx
CHANGED
|
@@ -9,9 +9,9 @@ import { useRef, type KeyboardEvent } from "react";
|
|
|
9
9
|
import { AtSign, BellOff, MessageSquare, Pin } from "lucide-react";
|
|
10
10
|
import { Badge, Button, EmptyState, Skeleton, UserAvatar, cn } from "@marktiderman/genesis-ui";
|
|
11
11
|
import { renderSlot, type SlotOverride } from "@marktiderman/genesis-core/slots";
|
|
12
|
-
import { previewText } from "../core/thread";
|
|
13
12
|
import type { ChatInboxItem } from "../core/types";
|
|
14
|
-
import { useChat, useInbox } from "../react/index";
|
|
13
|
+
import { useChat, useInbox, type ChatAttachmentKind } from "../react/index";
|
|
14
|
+
import { attachmentPreview } from "../react/previews";
|
|
15
15
|
import { relativeTime, type ResolveUser } from "./format";
|
|
16
16
|
|
|
17
17
|
export interface ChatInboxEmptyProps {
|
|
@@ -55,7 +55,7 @@ export function inboxTitle(item: ChatInboxItem, resolveUser: ResolveUser, fallba
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived = false, fallbackTitle, slots, className, ...rest }: ChatInboxProps) {
|
|
58
|
-
const { userId: me } = useChat();
|
|
58
|
+
const { userId: me, attachmentKinds } = useChat();
|
|
59
59
|
const { items, isLoading, error, refetch } = useInbox({ kind, archived });
|
|
60
60
|
const list = useRef<HTMLUListElement>(null);
|
|
61
61
|
|
|
@@ -113,7 +113,7 @@ export function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived =
|
|
|
113
113
|
<ul ref={list} className="flex flex-col" onKeyDown={onKeyDown}>
|
|
114
114
|
{items.map((item) => (
|
|
115
115
|
<li key={item.conversationId}>
|
|
116
|
-
<InboxRow item={item} me={me} resolveUser={resolveUser} fallbackTitle={fallbackTitle} selected={item.conversationId === selectedId} onSelect={onSelect} />
|
|
116
|
+
<InboxRow item={item} me={me} kinds={attachmentKinds} resolveUser={resolveUser} fallbackTitle={fallbackTitle} selected={item.conversationId === selectedId} onSelect={onSelect} />
|
|
117
117
|
</li>
|
|
118
118
|
))}
|
|
119
119
|
</ul>
|
|
@@ -124,18 +124,19 @@ export function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived =
|
|
|
124
124
|
interface InboxRowProps {
|
|
125
125
|
item: ChatInboxItem;
|
|
126
126
|
me: string;
|
|
127
|
+
kinds?: Record<string, ChatAttachmentKind>;
|
|
127
128
|
resolveUser: ResolveUser;
|
|
128
129
|
fallbackTitle?: (item: ChatInboxItem) => string;
|
|
129
130
|
selected: boolean;
|
|
130
131
|
onSelect: (id: string) => void;
|
|
131
132
|
}
|
|
132
133
|
|
|
133
|
-
function InboxRow({ item, me, resolveUser, fallbackTitle, selected, onSelect }: InboxRowProps) {
|
|
134
|
+
function InboxRow({ item, me, kinds, resolveUser, fallbackTitle, selected, onSelect }: InboxRowProps) {
|
|
134
135
|
const title = inboxTitle(item, resolveUser, fallbackTitle);
|
|
135
136
|
const other = item.otherUserId ? resolveUser(item.otherUserId) : null;
|
|
136
137
|
const last = item.lastMessage;
|
|
137
138
|
const unread = item.unread > 0;
|
|
138
|
-
let preview = last ?
|
|
139
|
+
let preview = last ? attachmentPreview(last.body, last.attachment, kinds, last.deleted) : "";
|
|
139
140
|
if (last && last.kind !== "system" && !last.deleted && last.senderId) {
|
|
140
141
|
if (last.senderId === me) preview = `You: ${preview}`;
|
|
141
142
|
else if (!item.otherUserId && item.memberCount > 2) preview = `${resolveUser(last.senderId).name}: ${preview}`;
|
package/src/web/memory-client.ts
CHANGED
|
@@ -468,9 +468,16 @@ export function createMemoryChatClient(options: MemoryChatOptions): MemoryChatCl
|
|
|
468
468
|
log("report", messageId, reason, note);
|
|
469
469
|
return uuid();
|
|
470
470
|
},
|
|
471
|
-
async upload(
|
|
471
|
+
async upload(
|
|
472
|
+
conversationId: string,
|
|
473
|
+
userId: string,
|
|
474
|
+
file: Blob | ArrayBuffer,
|
|
475
|
+
opts: { name: string; contentType: string; id?: string; onProgress?: (sent: number, total: number) => void; signal?: AbortSignal },
|
|
476
|
+
) {
|
|
472
477
|
log("upload", conversationId, opts.name);
|
|
473
478
|
await wait();
|
|
479
|
+
const total = file instanceof Blob ? file.size : file.byteLength;
|
|
480
|
+
opts.onProgress?.(total, total);
|
|
474
481
|
const path = `${conversationId}/${userId}/${opts.id ?? uuid()}-${opts.name}`;
|
|
475
482
|
const blob = file instanceof Blob ? file : new Blob([file], { type: opts.contentType });
|
|
476
483
|
files[path] = typeof URL !== "undefined" && typeof URL.createObjectURL === "function" ? URL.createObjectURL(blob) : `memory://${path}`;
|
|
@@ -569,7 +576,8 @@ export function createMemoryChatClient(options: MemoryChatOptions): MemoryChatCl
|
|
|
569
576
|
},
|
|
570
577
|
async cancelScheduled(id: string) {
|
|
571
578
|
log("cancelScheduled", id);
|
|
572
|
-
|
|
579
|
+
// As cancel_scheduled: anything not sent and not cancelled, a failed one included.
|
|
580
|
+
const s = scheduledList.find((x) => x.id === id && x.status !== "sent" && x.status !== "cancelled");
|
|
573
581
|
if (!s) throw new ChatError("Nothing to cancel", "22023", "cancel_scheduled");
|
|
574
582
|
s.cancelledAt = new Date().toISOString();
|
|
575
583
|
s.status = "cancelled";
|
|
@@ -622,7 +630,7 @@ export function createMemoryChatClient(options: MemoryChatOptions): MemoryChatCl
|
|
|
622
630
|
if (conversationId && c.conversation.id !== conversationId) continue;
|
|
623
631
|
for (const m of c.messages) {
|
|
624
632
|
if (m.deletedAt || !visible(c, m)) continue;
|
|
625
|
-
const base = { messageId: m.id, conversationId: c.conversation.id, seq: m.seq, senderId: m.senderId, createdAt: m.createdAt };
|
|
633
|
+
const base = { messageId: m.id, conversationId: c.conversation.id, seq: m.seq, senderId: m.senderId, createdAt: m.createdAt, threadRootId: m.threadRootId ?? null };
|
|
626
634
|
if (m.body.toLowerCase().includes(q)) hits.push({ ...base, snippet: mark(m.body), inTranscript: false });
|
|
627
635
|
else {
|
|
628
636
|
const t = m.attachments.find((a) => a.transcript?.toLowerCase().includes(q))?.transcript;
|
package/src/web/panel.tsx
CHANGED
|
@@ -62,7 +62,7 @@ export function ChatPanel({
|
|
|
62
62
|
}: ChatPanelProps) {
|
|
63
63
|
const [own, setOwn] = useState<string | null>(defaultSelectedId);
|
|
64
64
|
const [searching, setSearching] = useState(false);
|
|
65
|
-
const [jump, setJump] = useState<{ messageId: string; seq?: number } | null>(null);
|
|
65
|
+
const [jump, setJump] = useState<{ messageId: string; seq?: number; threadRootId?: string | null } | null>(null);
|
|
66
66
|
const { items } = useInbox({ kind: inboxProps?.kind });
|
|
67
67
|
const titleOf = (id: string) => {
|
|
68
68
|
const item = items.find((i: ChatInboxItem) => i.conversationId === id);
|
|
@@ -113,7 +113,7 @@ export function ChatPanel({
|
|
|
113
113
|
autoFocus
|
|
114
114
|
conversationTitle={titleOf}
|
|
115
115
|
onOpen={(hit) => {
|
|
116
|
-
setJump({ messageId: hit.messageId, seq: hit.seq });
|
|
116
|
+
setJump({ messageId: hit.messageId, seq: hit.seq, threadRootId: hit.threadRootId });
|
|
117
117
|
select(hit.conversationId);
|
|
118
118
|
}}
|
|
119
119
|
className="min-h-0 flex-1"
|
package/src/web/schedule.tsx
CHANGED
|
@@ -145,7 +145,7 @@ export interface ChatScheduledListProps {
|
|
|
145
145
|
className?: string;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
/** Your scheduled messages, soonest first, each with when it goes and Cancel. */
|
|
148
|
+
/** Your scheduled messages, soonest first, each with when it goes and Cancel (Remove for one that failed). */
|
|
149
149
|
export function ChatScheduledList({ conversationId, conversationTitle, className }: ChatScheduledListProps) {
|
|
150
150
|
const { items, isLoading, cancel } = useScheduled(conversationId);
|
|
151
151
|
if (isLoading) {
|
|
@@ -172,7 +172,19 @@ export function ChatScheduledList({ conversationId, conversationTitle, className
|
|
|
172
172
|
<div className="truncate text-sm">{previewText(s.body, s.attachments[0]?.kind ?? null)}</div>
|
|
173
173
|
{s.error ? <div className="text-xs text-destructive">{s.error}</div> : null}
|
|
174
174
|
</div>
|
|
175
|
-
{s.status === "
|
|
175
|
+
{s.status === "failed" ? (
|
|
176
|
+
<Button
|
|
177
|
+
type="button"
|
|
178
|
+
variant="ghost"
|
|
179
|
+
size="sm"
|
|
180
|
+
className="h-7 shrink-0 gap-1 text-xs"
|
|
181
|
+
onClick={() => void cancel(s.id).then(() => toast.success("Removed"), () => toast.error("Couldn't remove it"))}
|
|
182
|
+
aria-label="Remove the message that couldn't send"
|
|
183
|
+
data-testid="genesis-chat-scheduled-remove"
|
|
184
|
+
>
|
|
185
|
+
<X className="size-3.5" aria-hidden /> Remove
|
|
186
|
+
</Button>
|
|
187
|
+
) : s.status === "scheduled" ? (
|
|
176
188
|
<Button
|
|
177
189
|
type="button"
|
|
178
190
|
variant="ghost"
|
package/src/web/thread.tsx
CHANGED
|
@@ -127,15 +127,18 @@ export interface ChatThreadProps {
|
|
|
127
127
|
threadRootId?: string;
|
|
128
128
|
/** One topic's messages. */
|
|
129
129
|
topic?: string;
|
|
130
|
-
/**
|
|
131
|
-
|
|
130
|
+
/**
|
|
131
|
+
* Shown on messages that have replies, to open their thread. Also called when a search result
|
|
132
|
+
* is a reply in a thread, with `jumpTo` (that reply) for the thread's own ChatThread.
|
|
133
|
+
*/
|
|
134
|
+
onOpenThread?: (message: ChatMessage, opts?: { jumpTo?: { messageId: string; seq: number } }) => void;
|
|
132
135
|
/** The quick reactions; 👍 ❤️ 😂 😮 😢 🙏 by default. */
|
|
133
136
|
quickReactions?: readonly string[];
|
|
134
137
|
placeholder?: string;
|
|
135
138
|
/** Show the topic bar even before the first topic exists (so an owner can add one). */
|
|
136
139
|
showTopics?: boolean;
|
|
137
140
|
/** Scroll to this message once it's loaded (a search result across conversations); `seq` lets earlier pages load to reach it. */
|
|
138
|
-
jumpTo?: { messageId: string; seq?: number } | null;
|
|
141
|
+
jumpTo?: { messageId: string; seq?: number; threadRootId?: string | null } | null;
|
|
139
142
|
/** A search button in the header. Default true. */
|
|
140
143
|
search?: boolean;
|
|
141
144
|
/** Scheduled send in the composer. Default true. */
|
|
@@ -221,7 +224,7 @@ export function ChatThread({
|
|
|
221
224
|
slots,
|
|
222
225
|
className,
|
|
223
226
|
}: ChatThreadProps) {
|
|
224
|
-
const { userId: me, attachmentKinds } = useChat();
|
|
227
|
+
const { userId: me, attachmentKinds, client: chatClient } = useChat();
|
|
225
228
|
const topicsState = useTopics(threadRootId ? null : conversationId);
|
|
226
229
|
const [chosenTopic, setChosenTopic] = useState<string | null>(null);
|
|
227
230
|
const topic = topicProp ?? chosenTopic ?? undefined;
|
|
@@ -394,9 +397,34 @@ export function ChatThread({
|
|
|
394
397
|
|
|
395
398
|
// A message to reach: loads earlier pages until it is here (leaving a topic it isn't in), then scrolls to it.
|
|
396
399
|
type Jump = { messageId: string; seq?: number; phase: "new" | "loading" | "loaded" };
|
|
397
|
-
|
|
400
|
+
/**
|
|
401
|
+
* A reply in a thread isn't on this timeline: open its thread (onOpenThread, scrolled to the
|
|
402
|
+
* reply) and come here to the thread's first message; without onOpenThread, just to that.
|
|
403
|
+
*/
|
|
404
|
+
const reach = useCallback(
|
|
405
|
+
(target: { messageId: string; seq?: number; threadRootId?: string | null }) => {
|
|
406
|
+
const root = target.threadRootId;
|
|
407
|
+
if (!root || root === threadRootId || root === target.messageId) {
|
|
408
|
+
setPendingJump({ messageId: target.messageId, seq: target.seq, phase: "new" });
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
void chatClient.message(root).then(
|
|
412
|
+
(m) => {
|
|
413
|
+
if (!m) {
|
|
414
|
+
toast.info("That message isn't here any more");
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
onOpenThread?.(m, target.seq != null ? { jumpTo: { messageId: target.messageId, seq: target.seq } } : undefined);
|
|
418
|
+
setPendingJump({ messageId: m.id, seq: m.seq, phase: "new" });
|
|
419
|
+
},
|
|
420
|
+
(e) => toast.error(errorText(e)),
|
|
421
|
+
);
|
|
422
|
+
},
|
|
423
|
+
[chatClient, onOpenThread, threadRootId],
|
|
424
|
+
);
|
|
425
|
+
const [pendingJump, setPendingJump] = useState<Jump | null>(jumpToProp && !jumpToProp.threadRootId ? { ...jumpToProp, phase: "new" } : null);
|
|
398
426
|
useEffect(() => {
|
|
399
|
-
if (jumpToProp)
|
|
427
|
+
if (jumpToProp) reach(jumpToProp);
|
|
400
428
|
}, [jumpToProp?.messageId, jumpToProp?.seq]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
401
429
|
const { loadUntil } = state;
|
|
402
430
|
useEffect(() => {
|
|
@@ -426,9 +454,9 @@ export function ChatThread({
|
|
|
426
454
|
(hit: ChatSearchHit) => {
|
|
427
455
|
setSearching(false);
|
|
428
456
|
if (topic && topicProp === undefined) setChosenTopic(null);
|
|
429
|
-
|
|
457
|
+
reach(hit);
|
|
430
458
|
},
|
|
431
|
-
[topic, topicProp],
|
|
459
|
+
[topic, topicProp, reach],
|
|
432
460
|
);
|
|
433
461
|
|
|
434
462
|
// ---- actions --------------------------------------------------------------
|
package/src/web/video.tsx
CHANGED
|
@@ -220,21 +220,26 @@ export function useVideoRecorder({ maxMs, onTick }: UseVideoRecorderOptions) {
|
|
|
220
220
|
|
|
221
221
|
export type VideoRecorder = ReturnType<typeof useVideoRecorder>;
|
|
222
222
|
|
|
223
|
-
/**
|
|
224
|
-
|
|
223
|
+
/**
|
|
224
|
+
* A recording as a file for the outbox: `hosted` sends it to the hosted provider (the provider's
|
|
225
|
+
* `videoUpload`, the message going at once with the video pending); otherwise it goes to storage.
|
|
226
|
+
*/
|
|
227
|
+
export function videoFile(rec: VideoRecording, opts: { hosted?: boolean } = {}): LocalFile {
|
|
225
228
|
return {
|
|
226
229
|
data: rec.blob,
|
|
227
230
|
name: rec.name,
|
|
228
231
|
contentType: rec.contentType,
|
|
229
232
|
previewUrl: rec.url ?? undefined,
|
|
230
233
|
meta: { kind: "video", durationMs: rec.durationMs, bytes: rec.blob.size },
|
|
234
|
+
...(opts.hosted ? { hosted: true } : {}),
|
|
231
235
|
};
|
|
232
236
|
}
|
|
233
237
|
|
|
234
238
|
/**
|
|
235
239
|
* Uploads a recording through the app's `videoUpload` (a Mux direct upload: PUT the bytes to
|
|
236
240
|
* the URL it returns) and gives back the attachment to send, pending until Mux's webhook marks
|
|
237
|
-
* it ready.
|
|
241
|
+
* it ready. The composer no longer uses it: it sends `videoFile(rec, { hosted: true })` through
|
|
242
|
+
* the outbox, so the message keeps its place in the order while the bytes go up.
|
|
238
243
|
*/
|
|
239
244
|
export async function uploadVideo(
|
|
240
245
|
videoUpload: ChatVideoUpload,
|
|
@@ -268,10 +273,15 @@ export interface ChatVideoRecorderProps {
|
|
|
268
273
|
send: (input: Omit<SendInput, "conversationId" | "clientId">, files?: LocalFile[]) => Promise<unknown>;
|
|
269
274
|
/** Merged into what's sent (a reply, a topic, an internal note). */
|
|
270
275
|
extra?: Omit<SendInput, "conversationId" | "clientId" | "attachments" | "body">;
|
|
276
|
+
/**
|
|
277
|
+
* Marks the take as hosted. Pass the same `videoUpload` the `ChatProvider` has (or give the
|
|
278
|
+
* provider a `hostedUpload`): the outbox does the upload, and without one it falls back to
|
|
279
|
+
* storage with no error.
|
|
280
|
+
*/
|
|
271
281
|
videoUpload?: ChatVideoUpload;
|
|
272
282
|
onTick?: () => void;
|
|
273
283
|
onSent?: () => void;
|
|
274
|
-
/**
|
|
284
|
+
/** @deprecated Unused: the bytes go through the outbox's `hostedUpload` now. */
|
|
275
285
|
fetchImpl?: typeof fetch;
|
|
276
286
|
}
|
|
277
287
|
|
|
@@ -290,56 +300,45 @@ function Preview({ stream }: { stream: MediaStream | null }) {
|
|
|
290
300
|
}
|
|
291
301
|
|
|
292
302
|
/** The composer's video dialog: preview, record (to the role's limit), review, retake, send. */
|
|
293
|
-
export function ChatVideoRecorder({ open, onOpenChange,
|
|
303
|
+
export function ChatVideoRecorder({ open, onOpenChange, maxMs, send, extra, videoUpload, onTick, onSent }: ChatVideoRecorderProps) {
|
|
294
304
|
const rec = useVideoRecorder({ maxMs, onTick });
|
|
295
305
|
const [sending, setSending] = useState(false);
|
|
296
306
|
const [sendError, setSendError] = useState<string | null>(null);
|
|
297
|
-
|
|
307
|
+
// One send at a time; once sent, the outbox owns the upload (its Delete cancels it).
|
|
308
|
+
const sendingNow = useRef(false);
|
|
298
309
|
const { open: openCamera, reset } = rec;
|
|
299
310
|
|
|
300
311
|
useEffect(() => {
|
|
301
312
|
if (open) void openCamera();
|
|
302
313
|
else reset();
|
|
303
314
|
}, [open, openCamera, reset]);
|
|
304
|
-
// Closing or unmounting stops an upload still going.
|
|
305
|
-
useEffect(() => () => upload.current?.abort(), []);
|
|
306
315
|
|
|
307
316
|
const submit = async () => {
|
|
308
317
|
const take = rec.recording;
|
|
309
|
-
if (!take ||
|
|
310
|
-
|
|
311
|
-
upload.current = controller;
|
|
318
|
+
if (!take || sendingNow.current) return;
|
|
319
|
+
sendingNow.current = true;
|
|
312
320
|
setSending(true);
|
|
313
321
|
setSendError(null);
|
|
314
322
|
try {
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
} else {
|
|
320
|
-
// The outbox shows the take while it uploads and frees its URL once it lands.
|
|
321
|
-
await send({ ...extra, body: "" }, [videoFile(take)]);
|
|
322
|
-
rec.handOff();
|
|
323
|
-
}
|
|
323
|
+
// Through the outbox either way, in its place in the order: shown at once, the take as its
|
|
324
|
+
// preview (freed once it lands). Hosted, the message goes while the bytes go up.
|
|
325
|
+
await send({ ...extra, body: "" }, [videoFile(take, { hosted: Boolean(videoUpload) })]);
|
|
326
|
+
rec.handOff();
|
|
324
327
|
onSent?.();
|
|
325
328
|
onOpenChange(false);
|
|
326
329
|
} catch (e) {
|
|
327
|
-
|
|
330
|
+
setSendError(e instanceof Error ? e.message : "The video didn't send");
|
|
328
331
|
} finally {
|
|
329
|
-
|
|
332
|
+
sendingNow.current = false;
|
|
330
333
|
setSending(false);
|
|
331
334
|
}
|
|
332
335
|
};
|
|
333
336
|
|
|
334
|
-
/** Closing while it uploads cancels the upload. */
|
|
335
|
-
const close = (o: boolean) => {
|
|
336
|
-
if (!o) upload.current?.abort();
|
|
337
|
-
onOpenChange(o);
|
|
338
|
-
};
|
|
339
337
|
|
|
340
338
|
const recording = rec.state === "recording";
|
|
339
|
+
// It stays open while the take goes to the outbox: closing would free the preview it was handed.
|
|
341
340
|
return (
|
|
342
|
-
<Dialog open={open} onOpenChange={
|
|
341
|
+
<Dialog open={open} onOpenChange={(o) => (o || !sendingNow.current) && onOpenChange(o)}>
|
|
343
342
|
<DialogContent className="sm:max-w-lg" data-testid="genesis-chat-video-recorder">
|
|
344
343
|
<DialogHeader>
|
|
345
344
|
<DialogTitle>Record a video</DialogTitle>
|
|
@@ -370,12 +369,7 @@ export function ChatVideoRecorder({ open, onOpenChange, conversationId, maxMs, s
|
|
|
370
369
|
) : null}
|
|
371
370
|
{sending ? (
|
|
372
371
|
<span className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-background/70 text-sm font-medium" role="status">
|
|
373
|
-
<Spinner />
|
|
374
|
-
{videoUpload ? (
|
|
375
|
-
<Button type="button" variant="outline" size="sm" onClick={() => upload.current?.abort()} data-testid="genesis-chat-video-cancel-upload">
|
|
376
|
-
Cancel upload
|
|
377
|
-
</Button>
|
|
378
|
-
) : null}
|
|
372
|
+
<Spinner /> Sending…
|
|
379
373
|
</span>
|
|
380
374
|
) : null}
|
|
381
375
|
</div>
|