@marktiderman/genesis-chat 0.1.0 → 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,757 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* The chat client: the app's own Supabase (migrations 0001 and 0002, the genesis_chat
|
|
4
|
+
* schema) under row level security. Reads are table selects; every write is a
|
|
5
|
+
* genesis_chat function. Live updates are hints on private Realtime topics,
|
|
6
|
+
* after which the client fetches again, so nothing is trusted from the wire.
|
|
7
|
+
*/
|
|
8
|
+
import type { RealtimeChannel, SupabaseClient } from "@supabase/supabase-js";
|
|
9
|
+
import type {
|
|
10
|
+
AttachmentInput,
|
|
11
|
+
AvailabilityInput,
|
|
12
|
+
ChatActivity,
|
|
13
|
+
ChatAttachment,
|
|
14
|
+
ChatAvailability,
|
|
15
|
+
ChatLinkPreview,
|
|
16
|
+
ChatSearchHit,
|
|
17
|
+
ChatTopic,
|
|
18
|
+
ScheduleInput,
|
|
19
|
+
ScheduledMessage,
|
|
20
|
+
TopicInput,
|
|
21
|
+
ChatConversation,
|
|
22
|
+
ChatInboxItem,
|
|
23
|
+
ChatKindRules,
|
|
24
|
+
ChatMember,
|
|
25
|
+
ChatMessage,
|
|
26
|
+
ChatRole,
|
|
27
|
+
ChatSignal,
|
|
28
|
+
SendInput,
|
|
29
|
+
} from "./types";
|
|
30
|
+
import { normalizeHours } from "./availability";
|
|
31
|
+
import { fileExtension, isAllowedExtension, randomId } from "./media";
|
|
32
|
+
|
|
33
|
+
/** Any supabase-js client; the app's generated Database types don't need to list genesis_chat. */
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
35
|
+
export type ChatSupabase = SupabaseClient<any, any, any>;
|
|
36
|
+
|
|
37
|
+
export const CHAT_SCHEMA = "genesis_chat";
|
|
38
|
+
export const CHAT_BUCKET = "genesis-chat";
|
|
39
|
+
|
|
40
|
+
export const conversationTopic = (conversationId: string) => `genesis-chat:conv:${conversationId}`;
|
|
41
|
+
export const userTopic = (userId: string) => `genesis-chat:user:${userId}`;
|
|
42
|
+
|
|
43
|
+
/** A refusal from the database, with its SQLSTATE (`42501` not allowed, `22023` bad input, `GS429` too many). */
|
|
44
|
+
export class ChatError extends Error {
|
|
45
|
+
constructor(
|
|
46
|
+
message: string,
|
|
47
|
+
readonly code: string | undefined,
|
|
48
|
+
readonly operation: string,
|
|
49
|
+
) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.name = "ChatError";
|
|
52
|
+
}
|
|
53
|
+
get forbidden(): boolean {
|
|
54
|
+
return this.code === "42501";
|
|
55
|
+
}
|
|
56
|
+
get rateLimited(): boolean {
|
|
57
|
+
return this.code === "GS429";
|
|
58
|
+
}
|
|
59
|
+
/** Worth trying again: the network, not the database, said no. */
|
|
60
|
+
get transient(): boolean {
|
|
61
|
+
return this.code === undefined || this.code === "" || /^(08|57|53)/.test(this.code) || this.code === "PGRST000";
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Sending it again won't help: the database refused what was sent (not allowed 42501, bad
|
|
65
|
+
* data 22xxx such as 22023, a conflict 23xxx such as 23505, another refusal 42xxx, or a
|
|
66
|
+
* function's own `raise` P0001). A person may still retry by hand; nothing retries it alone.
|
|
67
|
+
*/
|
|
68
|
+
get permanent(): boolean {
|
|
69
|
+
return !this.transient && !this.rateLimited && /^(22|23|42|P0)/.test(this.code ?? "");
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function must<T>(operation: string, result: { data: T | null; error: { message: string; code?: string } | null }): T {
|
|
74
|
+
if (result.error) throw new ChatError(result.error.message, result.error.code, operation);
|
|
75
|
+
return result.data as T;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
type Row = Record<string, unknown>;
|
|
79
|
+
const str = (v: unknown) => (v == null ? null : String(v));
|
|
80
|
+
const num = (v: unknown) => (v == null ? null : Number(v));
|
|
81
|
+
|
|
82
|
+
export function toAttachment(r: Row): ChatAttachment {
|
|
83
|
+
return {
|
|
84
|
+
id: String(r.id),
|
|
85
|
+
messageId: String(r.message_id),
|
|
86
|
+
position: Number(r.position ?? 0),
|
|
87
|
+
kind: String(r.kind),
|
|
88
|
+
ref: (r.ref as Record<string, unknown>) ?? {},
|
|
89
|
+
provider: (r.provider as ChatAttachment["provider"]) ?? "storage",
|
|
90
|
+
path: str(r.path),
|
|
91
|
+
providerId: str(r.provider_id),
|
|
92
|
+
playbackId: str(r.playback_id),
|
|
93
|
+
url: str(r.url),
|
|
94
|
+
status: (r.status as ChatAttachment["status"]) ?? "ready",
|
|
95
|
+
name: str(r.name),
|
|
96
|
+
mime: str(r.mime),
|
|
97
|
+
bytes: num(r.bytes),
|
|
98
|
+
width: num(r.width),
|
|
99
|
+
height: num(r.height),
|
|
100
|
+
durationMs: num(r.duration_ms),
|
|
101
|
+
waveform: (r.waveform as number[] | null) ?? null,
|
|
102
|
+
posterPath: str(r.poster_path),
|
|
103
|
+
thumbhash: str(r.thumbhash),
|
|
104
|
+
transcript: str(r.transcript),
|
|
105
|
+
transcriptStatus: (r.transcript_status as ChatAttachment["transcriptStatus"]) ?? null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function toMessage(r: Row): ChatMessage {
|
|
110
|
+
const attachments = ((r.attachments as Row[] | undefined) ?? []).map(toAttachment).sort((a, b) => a.position - b.position);
|
|
111
|
+
const reactions = ((r.reactions as Row[] | undefined) ?? []).map((x) => ({ emoji: String(x.emoji), userId: String(x.user_id) }));
|
|
112
|
+
return {
|
|
113
|
+
id: String(r.id),
|
|
114
|
+
conversationId: String(r.conversation_id),
|
|
115
|
+
seq: Number(r.seq),
|
|
116
|
+
clientId: String(r.client_id),
|
|
117
|
+
senderId: str(r.sender_id),
|
|
118
|
+
senderRole: r.sender_role as ChatMessage["senderRole"],
|
|
119
|
+
kind: (r.kind as ChatMessage["kind"]) ?? "text",
|
|
120
|
+
body: String(r.body ?? ""),
|
|
121
|
+
mentions: (r.mentions as string[]) ?? [],
|
|
122
|
+
replyToId: str(r.reply_to_id),
|
|
123
|
+
threadRootId: str(r.thread_root_id),
|
|
124
|
+
threadCount: Number(r.thread_count ?? 0),
|
|
125
|
+
threadLastAt: str(r.thread_last_at),
|
|
126
|
+
topic: str(r.topic),
|
|
127
|
+
visibility: (r.visibility as ChatMessage["visibility"]) ?? "everyone",
|
|
128
|
+
silent: Boolean(r.silent),
|
|
129
|
+
meta: (r.meta as Record<string, unknown>) ?? {},
|
|
130
|
+
createdAt: String(r.created_at),
|
|
131
|
+
editedAt: str(r.edited_at),
|
|
132
|
+
deletedAt: str(r.deleted_at),
|
|
133
|
+
moderation: r.moderation === "hidden" || r.moderation === "flagged" ? r.moderation : null,
|
|
134
|
+
attachments,
|
|
135
|
+
reactions,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toConversation(r: Row): ChatConversation {
|
|
140
|
+
return {
|
|
141
|
+
id: String(r.id),
|
|
142
|
+
kind: String(r.kind),
|
|
143
|
+
title: str(r.title),
|
|
144
|
+
subjectType: str(r.subject_type),
|
|
145
|
+
subjectId: str(r.subject_id),
|
|
146
|
+
settings: (r.settings as ChatConversation["settings"]) ?? {},
|
|
147
|
+
status: (r.status as ChatConversation["status"]) ?? null,
|
|
148
|
+
assigneeId: str(r.assignee_id),
|
|
149
|
+
lastSeq: Number(r.last_seq ?? 0),
|
|
150
|
+
lastMessageAt: str(r.last_message_at),
|
|
151
|
+
createdAt: String(r.created_at),
|
|
152
|
+
archivedAt: str(r.archived_at),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function toInboxItem(r: Row): ChatInboxItem {
|
|
157
|
+
const last = r.last_message as Row | null;
|
|
158
|
+
return {
|
|
159
|
+
conversationId: String(r.conversation_id),
|
|
160
|
+
kind: String(r.kind),
|
|
161
|
+
title: str(r.title),
|
|
162
|
+
subjectType: str(r.subject_type),
|
|
163
|
+
subjectId: str(r.subject_id),
|
|
164
|
+
status: (r.status as ChatInboxItem["status"]) ?? null,
|
|
165
|
+
role: r.role as ChatRole,
|
|
166
|
+
otherUserId: str(r.other_user_id),
|
|
167
|
+
memberCount: Number(r.member_count ?? 0),
|
|
168
|
+
lastSeq: Number(r.last_seq ?? 0),
|
|
169
|
+
lastReadSeq: Number(r.last_read_seq ?? 0),
|
|
170
|
+
unread: Number(r.unread ?? 0),
|
|
171
|
+
mentioned: Boolean(r.mentioned),
|
|
172
|
+
muted: Boolean(r.muted),
|
|
173
|
+
pinned: Boolean(r.pinned),
|
|
174
|
+
archived: Boolean(r.archived),
|
|
175
|
+
lastMessageAt: str(r.last_message_at),
|
|
176
|
+
lastMessage: last
|
|
177
|
+
? {
|
|
178
|
+
id: String(last.id),
|
|
179
|
+
seq: Number(last.seq),
|
|
180
|
+
senderId: str(last.sender_id),
|
|
181
|
+
kind: (last.kind as "text" | "system") ?? "text",
|
|
182
|
+
body: String(last.body ?? ""),
|
|
183
|
+
deleted: Boolean(last.deleted),
|
|
184
|
+
createdAt: String(last.created_at),
|
|
185
|
+
attachment: (last.attachment as string | null) ?? null,
|
|
186
|
+
}
|
|
187
|
+
: null,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function toTopic(r: Row): ChatTopic {
|
|
192
|
+
return {
|
|
193
|
+
conversationId: String(r.conversation_id),
|
|
194
|
+
topic: String(r.topic),
|
|
195
|
+
title: String(r.title ?? r.topic),
|
|
196
|
+
position: Number(r.position ?? 0),
|
|
197
|
+
closedAt: str(r.closed_at),
|
|
198
|
+
createdBy: str(r.created_by),
|
|
199
|
+
createdAt: String(r.created_at),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function fromAttachmentArg(a: Row): AttachmentInput {
|
|
204
|
+
const out: AttachmentInput = { kind: String(a.kind ?? "file") };
|
|
205
|
+
const map: [keyof AttachmentInput, string][] = [
|
|
206
|
+
["path", "path"], ["provider", "provider"], ["providerId", "provider_id"], ["url", "url"], ["ref", "ref"],
|
|
207
|
+
["status", "status"], ["name", "name"], ["mime", "mime"], ["bytes", "bytes"], ["width", "width"],
|
|
208
|
+
["height", "height"], ["durationMs", "duration_ms"], ["waveform", "waveform"], ["posterPath", "poster_path"], ["thumbhash", "thumbhash"],
|
|
209
|
+
];
|
|
210
|
+
for (const [k, col] of map) if (a[col] != null) (out as unknown as Record<string, unknown>)[k] = a[col];
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function toScheduled(r: Row): ScheduledMessage {
|
|
215
|
+
const sentMessageId = str(r.sent_message_id);
|
|
216
|
+
const cancelledAt = str(r.cancelled_at);
|
|
217
|
+
const error = str(r.error);
|
|
218
|
+
return {
|
|
219
|
+
id: String(r.id),
|
|
220
|
+
conversationId: String(r.conversation_id),
|
|
221
|
+
clientId: String(r.client_id),
|
|
222
|
+
body: String(r.body ?? ""),
|
|
223
|
+
attachments: ((r.attachments as Row[] | null) ?? []).map(fromAttachmentArg),
|
|
224
|
+
replyToId: str(r.reply_to_id),
|
|
225
|
+
topic: str(r.topic),
|
|
226
|
+
mentions: (r.mentions as string[] | null) ?? [],
|
|
227
|
+
silent: Boolean(r.silent),
|
|
228
|
+
sendAt: String(r.send_at),
|
|
229
|
+
sentMessageId,
|
|
230
|
+
cancelledAt,
|
|
231
|
+
error,
|
|
232
|
+
createdAt: String(r.created_at),
|
|
233
|
+
status: sentMessageId ? "sent" : cancelledAt ? "cancelled" : error ? "failed" : "scheduled",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function toAvailability(r: Row): ChatAvailability {
|
|
238
|
+
return {
|
|
239
|
+
userId: String(r.user_id),
|
|
240
|
+
awayMessage: str(r.away_message),
|
|
241
|
+
awayUntil: str(r.away_until),
|
|
242
|
+
timeZone: str(r.time_zone),
|
|
243
|
+
// Someone else's hours: only well-formed days and ranges reach the renderers.
|
|
244
|
+
hours: normalizeHours(r.hours),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function toSearchHit(r: Row): ChatSearchHit {
|
|
249
|
+
return {
|
|
250
|
+
messageId: String(r.message_id),
|
|
251
|
+
conversationId: String(r.conversation_id),
|
|
252
|
+
seq: Number(r.seq),
|
|
253
|
+
senderId: str(r.sender_id),
|
|
254
|
+
createdAt: String(r.created_at),
|
|
255
|
+
snippet: String(r.snippet ?? ""),
|
|
256
|
+
inTranscript: Boolean(r.in_transcript),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function toLinkPreview(r: Row): ChatLinkPreview {
|
|
261
|
+
const image = str(r.image_url);
|
|
262
|
+
return {
|
|
263
|
+
url: String(r.url),
|
|
264
|
+
status: (r.status as ChatLinkPreview["status"]) ?? "ok",
|
|
265
|
+
title: str(r.title),
|
|
266
|
+
description: str(r.description),
|
|
267
|
+
imageUrl: image && /^https:\/\//i.test(image) ? image : null,
|
|
268
|
+
siteName: str(r.site_name),
|
|
269
|
+
fetchedAt: String(r.fetched_at),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const MESSAGE_COLUMNS = "*, attachments(*), reactions(emoji, user_id)";
|
|
274
|
+
|
|
275
|
+
export interface MessagePage {
|
|
276
|
+
messages: ChatMessage[];
|
|
277
|
+
/** More, older messages exist before the first one. */
|
|
278
|
+
hasEarlier: boolean;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export interface ChatClientOptions {
|
|
282
|
+
/** Signed file links live this long (seconds). Default an hour. */
|
|
283
|
+
signedUrlSeconds?: number;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface Subscription {
|
|
287
|
+
unsubscribe(): void;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export interface ConversationHandlers {
|
|
291
|
+
onSignal: (s: ChatSignal) => void;
|
|
292
|
+
onActivity?: (a: ChatActivity) => void;
|
|
293
|
+
/** Every join, the first included: catch up on what arrived before the channel was live. */
|
|
294
|
+
onJoin?: () => void;
|
|
295
|
+
/** Re-joins only. Prefer `onJoin`. */
|
|
296
|
+
onReconnect?: () => void;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
export interface ConversationChannel extends Subscription {
|
|
300
|
+
/** Tell the others you are typing or recording (throttle to once every 3 seconds). */
|
|
301
|
+
signal(activity: ChatActivity["activity"]): void;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function createChatClient(supabase: ChatSupabase, options: ChatClientOptions = {}) {
|
|
305
|
+
const db = () => supabase.schema(CHAT_SCHEMA);
|
|
306
|
+
const signedUrlSeconds = options.signedUrlSeconds ?? 3600;
|
|
307
|
+
const signedCache = new Map<string, { url: string; until: number }>();
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Private Realtime channels authorise with the signed-in user's token; set it before
|
|
311
|
+
* joining (supabase-js doesn't for a channel joined before the session loads), or the
|
|
312
|
+
* join is refused and no hint ever arrives. A refusal to set it still joins and retries.
|
|
313
|
+
*/
|
|
314
|
+
const unsubscribed = new WeakSet<RealtimeChannel>();
|
|
315
|
+
function join(channel: RealtimeChannel, onStatus?: (status: string) => void) {
|
|
316
|
+
const realtime = (supabase as unknown as { realtime?: { setAuth?: (token?: string | null) => Promise<void> | void } }).realtime;
|
|
317
|
+
void Promise.resolve(realtime?.setAuth?.())
|
|
318
|
+
.catch(() => undefined)
|
|
319
|
+
.then(() => {
|
|
320
|
+
if (!unsubscribed.has(channel)) channel.subscribe(onStatus);
|
|
321
|
+
});
|
|
322
|
+
return () => {
|
|
323
|
+
unsubscribed.add(channel);
|
|
324
|
+
void supabase.removeChannel(channel);
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function rpc<T>(fn: string, args: Record<string, unknown>): Promise<T> {
|
|
329
|
+
return must<T>(fn, await db().rpc(fn, args));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
supabase,
|
|
334
|
+
|
|
335
|
+
/** Your conversations: one `kind`, several `kinds`, or all but `excludeKinds` (e.g. Messages without `support`). */
|
|
336
|
+
async inbox(
|
|
337
|
+
opts: { kind?: string; kinds?: string[]; excludeKinds?: string[]; archived?: boolean; limit?: number; before?: string } = {},
|
|
338
|
+
): Promise<ChatInboxItem[]> {
|
|
339
|
+
const rows = await rpc<Row[]>("inbox", {
|
|
340
|
+
p_kind: opts.kind ?? null,
|
|
341
|
+
p_archived: opts.archived ?? false,
|
|
342
|
+
p_limit: opts.limit ?? 50,
|
|
343
|
+
p_before: opts.before ?? null,
|
|
344
|
+
...(opts.kinds?.length ? { p_kinds: opts.kinds } : {}),
|
|
345
|
+
...(opts.excludeKinds?.length ? { p_exclude_kinds: opts.excludeKinds } : {}),
|
|
346
|
+
});
|
|
347
|
+
return rows.map(toInboxItem);
|
|
348
|
+
},
|
|
349
|
+
|
|
350
|
+
async observed(kind: string, opts: { status?: string; limit?: number; before?: string } = {}) {
|
|
351
|
+
return rpc<Row[]>("observed", { p_kind: kind, p_status: opts.status ?? null, p_limit: opts.limit ?? 50, p_before: opts.before ?? null });
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
/** Unread across your conversations (for a badge), leaving out `excludeKinds` to match an inbox that does. */
|
|
355
|
+
async unreadTotal(opts: { excludeKinds?: string[] } = {}): Promise<number> {
|
|
356
|
+
return rpc<number>("unread_total", opts.excludeKinds?.length ? { p_exclude_kinds: opts.excludeKinds } : {});
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
async conversation(id: string): Promise<ChatConversation | null> {
|
|
360
|
+
const row = must("conversation", await db().from("conversations").select("*").eq("id", id).maybeSingle());
|
|
361
|
+
return row ? toConversation(row as Row) : null;
|
|
362
|
+
},
|
|
363
|
+
|
|
364
|
+
/** A kind's rules (how many members, who posts, threads), for what the UI offers. */
|
|
365
|
+
async kind(kind: string): Promise<ChatKindRules | null> {
|
|
366
|
+
const row = must("kind", await db().from("kinds").select("kind, label, max_members, who_posts, membership, threads, observers_post").eq("kind", kind).maybeSingle()) as Row | null;
|
|
367
|
+
return row
|
|
368
|
+
? {
|
|
369
|
+
kind: String(row.kind),
|
|
370
|
+
label: String(row.label),
|
|
371
|
+
maxMembers: row.max_members == null ? null : Number(row.max_members),
|
|
372
|
+
whoPosts: row.who_posts as ChatKindRules["whoPosts"],
|
|
373
|
+
membership: row.membership as ChatKindRules["membership"],
|
|
374
|
+
threads: Boolean(row.threads),
|
|
375
|
+
observersPost: Boolean(row.observers_post),
|
|
376
|
+
}
|
|
377
|
+
: null;
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
async myRole(id: string): Promise<ChatRole | null> {
|
|
381
|
+
return rpc<ChatRole | null>("my_role", { p_conversation_id: id });
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
async canPost(id: string): Promise<boolean> {
|
|
385
|
+
return rpc<boolean>("can_post", { p_conversation_id: id });
|
|
386
|
+
},
|
|
387
|
+
|
|
388
|
+
async members(id: string): Promise<ChatMember[]> {
|
|
389
|
+
const rows = must<Row[]>(
|
|
390
|
+
"members",
|
|
391
|
+
await db().from("members").select("*").eq("conversation_id", id).is("left_at", null).neq("role", "observer"),
|
|
392
|
+
);
|
|
393
|
+
return rows.map((r) => ({
|
|
394
|
+
conversationId: String(r.conversation_id),
|
|
395
|
+
userId: String(r.user_id),
|
|
396
|
+
role: r.role as ChatRole,
|
|
397
|
+
lastReadSeq: Number(r.last_read_seq ?? 0),
|
|
398
|
+
joinedAt: String(r.joined_at),
|
|
399
|
+
}));
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* A page of messages, oldest first: the latest `limit` (default 50), or those
|
|
404
|
+
* before `beforeSeq`, or (to catch up after a reconnect) those after `afterSeq`.
|
|
405
|
+
* A thread's replies with `threadRootId`; one topic's with `topic`.
|
|
406
|
+
*/
|
|
407
|
+
async messages(
|
|
408
|
+
conversationId: string,
|
|
409
|
+
opts: { beforeSeq?: number; afterSeq?: number; limit?: number; threadRootId?: string; topic?: string } = {},
|
|
410
|
+
): Promise<MessagePage> {
|
|
411
|
+
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
|
|
412
|
+
let q = db().from("messages").select(MESSAGE_COLUMNS).eq("conversation_id", conversationId);
|
|
413
|
+
q = opts.threadRootId ? q.eq("thread_root_id", opts.threadRootId) : q.is("thread_root_id", null);
|
|
414
|
+
if (opts.topic) q = q.eq("topic", opts.topic);
|
|
415
|
+
if (opts.afterSeq != null) {
|
|
416
|
+
const rows = must<Row[]>("messages", await q.gt("seq", opts.afterSeq).order("seq", { ascending: true }).limit(limit));
|
|
417
|
+
return { messages: rows.map(toMessage), hasEarlier: false };
|
|
418
|
+
}
|
|
419
|
+
if (opts.beforeSeq != null) q = q.lt("seq", opts.beforeSeq);
|
|
420
|
+
const rows = must<Row[]>("messages", await q.order("seq", { ascending: false }).limit(limit + 1));
|
|
421
|
+
const hasEarlier = rows.length > limit;
|
|
422
|
+
return { messages: rows.slice(0, limit).map(toMessage).reverse(), hasEarlier };
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
async message(id: string): Promise<ChatMessage | null> {
|
|
426
|
+
const row = must("message", await db().from("messages").select(MESSAGE_COLUMNS).eq("id", id).maybeSingle());
|
|
427
|
+
return row ? toMessage(row as Row) : null;
|
|
428
|
+
},
|
|
429
|
+
|
|
430
|
+
async startConversation(kind: string, memberIds: string[], opts: { title?: string; subjectType?: string; subjectId?: string } = {}) {
|
|
431
|
+
return rpc<string>("start_conversation", {
|
|
432
|
+
p_kind: kind,
|
|
433
|
+
p_member_ids: memberIds,
|
|
434
|
+
p_title: opts.title ?? null,
|
|
435
|
+
p_subject_type: opts.subjectType ?? null,
|
|
436
|
+
p_subject_id: opts.subjectId ?? null,
|
|
437
|
+
});
|
|
438
|
+
},
|
|
439
|
+
|
|
440
|
+
/** Sends a message. Safe to retry with the same clientId. */
|
|
441
|
+
async send(input: SendInput): Promise<ChatMessage> {
|
|
442
|
+
const row = await rpc<Row>("send_message", {
|
|
443
|
+
p_conversation_id: input.conversationId,
|
|
444
|
+
p_client_id: input.clientId,
|
|
445
|
+
p_body: input.body ?? "",
|
|
446
|
+
p_attachments: (input.attachments ?? []).map(attachmentArg),
|
|
447
|
+
p_reply_to_id: input.replyToId ?? null,
|
|
448
|
+
p_thread_root_id: input.threadRootId ?? null,
|
|
449
|
+
p_topic: input.topic ?? null,
|
|
450
|
+
p_mentions: input.mentions ?? [],
|
|
451
|
+
p_visibility: input.visibility ?? "everyone",
|
|
452
|
+
p_silent: input.silent ?? false,
|
|
453
|
+
});
|
|
454
|
+
// The function returns the message row; fetch it again for its attachments.
|
|
455
|
+
return (await this.message(String(row.id))) ?? toMessage(row);
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
async edit(messageId: string, body: string, mentions: string[] = []) {
|
|
459
|
+
return toMessage(await rpc<Row>("edit_message", { p_message_id: messageId, p_body: body, p_mentions: mentions }));
|
|
460
|
+
},
|
|
461
|
+
|
|
462
|
+
async remove(messageId: string) {
|
|
463
|
+
await rpc("delete_message", { p_message_id: messageId });
|
|
464
|
+
},
|
|
465
|
+
|
|
466
|
+
async react(messageId: string, emoji: string, on = true) {
|
|
467
|
+
await rpc("react", { p_message_id: messageId, p_emoji: emoji, p_on: on });
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
async markRead(conversationId: string, seq?: number) {
|
|
471
|
+
return rpc<number>("mark_read", { p_conversation_id: conversationId, p_seq: seq ?? null });
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
async setMySettings(
|
|
475
|
+
conversationId: string,
|
|
476
|
+
s: { mutedUntil?: string | null; notify?: "all" | "mentions" | "none"; archived?: boolean; pinned?: boolean },
|
|
477
|
+
) {
|
|
478
|
+
await rpc("set_my_settings", {
|
|
479
|
+
p_conversation_id: conversationId,
|
|
480
|
+
p_muted_until: s.mutedUntil ?? null,
|
|
481
|
+
p_notify: s.notify ?? null,
|
|
482
|
+
p_archived: s.archived ?? null,
|
|
483
|
+
p_pinned: s.pinned ?? null,
|
|
484
|
+
p_clear_mute: s.mutedUntil === null,
|
|
485
|
+
});
|
|
486
|
+
},
|
|
487
|
+
|
|
488
|
+
async updateConversation(conversationId: string, s: { title?: string; settings?: Record<string, unknown> }) {
|
|
489
|
+
await rpc("update_conversation", { p_conversation_id: conversationId, p_title: s.title ?? null, p_settings: s.settings ?? null });
|
|
490
|
+
},
|
|
491
|
+
|
|
492
|
+
async addMembers(conversationId: string, userIds: string[]) {
|
|
493
|
+
await rpc("add_members", { p_conversation_id: conversationId, p_user_ids: userIds });
|
|
494
|
+
},
|
|
495
|
+
|
|
496
|
+
async removeMember(conversationId: string, userId?: string) {
|
|
497
|
+
await rpc("remove_member", { p_conversation_id: conversationId, p_user_id: userId ?? null });
|
|
498
|
+
},
|
|
499
|
+
|
|
500
|
+
async setStatus(conversationId: string, status: ChatConversation["status"], assigneeId?: string | null) {
|
|
501
|
+
await rpc("set_status", { p_conversation_id: conversationId, p_status: status, p_assignee_id: assigneeId ?? null });
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
async pin(messageId: string, on = true) {
|
|
505
|
+
await rpc("pin_message", { p_message_id: messageId, p_on: on });
|
|
506
|
+
},
|
|
507
|
+
|
|
508
|
+
async pins(conversationId: string): Promise<string[]> {
|
|
509
|
+
const rows = must<Row[]>("pins", await db().from("pins").select("message_id").eq("conversation_id", conversationId));
|
|
510
|
+
return rows.map((r) => String(r.message_id));
|
|
511
|
+
},
|
|
512
|
+
|
|
513
|
+
async save(messageId: string, on = true, note?: string) {
|
|
514
|
+
await rpc("save_message", { p_message_id: messageId, p_on: on, p_note: note ?? null });
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
async report(messageId: string, reason: "spam" | "harassment" | "safety" | "other", note?: string) {
|
|
518
|
+
return rpc<string>("report_message", { p_message_id: messageId, p_reason: reason, p_note: note ?? null });
|
|
519
|
+
},
|
|
520
|
+
|
|
521
|
+
// ── Migration 0002 ──────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
/** A conversation's topics, in order (position, then key). */
|
|
524
|
+
async topics(conversationId: string): Promise<ChatTopic[]> {
|
|
525
|
+
const rows = must<Row[]>(
|
|
526
|
+
"topics",
|
|
527
|
+
await db().from("topics").select("*").eq("conversation_id", conversationId).order("position", { ascending: true }).order("topic", { ascending: true }),
|
|
528
|
+
);
|
|
529
|
+
return rows.map(toTopic);
|
|
530
|
+
},
|
|
531
|
+
|
|
532
|
+
/** Adds a topic, or renames, reorders, closes (`closed: true`) or reopens it. Owners and admins. */
|
|
533
|
+
async setTopic(conversationId: string, topic: string, input: TopicInput = {}) {
|
|
534
|
+
await rpc("set_topic", {
|
|
535
|
+
p_conversation_id: conversationId,
|
|
536
|
+
p_topic: topic,
|
|
537
|
+
p_title: input.title ?? null,
|
|
538
|
+
p_position: input.position ?? null,
|
|
539
|
+
p_closed: input.closed ?? null,
|
|
540
|
+
});
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
/** Schedules a message (a minute to a year ahead) and returns its id. Safe to retry with the same clientId. */
|
|
544
|
+
async schedule(input: ScheduleInput): Promise<string> {
|
|
545
|
+
const sendAt = typeof input.sendAt === "string" ? input.sendAt : input.sendAt.toISOString();
|
|
546
|
+
return rpc<string>("schedule_message", {
|
|
547
|
+
p_conversation_id: input.conversationId,
|
|
548
|
+
p_client_id: input.clientId,
|
|
549
|
+
p_send_at: sendAt,
|
|
550
|
+
p_body: input.body ?? "",
|
|
551
|
+
p_attachments: (input.attachments ?? []).map(attachmentArg),
|
|
552
|
+
p_reply_to_id: input.replyToId ?? null,
|
|
553
|
+
p_topic: input.topic ?? null,
|
|
554
|
+
p_mentions: input.mentions ?? [],
|
|
555
|
+
p_silent: input.silent ?? false,
|
|
556
|
+
});
|
|
557
|
+
},
|
|
558
|
+
|
|
559
|
+
/** Your messages still waiting to go (and ones that failed when due), soonest first; in one conversation or all. */
|
|
560
|
+
async scheduled(conversationId?: string): Promise<ScheduledMessage[]> {
|
|
561
|
+
let q = db().from("scheduled").select("*").is("sent_message_id", null).is("cancelled_at", null);
|
|
562
|
+
if (conversationId) q = q.eq("conversation_id", conversationId);
|
|
563
|
+
const rows = must<Row[]>("scheduled", await q.order("send_at", { ascending: true }).limit(200));
|
|
564
|
+
return rows.map(toScheduled);
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
async cancelScheduled(id: string) {
|
|
568
|
+
await rpc("cancel_scheduled", { p_id: id });
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
/** The away message and hours of a conversation's members who set them (not observers). */
|
|
572
|
+
async availabilityFor(conversationId: string): Promise<ChatAvailability[]> {
|
|
573
|
+
const rows = await rpc<Row[]>("availability_for", { p_conversation_id: conversationId });
|
|
574
|
+
return (rows ?? []).map(toAvailability);
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
/** Your own away message and hours, or null when never set. */
|
|
578
|
+
async myAvailability(): Promise<ChatAvailability | null> {
|
|
579
|
+
const row = must("myAvailability", await db().from("availability").select("*").maybeSingle()) as Row | null;
|
|
580
|
+
return row ? toAvailability(row) : null;
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
/** Replaces your away message and hours (a field left out is cleared). */
|
|
584
|
+
async setAvailability(input: AvailabilityInput) {
|
|
585
|
+
const until = input.awayUntil ?? null;
|
|
586
|
+
await rpc("set_availability", {
|
|
587
|
+
p_away_message: input.awayMessage ?? null,
|
|
588
|
+
p_away_until: until,
|
|
589
|
+
p_time_zone: input.timeZone ?? null,
|
|
590
|
+
p_hours: input.hours ?? null,
|
|
591
|
+
});
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Sends one text message into each of these conversations where you may post (a coach to
|
|
596
|
+
* each client's direct conversation), so replies come back privately. Text only. Safe to
|
|
597
|
+
* retry with the same clientId. Returns the ids of the messages sent.
|
|
598
|
+
*/
|
|
599
|
+
async broadcast(conversationIds: string[], clientId: string, body: string, opts: { silent?: boolean } = {}): Promise<string[]> {
|
|
600
|
+
const ids = await rpc<string[] | null>("broadcast_message", {
|
|
601
|
+
p_conversation_ids: conversationIds,
|
|
602
|
+
p_client_id: clientId,
|
|
603
|
+
p_body: body,
|
|
604
|
+
p_attachments: [],
|
|
605
|
+
p_silent: opts.silent ?? false,
|
|
606
|
+
});
|
|
607
|
+
return (ids ?? []).map(String);
|
|
608
|
+
},
|
|
609
|
+
|
|
610
|
+
/** Messages (and transcripts) you can read that match, newest first: in one conversation or all of yours. Two characters at least. */
|
|
611
|
+
async search(query: string, conversationId?: string | null, opts: { limit?: number } = {}): Promise<ChatSearchHit[]> {
|
|
612
|
+
if (query.trim().length < 2) return [];
|
|
613
|
+
const rows = await rpc<Row[] | null>("search_messages", {
|
|
614
|
+
p_query: query,
|
|
615
|
+
p_conversation_id: conversationId ?? null,
|
|
616
|
+
p_limit: opts.limit ?? 30,
|
|
617
|
+
});
|
|
618
|
+
return (rows ?? []).map(toSearchHit);
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The previews the server has fetched for the links in these messages, by message id (only
|
|
623
|
+
* `ok` ones; links not fetched yet are left out). Read through link_previews_for, one call
|
|
624
|
+
* per message (up to 6 at once), which answers only for messages you can read: the preview
|
|
625
|
+
* table itself is closed, since a URL can carry a secret.
|
|
626
|
+
*/
|
|
627
|
+
async linkPreviewsFor(messageIds: string[]): Promise<Record<string, ChatLinkPreview[]>> {
|
|
628
|
+
const ids = [...new Set(messageIds.filter(Boolean))].slice(0, 200);
|
|
629
|
+
const out: Record<string, ChatLinkPreview[]> = {};
|
|
630
|
+
let next = 0;
|
|
631
|
+
const worker = async () => {
|
|
632
|
+
while (next < ids.length) {
|
|
633
|
+
const id = ids[next++]!;
|
|
634
|
+
const rows = await rpc<Row[] | null>("link_previews_for", { p_message_id: id });
|
|
635
|
+
out[id] = (rows ?? []).map(toLinkPreview).filter((p) => p.status === "ok");
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
await Promise.all(Array.from({ length: Math.min(6, ids.length) }, worker));
|
|
639
|
+
return out;
|
|
640
|
+
},
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Uploads a file into the conversation's folder for the signed-in person and
|
|
644
|
+
* returns its path, ready to attach. Resumable for big files is the native
|
|
645
|
+
* renderer's job (Supabase TUS); this is the plain upload.
|
|
646
|
+
*/
|
|
647
|
+
async upload(conversationId: string, userId: string, file: Blob | ArrayBuffer, opts: { name: string; contentType: string; id?: string }) {
|
|
648
|
+
const ext = fileExtension(opts.name, opts.contentType);
|
|
649
|
+
if (!isAllowedExtension(ext)) throw new ChatError(`Files of type .${ext} can't be sent`, "22023", "upload");
|
|
650
|
+
const id = opts.id ?? randomId();
|
|
651
|
+
const path = `${conversationId}/${userId}/${id}.${ext}`;
|
|
652
|
+
must("upload", await supabase.storage.from(CHAT_BUCKET).upload(path, file, { contentType: opts.contentType, upsert: false }));
|
|
653
|
+
return path;
|
|
654
|
+
},
|
|
655
|
+
|
|
656
|
+
/** Signed links for files, cached until shortly before they expire. */
|
|
657
|
+
async fileUrls(paths: string[]): Promise<Record<string, string>> {
|
|
658
|
+
const now = Date.now();
|
|
659
|
+
const out: Record<string, string> = {};
|
|
660
|
+
const missing: string[] = [];
|
|
661
|
+
for (const p of new Set(paths)) {
|
|
662
|
+
const hit = signedCache.get(p);
|
|
663
|
+
if (hit && hit.until > now) out[p] = hit.url;
|
|
664
|
+
else missing.push(p);
|
|
665
|
+
}
|
|
666
|
+
if (missing.length) {
|
|
667
|
+
const { data, error } = await supabase.storage.from(CHAT_BUCKET).createSignedUrls(missing, signedUrlSeconds);
|
|
668
|
+
if (error) throw new ChatError(error.message, undefined, "fileUrls");
|
|
669
|
+
for (const d of data ?? []) {
|
|
670
|
+
if (d.signedUrl && d.path) {
|
|
671
|
+
out[d.path] = d.signedUrl;
|
|
672
|
+
signedCache.set(d.path, { url: d.signedUrl, until: now + (signedUrlSeconds - 60) * 1000 });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
return out;
|
|
677
|
+
},
|
|
678
|
+
|
|
679
|
+
/** Your inbox's live hints (a new message anywhere you are, a membership change). */
|
|
680
|
+
subscribeInbox(userId: string, onSignal: (s: ChatSignal) => void): Subscription {
|
|
681
|
+
const channel = supabase
|
|
682
|
+
.channel(userTopic(userId), { config: { private: true } })
|
|
683
|
+
.on("broadcast", { event: "inbox" }, ({ payload }) =>
|
|
684
|
+
onSignal({ type: "inbox", conversationId: String(payload?.conversation_id), seq: payload?.seq }),
|
|
685
|
+
);
|
|
686
|
+
return { unsubscribe: join(channel) };
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* A conversation's live hints, plus who is typing or recording. `onJoin` runs every
|
|
691
|
+
* time the channel joins, the first time included: the moment to fetch what arrived
|
|
692
|
+
* before the subscription was live. `onReconnect` (kept for older callers) runs on
|
|
693
|
+
* re-joins only.
|
|
694
|
+
*/
|
|
695
|
+
subscribeConversation(
|
|
696
|
+
conversationId: string,
|
|
697
|
+
me: string,
|
|
698
|
+
handlers: ConversationHandlers,
|
|
699
|
+
): ConversationChannel {
|
|
700
|
+
let joinedOnce = false;
|
|
701
|
+
const channel: RealtimeChannel = supabase
|
|
702
|
+
.channel(conversationTopic(conversationId), { config: { private: true, broadcast: { self: false } } })
|
|
703
|
+
.on("broadcast", { event: "message" }, ({ payload }) =>
|
|
704
|
+
handlers.onSignal({ type: "message", conversationId, id: String(payload?.id), seq: payload?.seq, op: payload?.op ?? "insert" }),
|
|
705
|
+
)
|
|
706
|
+
.on("broadcast", { event: "reaction" }, ({ payload }) =>
|
|
707
|
+
handlers.onSignal({ type: "reaction", conversationId, messageId: String(payload?.message_id) }),
|
|
708
|
+
)
|
|
709
|
+
.on("broadcast", { event: "members" }, () => handlers.onSignal({ type: "members", conversationId }))
|
|
710
|
+
.on("broadcast", { event: "conversation" }, () => handlers.onSignal({ type: "conversation", conversationId }))
|
|
711
|
+
.on("broadcast", { event: "activity" }, ({ payload }) => {
|
|
712
|
+
if (payload?.userId && payload.userId !== me) handlers.onActivity?.({ userId: payload.userId, activity: payload.activity, at: Date.now() });
|
|
713
|
+
});
|
|
714
|
+
// Typing and recording are ephemeral: until the channel has joined, they're dropped
|
|
715
|
+
// rather than sent (supabase-js would fall back to REST and warn).
|
|
716
|
+
let joined = false;
|
|
717
|
+
const leave = join(channel, (status) => {
|
|
718
|
+
joined = status === "SUBSCRIBED";
|
|
719
|
+
if (status === "SUBSCRIBED") {
|
|
720
|
+
handlers.onJoin?.();
|
|
721
|
+
if (joinedOnce) handlers.onReconnect?.();
|
|
722
|
+
joinedOnce = true;
|
|
723
|
+
}
|
|
724
|
+
});
|
|
725
|
+
return {
|
|
726
|
+
signal(activity) {
|
|
727
|
+
if (!joined) return;
|
|
728
|
+
void channel.send({ type: "broadcast", event: "activity", payload: { userId: me, activity } });
|
|
729
|
+
},
|
|
730
|
+
unsubscribe: leave,
|
|
731
|
+
};
|
|
732
|
+
},
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
export type ChatClient = ReturnType<typeof createChatClient>;
|
|
737
|
+
|
|
738
|
+
function attachmentArg(a: AttachmentInput) {
|
|
739
|
+
return {
|
|
740
|
+
kind: a.kind,
|
|
741
|
+
path: a.path,
|
|
742
|
+
provider: a.provider,
|
|
743
|
+
provider_id: a.providerId,
|
|
744
|
+
url: a.url,
|
|
745
|
+
ref: a.ref,
|
|
746
|
+
status: a.status,
|
|
747
|
+
name: a.name,
|
|
748
|
+
mime: a.mime,
|
|
749
|
+
bytes: a.bytes,
|
|
750
|
+
width: a.width,
|
|
751
|
+
height: a.height,
|
|
752
|
+
duration_ms: a.durationMs,
|
|
753
|
+
waveform: a.waveform,
|
|
754
|
+
poster_path: a.posterPath,
|
|
755
|
+
thumbhash: a.thumbhash,
|
|
756
|
+
};
|
|
757
|
+
}
|