@marktiderman/genesis-chat 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +379 -13
- package/dist/chunk-A4YJX7KT.js +544 -0
- package/dist/chunk-A4YJX7KT.js.map +1 -0
- package/dist/chunk-AZS3HNLM.js +30 -0
- package/dist/chunk-AZS3HNLM.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-GBJ3YI4E.js +966 -0
- package/dist/chunk-GBJ3YI4E.js.map +1 -0
- package/dist/chunk-JU2FAPBI.js +609 -0
- package/dist/chunk-JU2FAPBI.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1939 -1
- package/dist/native.js +5023 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B9NAWQ58.d.ts +219 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +176 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +551 -21
- package/dist/server.js +976 -34
- package/dist/server.js.map +1 -1
- package/dist/thread-oPra7pkW.d.ts +57 -0
- package/dist/web.d.ts +335 -12
- package/dist/web.js +1784 -64
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/0003_genesis_chat_search_threads.sql +85 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
- package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
- package/package.json +78 -6
- package/src/__tests__/coaching-core.test.ts +334 -0
- package/src/__tests__/coaching-react.test.tsx +319 -0
- package/src/__tests__/coaching-web.test.tsx +521 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/fake-xhr.ts +54 -0
- package/src/__tests__/outbox-hosted.test.ts +269 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-279.test.ts +41 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +405 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fake-video.test.ts +81 -0
- package/src/__tests__/server-fakes.ts +96 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +303 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +349 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +783 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +635 -0
- package/src/core/thread.ts +171 -0
- package/src/core/transfer.ts +71 -0
- package/src/core/types.ts +328 -0
- package/src/index.ts +14 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +254 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +424 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +498 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +132 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +122 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +126 -0
- package/src/native/message-bubble.tsx +366 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +277 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +328 -0
- package/src/native/search-screen.tsx +189 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +647 -0
- package/src/native/topics.tsx +234 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +410 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +274 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +301 -0
- package/src/react/index.tsx +894 -0
- package/src/react/previews.ts +51 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/fake-video.ts +171 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +58 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +585 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +196 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +714 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +204 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +830 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +516 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js +0 -525
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js +0 -309
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Pure thread logic, the same on every platform: merging the server's messages
|
|
4
|
+
* with ones still on their way, grouping a run of messages from one sender,
|
|
5
|
+
* day dividers, the unread line, reactions by emoji, and the typing line.
|
|
6
|
+
*/
|
|
7
|
+
import type { ChatActivity, ChatMessage, PendingMessage } from "./types";
|
|
8
|
+
|
|
9
|
+
export type ThreadItem =
|
|
10
|
+
| { type: "message"; message: ChatMessage; pending?: undefined }
|
|
11
|
+
| { type: "pending"; pending: PendingMessage; message?: undefined };
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The server's messages (by seq), then what is still sending, oldest first. A
|
|
15
|
+
* pending message the server already has (same clientId) is dropped, so a send
|
|
16
|
+
* that landed never shows twice.
|
|
17
|
+
*/
|
|
18
|
+
export function mergeThread(server: ChatMessage[], pending: PendingMessage[]): ThreadItem[] {
|
|
19
|
+
const byId = new Map<string, ChatMessage>();
|
|
20
|
+
for (const m of server) byId.set(m.id, m);
|
|
21
|
+
const sorted = [...byId.values()].sort((a, b) => a.seq - b.seq);
|
|
22
|
+
const landed = new Set(sorted.map((m) => m.clientId));
|
|
23
|
+
const waiting = pending
|
|
24
|
+
.filter((p) => !landed.has(p.clientId))
|
|
25
|
+
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
26
|
+
return [
|
|
27
|
+
...sorted.map((message) => ({ type: "message" as const, message })),
|
|
28
|
+
...waiting.map((p) => ({ type: "pending" as const, pending: p })),
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Adds or replaces messages in a page, keeping it sorted and free of duplicates. */
|
|
33
|
+
export function upsertMessages(existing: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
|
|
34
|
+
const byId = new Map(existing.map((m) => [m.id, m]));
|
|
35
|
+
for (const m of incoming) byId.set(m.id, m);
|
|
36
|
+
return [...byId.values()].sort((a, b) => a.seq - b.seq);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function itemSender(item: ThreadItem): string | null {
|
|
40
|
+
return item.type === "message" ? item.message.senderId : item.pending.senderId;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function itemTime(item: ThreadItem): string {
|
|
44
|
+
return item.type === "message" ? item.message.createdAt : item.pending.createdAt;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Runs of one sender within five minutes share a header (name, avatar). */
|
|
48
|
+
export function startsGroup(items: ThreadItem[], index: number, gapMs = 5 * 60_000): boolean {
|
|
49
|
+
if (index === 0) return true;
|
|
50
|
+
const prev = items[index - 1]!;
|
|
51
|
+
const cur = items[index]!;
|
|
52
|
+
if (cur.type === "message" && cur.message.kind === "system") return true;
|
|
53
|
+
if (prev.type === "message" && prev.message.kind === "system") return true;
|
|
54
|
+
if (itemSender(prev) !== itemSender(cur)) return true;
|
|
55
|
+
return Date.parse(itemTime(cur)) - Date.parse(itemTime(prev)) > gapMs || !sameDay(itemTime(prev), itemTime(cur));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function sameDay(a: string, b: string): boolean {
|
|
59
|
+
const x = new Date(a);
|
|
60
|
+
const y = new Date(b);
|
|
61
|
+
return x.getFullYear() === y.getFullYear() && x.getMonth() === y.getMonth() && x.getDate() === y.getDate();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** A day divider goes before this item. */
|
|
65
|
+
export function startsDay(items: ThreadItem[], index: number): boolean {
|
|
66
|
+
return index === 0 || !sameDay(itemTime(items[index - 1]!), itemTime(items[index]!));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** "Today", "Yesterday", "Monday", or "12 Mar 2026". */
|
|
70
|
+
export function dayLabel(iso: string, now = new Date(), locale?: string): string {
|
|
71
|
+
const d = new Date(iso);
|
|
72
|
+
const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
|
73
|
+
const days = Math.round((startOf(now) - startOf(d)) / 86_400_000);
|
|
74
|
+
if (days === 0) return "Today";
|
|
75
|
+
if (days === 1) return "Yesterday";
|
|
76
|
+
if (days > 1 && days < 7) return d.toLocaleDateString(locale, { weekday: "long" });
|
|
77
|
+
return d.toLocaleDateString(locale, { day: "numeric", month: "short", year: d.getFullYear() === now.getFullYear() ? undefined : "numeric" });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The first message after what you had read, from someone else: where "New" goes. */
|
|
81
|
+
export function firstUnreadId(messages: ChatMessage[], lastReadSeq: number, me: string): string | null {
|
|
82
|
+
return messages.find((m) => m.seq > lastReadSeq && m.senderId !== me && !m.deletedAt)?.id ?? null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ReactionSummary {
|
|
86
|
+
emoji: string;
|
|
87
|
+
count: number;
|
|
88
|
+
mine: boolean;
|
|
89
|
+
userIds: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Reactions by emoji, most first, then in first-used order. */
|
|
93
|
+
export function summarizeReactions(message: ChatMessage, me: string): ReactionSummary[] {
|
|
94
|
+
const order: string[] = [];
|
|
95
|
+
const by = new Map<string, string[]>();
|
|
96
|
+
for (const r of message.reactions) {
|
|
97
|
+
if (!by.has(r.emoji)) {
|
|
98
|
+
by.set(r.emoji, []);
|
|
99
|
+
order.push(r.emoji);
|
|
100
|
+
}
|
|
101
|
+
by.get(r.emoji)!.push(r.userId);
|
|
102
|
+
}
|
|
103
|
+
return order
|
|
104
|
+
.map((emoji) => ({ emoji, count: by.get(emoji)!.length, mine: by.get(emoji)!.includes(me), userIds: by.get(emoji)! }))
|
|
105
|
+
.sort((a, b) => b.count - a.count || order.indexOf(a.emoji) - order.indexOf(b.emoji));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Who is doing what now: activity older than six seconds has stopped. */
|
|
109
|
+
export function currentActivity(activity: ChatActivity[], now = Date.now(), staleMs = 6000): ChatActivity[] {
|
|
110
|
+
const latest = new Map<string, ChatActivity>();
|
|
111
|
+
for (const a of activity) {
|
|
112
|
+
if (now - a.at > staleMs) continue;
|
|
113
|
+
const prev = latest.get(a.userId);
|
|
114
|
+
if (!prev || prev.at < a.at) latest.set(a.userId, a);
|
|
115
|
+
}
|
|
116
|
+
return [...latest.values()];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** "Sam is typing…", "Sam and Lee are typing…", "Sam is recording a voice message…". */
|
|
120
|
+
export function activityLine(activity: ChatActivity[], nameOf: (userId: string) => string): string | null {
|
|
121
|
+
if (activity.length === 0) return null;
|
|
122
|
+
const recording = activity.find((a) => a.activity !== "typing");
|
|
123
|
+
if (recording) {
|
|
124
|
+
return `${nameOf(recording.userId)} is recording ${recording.activity === "recording-video" ? "a video" : "a voice message"}…`;
|
|
125
|
+
}
|
|
126
|
+
const names = activity.map((a) => nameOf(a.userId));
|
|
127
|
+
if (names.length === 1) return `${names[0]} is typing…`;
|
|
128
|
+
if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`;
|
|
129
|
+
return "Several people are typing…";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Sends activity at most once every `everyMs`. */
|
|
133
|
+
export function throttle(fn: () => void, everyMs = 3000): () => void {
|
|
134
|
+
let last = 0;
|
|
135
|
+
return () => {
|
|
136
|
+
const now = Date.now();
|
|
137
|
+
if (now - last >= everyMs) {
|
|
138
|
+
last = now;
|
|
139
|
+
fn();
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The ids @mentioned in markdown written as `@[Name](user:<uuid>)`. */
|
|
145
|
+
export function parseMentions(body: string): string[] {
|
|
146
|
+
const ids = new Set<string>();
|
|
147
|
+
for (const m of body.matchAll(/@\[[^\]]{1,80}\]\(user:([0-9a-f-]{36})\)/gi)) ids.add(m[1]!.toLowerCase());
|
|
148
|
+
return [...ids];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A one-line preview: text, or what was attached. */
|
|
152
|
+
export function previewText(body: string, attachment: string | null | undefined, deleted = false): string {
|
|
153
|
+
if (deleted) return "Message deleted";
|
|
154
|
+
const text = body.replace(/@\[([^\]]+)\]\(user:[0-9a-f-]{36}\)/gi, "@$1").replace(/[*_`#>~]/g, "").trim();
|
|
155
|
+
if (text) return text.length > 140 ? `${text.slice(0, 139)}…` : text;
|
|
156
|
+
switch (attachment) {
|
|
157
|
+
case "image":
|
|
158
|
+
return "Photo";
|
|
159
|
+
case "voice":
|
|
160
|
+
return "Voice message";
|
|
161
|
+
case "video":
|
|
162
|
+
return "Video";
|
|
163
|
+
case "file":
|
|
164
|
+
return "File";
|
|
165
|
+
case null:
|
|
166
|
+
case undefined:
|
|
167
|
+
return "";
|
|
168
|
+
default:
|
|
169
|
+
return "Attachment";
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* The chat contract: what the genesis_chat schema (migrations 0001 and 0002) stores, in
|
|
4
|
+
* the app's words. Everything here is plain data, shared by web, native and
|
|
5
|
+
* the server.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** owner, admin or member of a conversation; observer when the kind's observer check lets you read it. */
|
|
9
|
+
export type ChatRole = "owner" | "admin" | "member" | "observer";
|
|
10
|
+
|
|
11
|
+
/** Built in: image, file, voice, video, link. Apps add their own (form, lesson, worksheet…). */
|
|
12
|
+
export type AttachmentKind = "image" | "file" | "voice" | "video" | "link" | (string & {});
|
|
13
|
+
|
|
14
|
+
export type AttachmentProvider = "storage" | "mux" | "cloudflare" | "external" | "none";
|
|
15
|
+
|
|
16
|
+
export interface ChatAttachment {
|
|
17
|
+
id: string;
|
|
18
|
+
messageId: string;
|
|
19
|
+
position: number;
|
|
20
|
+
kind: AttachmentKind;
|
|
21
|
+
/** The app's reference for its own kinds, e.g. `{ formId, versionHash }`. */
|
|
22
|
+
ref: Record<string, unknown>;
|
|
23
|
+
provider: AttachmentProvider;
|
|
24
|
+
/** The object in the genesis-chat bucket (`<conversation>/<uploader>/<name>.<ext>`). */
|
|
25
|
+
path: string | null;
|
|
26
|
+
providerId: string | null;
|
|
27
|
+
playbackId: string | null;
|
|
28
|
+
url: string | null;
|
|
29
|
+
status: "pending" | "ready" | "failed";
|
|
30
|
+
name: string | null;
|
|
31
|
+
mime: string | null;
|
|
32
|
+
bytes: number | null;
|
|
33
|
+
width: number | null;
|
|
34
|
+
height: number | null;
|
|
35
|
+
durationMs: number | null;
|
|
36
|
+
/** Peaks 0–100, at most 128, drawn before the audio loads. */
|
|
37
|
+
waveform: number[] | null;
|
|
38
|
+
posterPath: string | null;
|
|
39
|
+
thumbhash: string | null;
|
|
40
|
+
transcript: string | null;
|
|
41
|
+
transcriptStatus: "pending" | "ready" | "failed" | "off" | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ChatReaction {
|
|
45
|
+
emoji: string;
|
|
46
|
+
userId: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ChatMessage {
|
|
50
|
+
id: string;
|
|
51
|
+
conversationId: string;
|
|
52
|
+
seq: number;
|
|
53
|
+
clientId: string;
|
|
54
|
+
senderId: string | null;
|
|
55
|
+
senderRole: ChatRole | "system";
|
|
56
|
+
kind: "text" | "system";
|
|
57
|
+
/** Markdown. Empty for a deleted message. */
|
|
58
|
+
body: string;
|
|
59
|
+
mentions: string[];
|
|
60
|
+
replyToId: string | null;
|
|
61
|
+
threadRootId: string | null;
|
|
62
|
+
threadCount: number;
|
|
63
|
+
threadLastAt: string | null;
|
|
64
|
+
topic: string | null;
|
|
65
|
+
visibility: "everyone" | "observers";
|
|
66
|
+
silent: boolean;
|
|
67
|
+
meta: Record<string, unknown>;
|
|
68
|
+
createdAt: string;
|
|
69
|
+
editedAt: string | null;
|
|
70
|
+
deletedAt: string | null;
|
|
71
|
+
/**
|
|
72
|
+
* What moderation said (migration 0002): `hidden` messages reach only their sender and the
|
|
73
|
+
* conversation's observers; `flagged` ones are visible to everyone and reported to observers.
|
|
74
|
+
*/
|
|
75
|
+
moderation?: "flagged" | "hidden" | null;
|
|
76
|
+
attachments: ChatAttachment[];
|
|
77
|
+
reactions: ChatReaction[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface ChatConversation {
|
|
81
|
+
id: string;
|
|
82
|
+
kind: string;
|
|
83
|
+
title: string | null;
|
|
84
|
+
subjectType: string | null;
|
|
85
|
+
subjectId: string | null;
|
|
86
|
+
settings: { who_posts?: "members" | "admins" } & Record<string, unknown>;
|
|
87
|
+
status: "open" | "pending" | "solved" | "closed" | null;
|
|
88
|
+
assigneeId: string | null;
|
|
89
|
+
lastSeq: number;
|
|
90
|
+
lastMessageAt: string | null;
|
|
91
|
+
createdAt: string;
|
|
92
|
+
archivedAt: string | null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ChatMember {
|
|
96
|
+
conversationId: string;
|
|
97
|
+
userId: string;
|
|
98
|
+
role: ChatRole;
|
|
99
|
+
lastReadSeq: number;
|
|
100
|
+
joinedAt: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A kind's rules, as genesis_chat.kinds holds them. */
|
|
104
|
+
export interface ChatKindRules {
|
|
105
|
+
kind: string;
|
|
106
|
+
label: string;
|
|
107
|
+
/** 2 for a direct conversation. */
|
|
108
|
+
maxMembers: number | null;
|
|
109
|
+
whoPosts: "members" | "admins";
|
|
110
|
+
membership: "app" | "admins";
|
|
111
|
+
threads: boolean;
|
|
112
|
+
observersPost: boolean;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** The latest message, as the inbox previews it. */
|
|
116
|
+
export interface ChatPreview {
|
|
117
|
+
id: string;
|
|
118
|
+
seq: number;
|
|
119
|
+
senderId: string | null;
|
|
120
|
+
kind: "text" | "system";
|
|
121
|
+
body: string;
|
|
122
|
+
deleted: boolean;
|
|
123
|
+
createdAt: string;
|
|
124
|
+
attachment: AttachmentKind | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export interface ChatInboxItem {
|
|
128
|
+
conversationId: string;
|
|
129
|
+
kind: string;
|
|
130
|
+
title: string | null;
|
|
131
|
+
subjectType: string | null;
|
|
132
|
+
subjectId: string | null;
|
|
133
|
+
status: ChatConversation["status"];
|
|
134
|
+
role: ChatRole;
|
|
135
|
+
/** The other person, in a two-person kind. */
|
|
136
|
+
otherUserId: string | null;
|
|
137
|
+
memberCount: number;
|
|
138
|
+
lastSeq: number;
|
|
139
|
+
lastReadSeq: number;
|
|
140
|
+
/** Counted to 99. */
|
|
141
|
+
unread: number;
|
|
142
|
+
mentioned: boolean;
|
|
143
|
+
muted: boolean;
|
|
144
|
+
pinned: boolean;
|
|
145
|
+
archived: boolean;
|
|
146
|
+
lastMessageAt: string | null;
|
|
147
|
+
lastMessage: ChatPreview | null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** What a sender attaches; files are uploaded first (ChatClient.upload). */
|
|
151
|
+
export interface AttachmentInput {
|
|
152
|
+
kind: AttachmentKind;
|
|
153
|
+
path?: string;
|
|
154
|
+
provider?: AttachmentProvider;
|
|
155
|
+
providerId?: string;
|
|
156
|
+
url?: string;
|
|
157
|
+
ref?: Record<string, unknown>;
|
|
158
|
+
status?: "pending" | "ready";
|
|
159
|
+
name?: string;
|
|
160
|
+
mime?: string;
|
|
161
|
+
bytes?: number;
|
|
162
|
+
width?: number;
|
|
163
|
+
height?: number;
|
|
164
|
+
durationMs?: number;
|
|
165
|
+
waveform?: number[];
|
|
166
|
+
posterPath?: string;
|
|
167
|
+
thumbhash?: string;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface SendInput {
|
|
171
|
+
conversationId: string;
|
|
172
|
+
/** Made once per message on the device, so a retry never posts twice. */
|
|
173
|
+
clientId: string;
|
|
174
|
+
body?: string;
|
|
175
|
+
attachments?: AttachmentInput[];
|
|
176
|
+
replyToId?: string | null;
|
|
177
|
+
threadRootId?: string | null;
|
|
178
|
+
topic?: string | null;
|
|
179
|
+
mentions?: string[];
|
|
180
|
+
visibility?: "everyone" | "observers";
|
|
181
|
+
silent?: boolean;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** A message on its way: shown at once, swapped for the server's when it lands. */
|
|
185
|
+
export interface PendingMessage {
|
|
186
|
+
clientId: string;
|
|
187
|
+
input: SendInput;
|
|
188
|
+
senderId: string;
|
|
189
|
+
createdAt: string;
|
|
190
|
+
status: "sending" | "failed";
|
|
191
|
+
error?: string;
|
|
192
|
+
/** The server refused it (not allowed, bad data): only a manual retry sends it again. */
|
|
193
|
+
permanent?: boolean;
|
|
194
|
+
/** Local previews for files still uploading (object URLs or file URIs). */
|
|
195
|
+
previews?: Record<number, string>;
|
|
196
|
+
/** What each file is, in order, so a pending bubble can draw it (a voice note's duration and waveform, a file's name). */
|
|
197
|
+
files?: PendingFile[];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface PendingFile {
|
|
201
|
+
name: string;
|
|
202
|
+
contentType: string;
|
|
203
|
+
kind: AttachmentKind;
|
|
204
|
+
meta?: Partial<AttachmentInput>;
|
|
205
|
+
/** Not kept between launches (bytes with nowhere to save them): attach it again. */
|
|
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";
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** A live hint from Realtime: something changed; fetch to see what. */
|
|
225
|
+
export type ChatSignal =
|
|
226
|
+
| { type: "message"; conversationId: string; id: string; seq?: number; op: "insert" | "update" }
|
|
227
|
+
| { type: "reaction"; conversationId: string; messageId: string }
|
|
228
|
+
| { type: "members"; conversationId: string }
|
|
229
|
+
| { type: "conversation"; conversationId: string }
|
|
230
|
+
| { type: "inbox"; conversationId: string; seq?: number };
|
|
231
|
+
|
|
232
|
+
/** Ephemeral, never stored: someone typing or recording. */
|
|
233
|
+
export interface ChatActivity {
|
|
234
|
+
userId: string;
|
|
235
|
+
activity: "typing" | "recording-voice" | "recording-video";
|
|
236
|
+
at: number;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Migration 0002: topics, scheduled send, availability, search, link previews ──
|
|
240
|
+
|
|
241
|
+
/** A named sub-thread of a conversation (a cohort's lesson threads). Messages join one through `topic`. */
|
|
242
|
+
export interface ChatTopic {
|
|
243
|
+
conversationId: string;
|
|
244
|
+
/** The key messages carry in `topic`. */
|
|
245
|
+
topic: string;
|
|
246
|
+
title: string;
|
|
247
|
+
position: number;
|
|
248
|
+
/** Set when closed: only owners, admins and observers post in it then. */
|
|
249
|
+
closedAt: string | null;
|
|
250
|
+
createdBy: string | null;
|
|
251
|
+
createdAt: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** What `setTopic` changes; anything left out keeps its value (a new topic's title is its key). */
|
|
255
|
+
export interface TopicInput {
|
|
256
|
+
title?: string;
|
|
257
|
+
position?: number;
|
|
258
|
+
closed?: boolean;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A message to send later, as its sender. Only its sender reads it. */
|
|
262
|
+
export interface ScheduledMessage {
|
|
263
|
+
id: string;
|
|
264
|
+
conversationId: string;
|
|
265
|
+
clientId: string;
|
|
266
|
+
body: string;
|
|
267
|
+
attachments: AttachmentInput[];
|
|
268
|
+
replyToId: string | null;
|
|
269
|
+
topic: string | null;
|
|
270
|
+
mentions: string[];
|
|
271
|
+
silent: boolean;
|
|
272
|
+
sendAt: string;
|
|
273
|
+
sentMessageId: string | null;
|
|
274
|
+
cancelledAt: string | null;
|
|
275
|
+
/** Why it couldn't be sent when due (the sender left the conversation, say). */
|
|
276
|
+
error: string | null;
|
|
277
|
+
createdAt: string;
|
|
278
|
+
status: "scheduled" | "sent" | "cancelled" | "failed";
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Between a minute and a year ahead. Files in `attachments` are uploaded first, as for `send`. */
|
|
282
|
+
export interface ScheduleInput extends Omit<SendInput, "threadRootId" | "visibility"> {
|
|
283
|
+
sendAt: string | Date;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
|
|
287
|
+
|
|
288
|
+
/** Working hours per weekday as `[["09:00","17:00"]]`, 24-hour local times; a missing day is not working. An end at or before its start runs past midnight. */
|
|
289
|
+
export type WorkingHours = Partial<Record<Weekday, [string, string][]>>;
|
|
290
|
+
|
|
291
|
+
/** A person's away message and working hours. */
|
|
292
|
+
export interface ChatAvailability {
|
|
293
|
+
userId: string;
|
|
294
|
+
awayMessage: string | null;
|
|
295
|
+
/** Away until then; with an away message and no date, away until they clear it. */
|
|
296
|
+
awayUntil: string | null;
|
|
297
|
+
/** An IANA zone (`America/New_York`); hours are read in it. */
|
|
298
|
+
timeZone: string | null;
|
|
299
|
+
hours: WorkingHours | null;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export type AvailabilityInput = Partial<Omit<ChatAvailability, "userId">>;
|
|
303
|
+
|
|
304
|
+
/** One search result. `snippet` marks the matched words with « and » (the text's own « and » arrive as ‹ and ›, so marks are always real). */
|
|
305
|
+
export interface ChatSearchHit {
|
|
306
|
+
messageId: string;
|
|
307
|
+
conversationId: string;
|
|
308
|
+
seq: number;
|
|
309
|
+
senderId: string | null;
|
|
310
|
+
createdAt: string;
|
|
311
|
+
snippet: string;
|
|
312
|
+
/** Found in a voice note's or video's transcript, not the text. */
|
|
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;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Open Graph data for a link (fetched by the server's runner). Plain text: never render it as HTML. */
|
|
319
|
+
export interface ChatLinkPreview {
|
|
320
|
+
url: string;
|
|
321
|
+
status: "ok" | "failed" | "blocked";
|
|
322
|
+
title: string | null;
|
|
323
|
+
description: string | null;
|
|
324
|
+
/** Always https when present. */
|
|
325
|
+
imageUrl: string | null;
|
|
326
|
+
siteName: string | null;
|
|
327
|
+
fetchedAt: string;
|
|
328
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* @marktiderman/genesis-chat: conversations, messages, media and live updates
|
|
4
|
+
* in the app's own Supabase. The core has no React; `/react` adds hooks,
|
|
5
|
+
* `/web` and `/native` render on Genesis UI, `/server` works the outbox.
|
|
6
|
+
*/
|
|
7
|
+
export * from "./core/types";
|
|
8
|
+
export * from "./core/client";
|
|
9
|
+
export * from "./core/media";
|
|
10
|
+
export * from "./core/thread";
|
|
11
|
+
export * from "./core/outbox";
|
|
12
|
+
export * from "./core/transfer";
|
|
13
|
+
export * from "./core/availability";
|
|
14
|
+
export * from "./core/extras";
|