@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.
Files changed (175) hide show
  1. package/README.md +379 -13
  2. package/dist/chunk-A4YJX7KT.js +544 -0
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-GBJ3YI4E.js +966 -0
  9. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  10. package/dist/chunk-JU2FAPBI.js +609 -0
  11. package/dist/chunk-JU2FAPBI.js.map +1 -0
  12. package/dist/chunk-VEPZROQB.js +187 -0
  13. package/dist/chunk-VEPZROQB.js.map +1 -0
  14. package/dist/{client-BtdALFCx.d.ts → client-DUIG1hum.d.ts} +187 -11
  15. package/dist/index.d.ts +78 -163
  16. package/dist/index.js +3 -2
  17. package/dist/native.d.ts +1939 -1
  18. package/dist/native.js +5023 -0
  19. package/dist/native.js.map +1 -1
  20. package/dist/outbox-B9NAWQ58.d.ts +219 -0
  21. package/dist/push.d.ts +206 -0
  22. package/dist/push.js +3 -0
  23. package/dist/push.js.map +1 -0
  24. package/dist/react.d.ts +176 -7
  25. package/dist/react.js +3 -3
  26. package/dist/server.d.ts +551 -21
  27. package/dist/server.js +976 -34
  28. package/dist/server.js.map +1 -1
  29. package/dist/thread-oPra7pkW.d.ts +57 -0
  30. package/dist/web.d.ts +335 -12
  31. package/dist/web.js +1784 -64
  32. package/dist/web.js.map +1 -1
  33. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  34. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  35. package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
  36. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  37. package/package.json +78 -6
  38. package/src/__tests__/coaching-core.test.ts +334 -0
  39. package/src/__tests__/coaching-react.test.tsx +319 -0
  40. package/src/__tests__/coaching-web.test.tsx +521 -0
  41. package/src/__tests__/core.test.ts +91 -0
  42. package/src/__tests__/fake-xhr.ts +54 -0
  43. package/src/__tests__/outbox-hosted.test.ts +269 -0
  44. package/src/__tests__/outbox.test.ts +122 -0
  45. package/src/__tests__/realtime.test.ts +70 -0
  46. package/src/__tests__/review-279.test.ts +41 -0
  47. package/src/__tests__/review-fixes-core.test.ts +193 -0
  48. package/src/__tests__/review-fixes-web.test.tsx +405 -0
  49. package/src/__tests__/server-effects.test.ts +158 -0
  50. package/src/__tests__/server-fake-video.test.ts +81 -0
  51. package/src/__tests__/server-fakes.ts +96 -0
  52. package/src/__tests__/server-integrations.test.ts +247 -0
  53. package/src/__tests__/server-providers.test.ts +246 -0
  54. package/src/__tests__/server-runner.test.ts +277 -0
  55. package/src/__tests__/server-safety.test.ts +303 -0
  56. package/src/__tests__/web-markdown.test.tsx +88 -0
  57. package/src/__tests__/web.test.tsx +349 -0
  58. package/src/core/availability.ts +223 -0
  59. package/src/core/client.ts +783 -0
  60. package/src/core/extras.ts +106 -0
  61. package/src/core/media.ts +144 -0
  62. package/src/core/outbox.ts +635 -0
  63. package/src/core/thread.ts +171 -0
  64. package/src/core/transfer.ts +71 -0
  65. package/src/core/types.ts +328 -0
  66. package/src/index.ts +14 -0
  67. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  68. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  69. package/src/native/__tests__/composer-state.test.ts +188 -0
  70. package/src/native/__tests__/composer.test.tsx +133 -0
  71. package/src/native/__tests__/emoji.test.tsx +254 -0
  72. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  73. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  74. package/src/native/__tests__/organise-logic.test.ts +164 -0
  75. package/src/native/__tests__/organise-screens.test.tsx +424 -0
  76. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  77. package/src/native/__tests__/push-payload.test.ts +66 -0
  78. package/src/native/__tests__/push.test.ts +209 -0
  79. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  80. package/src/native/__tests__/rows-actions.test.ts +76 -0
  81. package/src/native/__tests__/thread-layout.test.ts +41 -0
  82. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  83. package/src/native/__tests__/tus.test.ts +154 -0
  84. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  85. package/src/native/__tests__/video-media.test.tsx +498 -0
  86. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  87. package/src/native/__tests__/video-upload.test.ts +227 -0
  88. package/src/native/access-error.tsx +31 -0
  89. package/src/native/actions.ts +42 -0
  90. package/src/native/attachment-cards.tsx +132 -0
  91. package/src/native/auto-retry.ts +69 -0
  92. package/src/native/availability.tsx +376 -0
  93. package/src/native/broadcast-sheet.tsx +193 -0
  94. package/src/native/composer-state.ts +302 -0
  95. package/src/native/composer.tsx +453 -0
  96. package/src/native/context.tsx +70 -0
  97. package/src/native/copy.ts +296 -0
  98. package/src/native/emoji-data.ts +21 -0
  99. package/src/native/emoji-picker.tsx +229 -0
  100. package/src/native/emoji.ts +118 -0
  101. package/src/native/icon-button.tsx +40 -0
  102. package/src/native/inbox-screen.tsx +122 -0
  103. package/src/native/index.ts +184 -0
  104. package/src/native/link-preview.tsx +104 -0
  105. package/src/native/media-colors.ts +15 -0
  106. package/src/native/media.tsx +187 -0
  107. package/src/native/message-actions-sheet.tsx +126 -0
  108. package/src/native/message-bubble.tsx +366 -0
  109. package/src/native/moderation.tsx +42 -0
  110. package/src/native/modules.ts +277 -0
  111. package/src/native/organise-copy.ts +195 -0
  112. package/src/native/photo-viewer.tsx +531 -0
  113. package/src/native/push.ts +375 -0
  114. package/src/native/recorder-machine.ts +176 -0
  115. package/src/native/rows.ts +81 -0
  116. package/src/native/schedule-sheet.tsx +328 -0
  117. package/src/native/search-screen.tsx +189 -0
  118. package/src/native/stack-options.ts +36 -0
  119. package/src/native/start-sheet.tsx +87 -0
  120. package/src/native/thread-screen.tsx +647 -0
  121. package/src/native/topics.tsx +234 -0
  122. package/src/native/tus.ts +282 -0
  123. package/src/native/use-composer.ts +71 -0
  124. package/src/native/use-jump.ts +91 -0
  125. package/src/native/use-voice-recorder.ts +234 -0
  126. package/src/native/video-player.tsx +410 -0
  127. package/src/native/video-recorder-state.ts +140 -0
  128. package/src/native/video-recorder.tsx +274 -0
  129. package/src/native/video-upload-tray.tsx +77 -0
  130. package/src/native/video-upload.ts +432 -0
  131. package/src/native/voice-player.tsx +301 -0
  132. package/src/react/index.tsx +894 -0
  133. package/src/react/previews.ts +51 -0
  134. package/src/server/assistant.ts +235 -0
  135. package/src/server/crypto.ts +86 -0
  136. package/src/server/expo.ts +119 -0
  137. package/src/server/fake-video.ts +171 -0
  138. package/src/server/handler.ts +32 -0
  139. package/src/server/index.ts +58 -0
  140. package/src/server/moderation.ts +155 -0
  141. package/src/server/mux.ts +290 -0
  142. package/src/server/notifications.ts +130 -0
  143. package/src/server/runner.ts +468 -0
  144. package/src/server/transcribers.ts +111 -0
  145. package/src/server/types.ts +171 -0
  146. package/src/server/unfurl.ts +480 -0
  147. package/src/server/upload.ts +158 -0
  148. package/src/web/attachments.tsx +379 -0
  149. package/src/web/availability.tsx +299 -0
  150. package/src/web/broadcast.tsx +166 -0
  151. package/src/web/bubble.tsx +585 -0
  152. package/src/web/cards.tsx +136 -0
  153. package/src/web/composer.tsx +571 -0
  154. package/src/web/emoji-picker.tsx +84 -0
  155. package/src/web/error-boundary.tsx +33 -0
  156. package/src/web/format.ts +55 -0
  157. package/src/web/inbox.tsx +196 -0
  158. package/src/web/index.ts +75 -0
  159. package/src/web/markdown.tsx +245 -0
  160. package/src/web/memory-client.ts +714 -0
  161. package/src/web/panel.tsx +135 -0
  162. package/src/web/schedule.tsx +204 -0
  163. package/src/web/search.tsx +129 -0
  164. package/src/web/thread-context.ts +64 -0
  165. package/src/web/thread.tsx +830 -0
  166. package/src/web/topics.tsx +160 -0
  167. package/src/web/use-voice-recorder.ts +264 -0
  168. package/src/web/video.tsx +516 -0
  169. package/src/web/voice-note.tsx +197 -0
  170. package/dist/chunk-27UTR5FW.js +0 -322
  171. package/dist/chunk-27UTR5FW.js.map +0 -1
  172. package/dist/chunk-JQXZUEEL.js +0 -525
  173. package/dist/chunk-JQXZUEEL.js.map +0 -1
  174. package/dist/chunk-SAY24E6V.js +0 -309
  175. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,966 @@
1
+ // src/core/availability.ts
2
+ var WEEKDAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
3
+ var SHORT = { mon: "Mon", tue: "Tue", wed: "Wed", thu: "Thu", fri: "Fri", sat: "Sat", sun: "Sun" };
4
+ function minutesOf(hhmm) {
5
+ if (typeof hhmm !== "string") return null;
6
+ const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm.trim());
7
+ if (!m) return null;
8
+ const h = Number(m[1]);
9
+ const min = Number(m[2]);
10
+ if (h > 24 || min > 59 || h === 24 && min > 0) return null;
11
+ return h * 60 + min;
12
+ }
13
+ var MAX_RANGES_PER_DAY = 4;
14
+ function normalizeHours(raw) {
15
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
16
+ const src = raw;
17
+ const out = {};
18
+ let any = false;
19
+ for (const day of WEEKDAYS) {
20
+ if (!Object.prototype.hasOwnProperty.call(src, day)) continue;
21
+ const ranges = src[day];
22
+ if (!Array.isArray(ranges)) continue;
23
+ const kept = [];
24
+ for (const r of ranges) {
25
+ if (!Array.isArray(r) || r.length !== 2) continue;
26
+ const [from, to] = r;
27
+ if (typeof from !== "string" || typeof to !== "string" || minutesOf(from) == null || minutesOf(to) == null) continue;
28
+ kept.push([from, to]);
29
+ if (kept.length >= MAX_RANGES_PER_DAY) break;
30
+ }
31
+ if (kept.length) {
32
+ out[day] = kept;
33
+ any = true;
34
+ }
35
+ }
36
+ return any ? out : null;
37
+ }
38
+ function localClock(now, timeZone) {
39
+ let parts;
40
+ try {
41
+ parts = new Intl.DateTimeFormat("en-US", {
42
+ timeZone: timeZone || void 0,
43
+ weekday: "short",
44
+ hour: "2-digit",
45
+ minute: "2-digit",
46
+ hourCycle: "h23"
47
+ }).formatToParts(now);
48
+ } catch {
49
+ parts = new Intl.DateTimeFormat("en-US", { weekday: "short", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(now);
50
+ }
51
+ const get = (t) => parts.find((p) => p.type === t)?.value ?? "";
52
+ const day = get("weekday").slice(0, 3).toLowerCase();
53
+ const hour = Number(get("hour")) % 24;
54
+ return { day: WEEKDAYS.includes(day) ? day : "mon", minutes: hour * 60 + Number(get("minute")) };
55
+ }
56
+ function isWithinWorkingHours(raw, timeZone, now = /* @__PURE__ */ new Date()) {
57
+ const hours = normalizeHours(raw);
58
+ if (!hours) return true;
59
+ const { day, minutes } = localClock(now, timeZone);
60
+ const yesterday = WEEKDAYS[(WEEKDAYS.indexOf(day) + 6) % 7];
61
+ for (const [from, to] of hours[day] ?? []) {
62
+ const a = minutesOf(from);
63
+ const b = minutesOf(to);
64
+ if (a == null || b == null) continue;
65
+ if (b > a ? minutes >= a && minutes < b : minutes >= a) return true;
66
+ }
67
+ for (const [from, to] of hours[yesterday] ?? []) {
68
+ const a = minutesOf(from);
69
+ const b = minutesOf(to);
70
+ if (a == null || b == null) continue;
71
+ if (b <= a && minutes < b) return true;
72
+ }
73
+ return false;
74
+ }
75
+ function isAway(a, now = /* @__PURE__ */ new Date()) {
76
+ if (!a) return false;
77
+ if (a.awayUntil) return Date.parse(a.awayUntil) > now.getTime();
78
+ return Boolean(a.awayMessage?.trim());
79
+ }
80
+ function clock(hhmm) {
81
+ const total = minutesOf(hhmm) ?? 0;
82
+ const h = Math.floor(total / 60) % 24;
83
+ const m = total % 60;
84
+ const h12 = h % 12 === 0 ? 12 : h % 12;
85
+ return m ? `${h12}:${String(m).padStart(2, "0")}` : String(h12);
86
+ }
87
+ function hoursSummary(raw) {
88
+ const hours = normalizeHours(raw);
89
+ if (!hours) return "";
90
+ const key = (d) => (hours[d] ?? []).map(([a, b]) => `${clock(a)}\u2013${clock(b)}`).join(", ");
91
+ const runs = [];
92
+ for (const d of WEEKDAYS) {
93
+ const text = key(d);
94
+ if (!text) continue;
95
+ const last = runs[runs.length - 1];
96
+ if (last && last.text === text && WEEKDAYS.indexOf(last.to) === WEEKDAYS.indexOf(d) - 1) last.to = d;
97
+ else runs.push({ from: d, to: d, text });
98
+ }
99
+ return runs.map((r) => {
100
+ const days = r.from === r.to ? SHORT[r.from] : WEEKDAYS.indexOf(r.to) - WEEKDAYS.indexOf(r.from) === 1 ? `${SHORT[r.from]}, ${SHORT[r.to]}` : `${SHORT[r.from]}\u2013${SHORT[r.to]}`;
101
+ return `${days}, ${r.text}`;
102
+ }).join("; ");
103
+ }
104
+ function timeZoneLabel(timeZone, locale = "en-US") {
105
+ if (!timeZone) return "";
106
+ try {
107
+ const name = new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: "longGeneric" }).formatToParts(/* @__PURE__ */ new Date()).find((p) => p.type === "timeZoneName")?.value;
108
+ if (name && !/^GMT|^UTC/.test(name)) return name.replace(/ Time$/, "");
109
+ } catch {
110
+ }
111
+ return (timeZone.split("/").pop() ?? timeZone).replace(/_/g, " ");
112
+ }
113
+ function untilLabel(iso, now = /* @__PURE__ */ new Date(), locale, timeZone) {
114
+ const d = new Date(iso);
115
+ if (!Number.isFinite(d.getTime())) return "later";
116
+ try {
117
+ return untilIn(d, now, locale, timeZone || void 0);
118
+ } catch {
119
+ return untilIn(d, now, void 0, void 0);
120
+ }
121
+ }
122
+ function untilIn(d, now, locale, tz) {
123
+ const dayKey = (x) => new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }).format(x);
124
+ if (dayKey(d) === dayKey(now)) return d.toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit", timeZone: tz });
125
+ const days = (d.getTime() - now.getTime()) / 864e5;
126
+ if (days < 7) return d.toLocaleDateString(locale, { weekday: "long", timeZone: tz });
127
+ return d.toLocaleDateString(locale, { day: "numeric", month: "short", timeZone: tz });
128
+ }
129
+ function awayLine(a, name, opts = {}) {
130
+ if (!a) return null;
131
+ const now = opts.now ?? /* @__PURE__ */ new Date();
132
+ const message = a.awayMessage?.trim();
133
+ if (isAway(a, now)) {
134
+ if (a.awayUntil) return `Away until ${untilLabel(a.awayUntil, now, opts.locale, a.timeZone)}${message ? `: ${message}` : ""}`;
135
+ return `${name} is away${message ? `: ${message}` : ""}`;
136
+ }
137
+ const summary = hoursSummary(a.hours);
138
+ if (!summary) return null;
139
+ if (!opts.always && isWithinWorkingHours(a.hours, a.timeZone, now)) return null;
140
+ const zone = timeZoneLabel(a.timeZone, opts.locale);
141
+ return `${name} replies ${summary}${zone ? ` (${zone})` : ""}`;
142
+ }
143
+ function namesLine(names) {
144
+ if (names.length <= 1) return names[0] ?? "";
145
+ if (names.length === 2) return `${names[0]} and ${names[1]}`;
146
+ const rest = names.length - 2;
147
+ return `${names[0]}, ${names[1]} and ${rest === 1 ? "1 other" : `${rest} others`}`;
148
+ }
149
+ function awaySummary(people, opts = {}) {
150
+ const now = opts.now ?? /* @__PURE__ */ new Date();
151
+ const out = people.filter((p) => awayLine(p.availability, p.name, { ...opts, now, always: false }) !== null);
152
+ if (out.length === 0) return null;
153
+ if (out.length === 1) return awayLine(out[0].availability, out[0].name, { ...opts, now, always: false });
154
+ const away = out.filter((p) => isAway(p.availability, now)).length;
155
+ const what = away === out.length ? "away" : away === 0 ? "outside their hours" : "away or outside their hours";
156
+ return `${namesLine(out.map((p) => p.name))} are ${what}`;
157
+ }
158
+
159
+ // src/core/media.ts
160
+ function randomId() {
161
+ const c = globalThis.crypto;
162
+ if (c?.randomUUID) return c.randomUUID();
163
+ const b = new Uint8Array(16);
164
+ if (c?.getRandomValues) c.getRandomValues(b);
165
+ else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
166
+ b[6] = b[6] & 15 | 64;
167
+ b[8] = b[8] & 63 | 128;
168
+ const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
169
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
170
+ }
171
+ var ALLOWED_EXTENSIONS = [
172
+ "png",
173
+ "jpg",
174
+ "jpeg",
175
+ "webp",
176
+ "gif",
177
+ "heic",
178
+ "heif",
179
+ "m4a",
180
+ "aac",
181
+ "mp3",
182
+ "ogg",
183
+ "oga",
184
+ "opus",
185
+ "weba",
186
+ "webm",
187
+ "mp4",
188
+ "mov",
189
+ "pdf",
190
+ "txt",
191
+ "csv",
192
+ "docx",
193
+ "xlsx",
194
+ "pptx"
195
+ ];
196
+ var MIME_EXTENSIONS = {
197
+ "image/png": "png",
198
+ "image/jpeg": "jpg",
199
+ "image/webp": "webp",
200
+ "image/gif": "gif",
201
+ "image/heic": "heic",
202
+ "image/heif": "heif",
203
+ "audio/mp4": "m4a",
204
+ "audio/x-m4a": "m4a",
205
+ "audio/aac": "aac",
206
+ "audio/mpeg": "mp3",
207
+ "audio/ogg": "ogg",
208
+ "audio/webm": "webm",
209
+ "video/mp4": "mp4",
210
+ "video/quicktime": "mov",
211
+ "video/webm": "webm",
212
+ "application/pdf": "pdf",
213
+ "text/plain": "txt",
214
+ "text/csv": "csv",
215
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
216
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
217
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx"
218
+ };
219
+ function fileExtension(name, contentType) {
220
+ const base = (contentType ?? "").split(";")[0].trim().toLowerCase();
221
+ const fromType = MIME_EXTENSIONS[base];
222
+ if (fromType) return fromType;
223
+ const dot = name.lastIndexOf(".");
224
+ return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
225
+ }
226
+ function isAllowedExtension(ext) {
227
+ return ALLOWED_EXTENSIONS.includes(ext.toLowerCase());
228
+ }
229
+ function attachmentKindFor(contentType, opts = {}) {
230
+ const t = contentType.toLowerCase();
231
+ if (opts.recordedVoice || t.startsWith("audio/")) return "voice";
232
+ if (t.startsWith("image/")) return "image";
233
+ if (t.startsWith("video/")) return "video";
234
+ return "file";
235
+ }
236
+ var CHAT_LIMITS = {
237
+ /** Voice notes and videos, for members. */
238
+ recordingMs: 5 * 6e4,
239
+ /** For owners, admins and observers (coaches, staff). */
240
+ staffRecordingMs: 10 * 6e4,
241
+ fileBytes: 50 * 1024 * 1024,
242
+ videoBytes: 100 * 1024 * 1024,
243
+ attachmentsPerMessage: 20,
244
+ bodyChars: 1e4
245
+ };
246
+ function recordingLimitMs(role) {
247
+ return role === "owner" || role === "admin" || role === "observer" ? CHAT_LIMITS.staffRecordingMs : CHAT_LIMITS.recordingMs;
248
+ }
249
+ function waveformPeaks(samples, count = 48) {
250
+ const n = Math.max(1, Math.min(128, Math.floor(count)));
251
+ if (samples.length === 0) return new Array(n).fill(0);
252
+ const size = samples.length / n;
253
+ const raw = [];
254
+ for (let i = 0; i < n; i++) {
255
+ let peak = 0;
256
+ const end = Math.min(samples.length, Math.floor((i + 1) * size));
257
+ for (let j = Math.floor(i * size); j < end; j++) {
258
+ const v = Math.abs(samples[j]);
259
+ if (v > peak) peak = v;
260
+ }
261
+ raw.push(peak);
262
+ }
263
+ const max = Math.max(...raw);
264
+ if (max < 0.01) return raw.map(() => 0);
265
+ return raw.map((v) => Math.round(v / max * 100));
266
+ }
267
+ function waveformFromMetering(levelsDb, count = 48) {
268
+ const linear = levelsDb.map((db) => Number.isFinite(db) ? Math.pow(10, Math.max(-60, Math.min(0, db)) / 20) : 0);
269
+ return waveformPeaks(linear, count);
270
+ }
271
+ function formatDuration(ms) {
272
+ const total = Math.max(0, Math.round((ms ?? 0) / 1e3));
273
+ const h = Math.floor(total / 3600);
274
+ const m = Math.floor(total % 3600 / 60);
275
+ const s = total % 60;
276
+ return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}` : `${m}:${String(s).padStart(2, "0")}`;
277
+ }
278
+ var PLAYBACK_RATES = [1, 1.5, 2];
279
+ function nextPlaybackRate(rate) {
280
+ const i = PLAYBACK_RATES.indexOf(rate);
281
+ return PLAYBACK_RATES[(i + 1) % PLAYBACK_RATES.length];
282
+ }
283
+
284
+ // src/core/client.ts
285
+ var CHAT_SCHEMA = "genesis_chat";
286
+ var CHAT_BUCKET = "genesis-chat";
287
+ var conversationTopic = (conversationId) => `genesis-chat:conv:${conversationId}`;
288
+ var userTopic = (userId) => `genesis-chat:user:${userId}`;
289
+ var ChatError = class extends Error {
290
+ constructor(message, code, operation) {
291
+ super(message);
292
+ this.code = code;
293
+ this.operation = operation;
294
+ this.name = "ChatError";
295
+ }
296
+ code;
297
+ operation;
298
+ get forbidden() {
299
+ return this.code === "42501";
300
+ }
301
+ get rateLimited() {
302
+ return this.code === "GS429";
303
+ }
304
+ /** Worth trying again: the network, not the database, said no. */
305
+ get transient() {
306
+ return this.code === void 0 || this.code === "" || /^(08|57|53)/.test(this.code) || this.code === "PGRST000";
307
+ }
308
+ /**
309
+ * Sending it again won't help: the database refused what was sent (not allowed 42501, bad
310
+ * data 22xxx such as 22023, a conflict 23xxx such as 23505, another refusal 42xxx, or a
311
+ * function's own `raise` P0001). A person may still retry by hand; nothing retries it alone.
312
+ */
313
+ get permanent() {
314
+ return !this.transient && !this.rateLimited && /^(22|23|42|P0)/.test(this.code ?? "");
315
+ }
316
+ };
317
+ function must(operation, result) {
318
+ if (result.error) throw new ChatError(result.error.message, result.error.code, operation);
319
+ return result.data;
320
+ }
321
+ var str = (v) => v == null ? null : String(v);
322
+ var num = (v) => v == null ? null : Number(v);
323
+ function toAttachment(r) {
324
+ return {
325
+ id: String(r.id),
326
+ messageId: String(r.message_id),
327
+ position: Number(r.position ?? 0),
328
+ kind: String(r.kind),
329
+ ref: r.ref ?? {},
330
+ provider: r.provider ?? "storage",
331
+ path: str(r.path),
332
+ providerId: str(r.provider_id),
333
+ playbackId: str(r.playback_id),
334
+ url: str(r.url),
335
+ status: r.status ?? "ready",
336
+ name: str(r.name),
337
+ mime: str(r.mime),
338
+ bytes: num(r.bytes),
339
+ width: num(r.width),
340
+ height: num(r.height),
341
+ durationMs: num(r.duration_ms),
342
+ waveform: r.waveform ?? null,
343
+ posterPath: str(r.poster_path),
344
+ thumbhash: str(r.thumbhash),
345
+ transcript: str(r.transcript),
346
+ transcriptStatus: r.transcript_status ?? null
347
+ };
348
+ }
349
+ function toMessage(r) {
350
+ const attachments = (r.attachments ?? []).map(toAttachment).sort((a, b) => a.position - b.position);
351
+ const reactions = (r.reactions ?? []).map((x) => ({ emoji: String(x.emoji), userId: String(x.user_id) }));
352
+ return {
353
+ id: String(r.id),
354
+ conversationId: String(r.conversation_id),
355
+ seq: Number(r.seq),
356
+ clientId: String(r.client_id),
357
+ senderId: str(r.sender_id),
358
+ senderRole: r.sender_role,
359
+ kind: r.kind ?? "text",
360
+ body: String(r.body ?? ""),
361
+ mentions: r.mentions ?? [],
362
+ replyToId: str(r.reply_to_id),
363
+ threadRootId: str(r.thread_root_id),
364
+ threadCount: Number(r.thread_count ?? 0),
365
+ threadLastAt: str(r.thread_last_at),
366
+ topic: str(r.topic),
367
+ visibility: r.visibility ?? "everyone",
368
+ silent: Boolean(r.silent),
369
+ meta: r.meta ?? {},
370
+ createdAt: String(r.created_at),
371
+ editedAt: str(r.edited_at),
372
+ deletedAt: str(r.deleted_at),
373
+ moderation: r.moderation === "hidden" || r.moderation === "flagged" ? r.moderation : null,
374
+ attachments,
375
+ reactions
376
+ };
377
+ }
378
+ function toConversation(r) {
379
+ return {
380
+ id: String(r.id),
381
+ kind: String(r.kind),
382
+ title: str(r.title),
383
+ subjectType: str(r.subject_type),
384
+ subjectId: str(r.subject_id),
385
+ settings: r.settings ?? {},
386
+ status: r.status ?? null,
387
+ assigneeId: str(r.assignee_id),
388
+ lastSeq: Number(r.last_seq ?? 0),
389
+ lastMessageAt: str(r.last_message_at),
390
+ createdAt: String(r.created_at),
391
+ archivedAt: str(r.archived_at)
392
+ };
393
+ }
394
+ function toInboxItem(r) {
395
+ const last = r.last_message;
396
+ return {
397
+ conversationId: String(r.conversation_id),
398
+ kind: String(r.kind),
399
+ title: str(r.title),
400
+ subjectType: str(r.subject_type),
401
+ subjectId: str(r.subject_id),
402
+ status: r.status ?? null,
403
+ role: r.role,
404
+ otherUserId: str(r.other_user_id),
405
+ memberCount: Number(r.member_count ?? 0),
406
+ lastSeq: Number(r.last_seq ?? 0),
407
+ lastReadSeq: Number(r.last_read_seq ?? 0),
408
+ unread: Number(r.unread ?? 0),
409
+ mentioned: Boolean(r.mentioned),
410
+ muted: Boolean(r.muted),
411
+ pinned: Boolean(r.pinned),
412
+ archived: Boolean(r.archived),
413
+ lastMessageAt: str(r.last_message_at),
414
+ lastMessage: last ? {
415
+ id: String(last.id),
416
+ seq: Number(last.seq),
417
+ senderId: str(last.sender_id),
418
+ kind: last.kind ?? "text",
419
+ body: String(last.body ?? ""),
420
+ deleted: Boolean(last.deleted),
421
+ createdAt: String(last.created_at),
422
+ attachment: last.attachment ?? null
423
+ } : null
424
+ };
425
+ }
426
+ function toTopic(r) {
427
+ return {
428
+ conversationId: String(r.conversation_id),
429
+ topic: String(r.topic),
430
+ title: String(r.title ?? r.topic),
431
+ position: Number(r.position ?? 0),
432
+ closedAt: str(r.closed_at),
433
+ createdBy: str(r.created_by),
434
+ createdAt: String(r.created_at)
435
+ };
436
+ }
437
+ function fromAttachmentArg(a) {
438
+ const out = { kind: String(a.kind ?? "file") };
439
+ const map = [
440
+ ["path", "path"],
441
+ ["provider", "provider"],
442
+ ["providerId", "provider_id"],
443
+ ["url", "url"],
444
+ ["ref", "ref"],
445
+ ["status", "status"],
446
+ ["name", "name"],
447
+ ["mime", "mime"],
448
+ ["bytes", "bytes"],
449
+ ["width", "width"],
450
+ ["height", "height"],
451
+ ["durationMs", "duration_ms"],
452
+ ["waveform", "waveform"],
453
+ ["posterPath", "poster_path"],
454
+ ["thumbhash", "thumbhash"]
455
+ ];
456
+ for (const [k, col] of map) if (a[col] != null) out[k] = a[col];
457
+ return out;
458
+ }
459
+ function toScheduled(r) {
460
+ const sentMessageId = str(r.sent_message_id);
461
+ const cancelledAt = str(r.cancelled_at);
462
+ const error = str(r.error);
463
+ return {
464
+ id: String(r.id),
465
+ conversationId: String(r.conversation_id),
466
+ clientId: String(r.client_id),
467
+ body: String(r.body ?? ""),
468
+ attachments: (r.attachments ?? []).map(fromAttachmentArg),
469
+ replyToId: str(r.reply_to_id),
470
+ topic: str(r.topic),
471
+ mentions: r.mentions ?? [],
472
+ silent: Boolean(r.silent),
473
+ sendAt: String(r.send_at),
474
+ sentMessageId,
475
+ cancelledAt,
476
+ error,
477
+ createdAt: String(r.created_at),
478
+ status: sentMessageId ? "sent" : cancelledAt ? "cancelled" : error ? "failed" : "scheduled"
479
+ };
480
+ }
481
+ function toAvailability(r) {
482
+ return {
483
+ userId: String(r.user_id),
484
+ awayMessage: str(r.away_message),
485
+ awayUntil: str(r.away_until),
486
+ timeZone: str(r.time_zone),
487
+ // Someone else's hours: only well-formed days and ranges reach the renderers.
488
+ hours: normalizeHours(r.hours)
489
+ };
490
+ }
491
+ function toSearchHit(r) {
492
+ return {
493
+ messageId: String(r.message_id),
494
+ conversationId: String(r.conversation_id),
495
+ seq: Number(r.seq),
496
+ senderId: str(r.sender_id),
497
+ createdAt: String(r.created_at),
498
+ snippet: String(r.snippet ?? ""),
499
+ inTranscript: Boolean(r.in_transcript),
500
+ threadRootId: str(r.thread_root_id)
501
+ };
502
+ }
503
+ function toLinkPreview(r) {
504
+ const image = str(r.image_url);
505
+ return {
506
+ url: String(r.url),
507
+ status: r.status ?? "ok",
508
+ title: str(r.title),
509
+ description: str(r.description),
510
+ imageUrl: image && /^https:\/\//i.test(image) ? image : null,
511
+ siteName: str(r.site_name),
512
+ fetchedAt: String(r.fetched_at)
513
+ };
514
+ }
515
+ var MESSAGE_COLUMNS = "*, attachments(*), reactions(emoji, user_id)";
516
+ function createChatClient(supabase, options = {}) {
517
+ const db = () => supabase.schema(CHAT_SCHEMA);
518
+ const signedUrlSeconds = options.signedUrlSeconds ?? 3600;
519
+ const signedCache = /* @__PURE__ */ new Map();
520
+ const unsubscribed = /* @__PURE__ */ new WeakSet();
521
+ function join(channel, onStatus) {
522
+ const realtime = supabase.realtime;
523
+ void Promise.resolve(realtime?.setAuth?.()).catch(() => void 0).then(() => {
524
+ if (!unsubscribed.has(channel)) channel.subscribe(onStatus);
525
+ });
526
+ return () => {
527
+ unsubscribed.add(channel);
528
+ void supabase.removeChannel(channel);
529
+ };
530
+ }
531
+ async function rpc(fn, args) {
532
+ return must(fn, await db().rpc(fn, args));
533
+ }
534
+ return {
535
+ supabase,
536
+ /** Your conversations: one `kind`, several `kinds`, or all but `excludeKinds` (e.g. Messages without `support`). */
537
+ async inbox(opts = {}) {
538
+ const rows = await rpc("inbox", {
539
+ p_kind: opts.kind ?? null,
540
+ p_archived: opts.archived ?? false,
541
+ p_limit: opts.limit ?? 50,
542
+ p_before: opts.before ?? null,
543
+ ...opts.kinds?.length ? { p_kinds: opts.kinds } : {},
544
+ ...opts.excludeKinds?.length ? { p_exclude_kinds: opts.excludeKinds } : {}
545
+ });
546
+ return rows.map(toInboxItem);
547
+ },
548
+ async observed(kind, opts = {}) {
549
+ return rpc("observed", { p_kind: kind, p_status: opts.status ?? null, p_limit: opts.limit ?? 50, p_before: opts.before ?? null });
550
+ },
551
+ /** Unread across your conversations (for a badge), leaving out `excludeKinds` to match an inbox that does. */
552
+ async unreadTotal(opts = {}) {
553
+ return rpc("unread_total", opts.excludeKinds?.length ? { p_exclude_kinds: opts.excludeKinds } : {});
554
+ },
555
+ async conversation(id) {
556
+ const row = must("conversation", await db().from("conversations").select("*").eq("id", id).maybeSingle());
557
+ return row ? toConversation(row) : null;
558
+ },
559
+ /** A kind's rules (how many members, who posts, threads), for what the UI offers. */
560
+ async kind(kind) {
561
+ const row = must("kind", await db().from("kinds").select("kind, label, max_members, who_posts, membership, threads, observers_post").eq("kind", kind).maybeSingle());
562
+ return row ? {
563
+ kind: String(row.kind),
564
+ label: String(row.label),
565
+ maxMembers: row.max_members == null ? null : Number(row.max_members),
566
+ whoPosts: row.who_posts,
567
+ membership: row.membership,
568
+ threads: Boolean(row.threads),
569
+ observersPost: Boolean(row.observers_post)
570
+ } : null;
571
+ },
572
+ async myRole(id) {
573
+ return rpc("my_role", { p_conversation_id: id });
574
+ },
575
+ async canPost(id) {
576
+ return rpc("can_post", { p_conversation_id: id });
577
+ },
578
+ async members(id) {
579
+ const rows = must(
580
+ "members",
581
+ await db().from("members").select("*").eq("conversation_id", id).is("left_at", null).neq("role", "observer")
582
+ );
583
+ return rows.map((r) => ({
584
+ conversationId: String(r.conversation_id),
585
+ userId: String(r.user_id),
586
+ role: r.role,
587
+ lastReadSeq: Number(r.last_read_seq ?? 0),
588
+ joinedAt: String(r.joined_at)
589
+ }));
590
+ },
591
+ /**
592
+ * A page of messages, oldest first: the latest `limit` (default 50), or those
593
+ * before `beforeSeq`, or (to catch up after a reconnect) those after `afterSeq`.
594
+ * A thread's replies with `threadRootId`; one topic's with `topic`.
595
+ */
596
+ async messages(conversationId, opts = {}) {
597
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
598
+ let q = db().from("messages").select(MESSAGE_COLUMNS).eq("conversation_id", conversationId);
599
+ q = opts.threadRootId ? q.eq("thread_root_id", opts.threadRootId) : q.is("thread_root_id", null);
600
+ if (opts.topic) q = q.eq("topic", opts.topic);
601
+ if (opts.afterSeq != null) {
602
+ const rows2 = must("messages", await q.gt("seq", opts.afterSeq).order("seq", { ascending: true }).limit(limit));
603
+ return { messages: rows2.map(toMessage), hasEarlier: false };
604
+ }
605
+ if (opts.beforeSeq != null) q = q.lt("seq", opts.beforeSeq);
606
+ const rows = must("messages", await q.order("seq", { ascending: false }).limit(limit + 1));
607
+ const hasEarlier = rows.length > limit;
608
+ return { messages: rows.slice(0, limit).map(toMessage).reverse(), hasEarlier };
609
+ },
610
+ async message(id) {
611
+ const row = must("message", await db().from("messages").select(MESSAGE_COLUMNS).eq("id", id).maybeSingle());
612
+ return row ? toMessage(row) : null;
613
+ },
614
+ async startConversation(kind, memberIds, opts = {}) {
615
+ return rpc("start_conversation", {
616
+ p_kind: kind,
617
+ p_member_ids: memberIds,
618
+ p_title: opts.title ?? null,
619
+ p_subject_type: opts.subjectType ?? null,
620
+ p_subject_id: opts.subjectId ?? null
621
+ });
622
+ },
623
+ /** Sends a message. Safe to retry with the same clientId. */
624
+ async send(input) {
625
+ const row = await rpc("send_message", {
626
+ p_conversation_id: input.conversationId,
627
+ p_client_id: input.clientId,
628
+ p_body: input.body ?? "",
629
+ p_attachments: (input.attachments ?? []).map(attachmentArg),
630
+ p_reply_to_id: input.replyToId ?? null,
631
+ p_thread_root_id: input.threadRootId ?? null,
632
+ p_topic: input.topic ?? null,
633
+ p_mentions: input.mentions ?? [],
634
+ p_visibility: input.visibility ?? "everyone",
635
+ p_silent: input.silent ?? false
636
+ });
637
+ return await this.message(String(row.id)) ?? toMessage(row);
638
+ },
639
+ async edit(messageId, body, mentions = []) {
640
+ return toMessage(await rpc("edit_message", { p_message_id: messageId, p_body: body, p_mentions: mentions }));
641
+ },
642
+ async remove(messageId) {
643
+ await rpc("delete_message", { p_message_id: messageId });
644
+ },
645
+ async react(messageId, emoji, on = true) {
646
+ await rpc("react", { p_message_id: messageId, p_emoji: emoji, p_on: on });
647
+ },
648
+ async markRead(conversationId, seq) {
649
+ return rpc("mark_read", { p_conversation_id: conversationId, p_seq: seq ?? null });
650
+ },
651
+ async setMySettings(conversationId, s) {
652
+ await rpc("set_my_settings", {
653
+ p_conversation_id: conversationId,
654
+ p_muted_until: s.mutedUntil ?? null,
655
+ p_notify: s.notify ?? null,
656
+ p_archived: s.archived ?? null,
657
+ p_pinned: s.pinned ?? null,
658
+ p_clear_mute: s.mutedUntil === null
659
+ });
660
+ },
661
+ async updateConversation(conversationId, s) {
662
+ await rpc("update_conversation", { p_conversation_id: conversationId, p_title: s.title ?? null, p_settings: s.settings ?? null });
663
+ },
664
+ async addMembers(conversationId, userIds) {
665
+ await rpc("add_members", { p_conversation_id: conversationId, p_user_ids: userIds });
666
+ },
667
+ async removeMember(conversationId, userId) {
668
+ await rpc("remove_member", { p_conversation_id: conversationId, p_user_id: userId ?? null });
669
+ },
670
+ async setStatus(conversationId, status, assigneeId) {
671
+ await rpc("set_status", { p_conversation_id: conversationId, p_status: status, p_assignee_id: assigneeId ?? null });
672
+ },
673
+ async pin(messageId, on = true) {
674
+ await rpc("pin_message", { p_message_id: messageId, p_on: on });
675
+ },
676
+ async pins(conversationId) {
677
+ const rows = must("pins", await db().from("pins").select("message_id").eq("conversation_id", conversationId));
678
+ return rows.map((r) => String(r.message_id));
679
+ },
680
+ async save(messageId, on = true, note) {
681
+ await rpc("save_message", { p_message_id: messageId, p_on: on, p_note: note ?? null });
682
+ },
683
+ async report(messageId, reason, note) {
684
+ return rpc("report_message", { p_message_id: messageId, p_reason: reason, p_note: note ?? null });
685
+ },
686
+ // ── Migration 0002 ──────────────────────────────────────────────────────
687
+ /** A conversation's topics, in order (position, then key). */
688
+ async topics(conversationId) {
689
+ const rows = must(
690
+ "topics",
691
+ await db().from("topics").select("*").eq("conversation_id", conversationId).order("position", { ascending: true }).order("topic", { ascending: true })
692
+ );
693
+ return rows.map(toTopic);
694
+ },
695
+ /** Adds a topic, or renames, reorders, closes (`closed: true`) or reopens it. Owners and admins. */
696
+ async setTopic(conversationId, topic, input = {}) {
697
+ await rpc("set_topic", {
698
+ p_conversation_id: conversationId,
699
+ p_topic: topic,
700
+ p_title: input.title ?? null,
701
+ p_position: input.position ?? null,
702
+ p_closed: input.closed ?? null
703
+ });
704
+ },
705
+ /** Schedules a message (a minute to a year ahead) and returns its id. Safe to retry with the same clientId. */
706
+ async schedule(input) {
707
+ const sendAt = typeof input.sendAt === "string" ? input.sendAt : input.sendAt.toISOString();
708
+ return rpc("schedule_message", {
709
+ p_conversation_id: input.conversationId,
710
+ p_client_id: input.clientId,
711
+ p_send_at: sendAt,
712
+ p_body: input.body ?? "",
713
+ p_attachments: (input.attachments ?? []).map(attachmentArg),
714
+ p_reply_to_id: input.replyToId ?? null,
715
+ p_topic: input.topic ?? null,
716
+ p_mentions: input.mentions ?? [],
717
+ p_silent: input.silent ?? false
718
+ });
719
+ },
720
+ /** Your messages still waiting to go (and ones that failed when due), soonest first; in one conversation or all. */
721
+ async scheduled(conversationId) {
722
+ let q = db().from("scheduled").select("*").is("sent_message_id", null).is("cancelled_at", null);
723
+ if (conversationId) q = q.eq("conversation_id", conversationId);
724
+ const rows = must("scheduled", await q.order("send_at", { ascending: true }).limit(200));
725
+ return rows.map(toScheduled);
726
+ },
727
+ async cancelScheduled(id) {
728
+ await rpc("cancel_scheduled", { p_id: id });
729
+ },
730
+ /** The away message and hours of a conversation's members who set them (not observers). */
731
+ async availabilityFor(conversationId) {
732
+ const rows = await rpc("availability_for", { p_conversation_id: conversationId });
733
+ return (rows ?? []).map(toAvailability);
734
+ },
735
+ /** Your own away message and hours, or null when never set. */
736
+ async myAvailability() {
737
+ const row = must("myAvailability", await db().from("availability").select("*").maybeSingle());
738
+ return row ? toAvailability(row) : null;
739
+ },
740
+ /** Replaces your away message and hours (a field left out is cleared). */
741
+ async setAvailability(input) {
742
+ const until = input.awayUntil ?? null;
743
+ await rpc("set_availability", {
744
+ p_away_message: input.awayMessage ?? null,
745
+ p_away_until: until,
746
+ p_time_zone: input.timeZone ?? null,
747
+ p_hours: input.hours ?? null
748
+ });
749
+ },
750
+ /**
751
+ * Sends one text message into each of these conversations where you may post (a coach to
752
+ * each client's direct conversation), so replies come back privately. Text only. Safe to
753
+ * retry with the same clientId. Returns the ids of the messages sent.
754
+ */
755
+ async broadcast(conversationIds, clientId, body, opts = {}) {
756
+ const ids = await rpc("broadcast_message", {
757
+ p_conversation_ids: conversationIds,
758
+ p_client_id: clientId,
759
+ p_body: body,
760
+ p_attachments: [],
761
+ p_silent: opts.silent ?? false
762
+ });
763
+ return (ids ?? []).map(String);
764
+ },
765
+ /** Messages (and transcripts) you can read that match, newest first: in one conversation or all of yours. Two characters at least. */
766
+ async search(query, conversationId, opts = {}) {
767
+ if (query.trim().length < 2) return [];
768
+ const rows = await rpc("search_messages", {
769
+ p_query: query,
770
+ p_conversation_id: conversationId ?? null,
771
+ p_limit: opts.limit ?? 30
772
+ });
773
+ return (rows ?? []).map(toSearchHit);
774
+ },
775
+ /**
776
+ * The previews the server has fetched for the links in these messages, by message id (only
777
+ * `ok` ones; links not fetched yet are left out). Read through link_previews_for, one call
778
+ * per message (up to 6 at once), which answers only for messages you can read: the preview
779
+ * table itself is closed, since a URL can carry a secret.
780
+ */
781
+ async linkPreviewsFor(messageIds) {
782
+ const ids = [...new Set(messageIds.filter(Boolean))].slice(0, 200);
783
+ const out = {};
784
+ let next = 0;
785
+ const worker = async () => {
786
+ while (next < ids.length) {
787
+ const id = ids[next++];
788
+ const rows = await rpc("link_previews_for", { p_message_id: id });
789
+ out[id] = (rows ?? []).map(toLinkPreview).filter((p) => p.status === "ok");
790
+ }
791
+ };
792
+ await Promise.all(Array.from({ length: Math.min(6, ids.length) }, worker));
793
+ return out;
794
+ },
795
+ /**
796
+ * Uploads a file into the conversation's folder for the signed-in person and
797
+ * returns its path, ready to attach. Resumable for big files is the native
798
+ * renderer's job (Supabase TUS); this is the plain upload.
799
+ *
800
+ * With `onProgress(sent, total)`, where XMLHttpRequest exists (browsers, React Native), the
801
+ * bytes go to a signed upload URL (`createSignedUploadUrl`, which checks the same storage
802
+ * policy as a plain upload) through XMLHttpRequest, which reports progress; elsewhere the
803
+ * plain upload runs and reports once, at the end. `signal` cancels it.
804
+ */
805
+ async upload(conversationId, userId, file, opts) {
806
+ const ext = fileExtension(opts.name, opts.contentType);
807
+ if (!isAllowedExtension(ext)) throw new ChatError(`Files of type .${ext} can't be sent`, "22023", "upload");
808
+ const id = opts.id ?? randomId();
809
+ const path = `${conversationId}/${userId}/${id}.${ext}`;
810
+ const bucket = supabase.storage.from(CHAT_BUCKET);
811
+ if (opts.onProgress && typeof XMLHttpRequest !== "undefined" && typeof bucket.createSignedUploadUrl === "function") {
812
+ const signed = must("upload", await bucket.createSignedUploadUrl(path));
813
+ if (!signed?.signedUrl) throw new ChatError("No upload URL came back", void 0, "upload");
814
+ await sendBytes(signed.signedUrl, file, {
815
+ method: "PUT",
816
+ headers: { "content-type": opts.contentType, "x-upsert": "false", "cache-control": "max-age=3600" },
817
+ signal: opts.signal,
818
+ onProgress: opts.onProgress
819
+ });
820
+ return path;
821
+ }
822
+ must("upload", await bucket.upload(path, file, { contentType: opts.contentType, upsert: false }));
823
+ const total = typeof Blob !== "undefined" && file instanceof Blob ? file.size : file.byteLength;
824
+ opts.onProgress?.(total, total);
825
+ return path;
826
+ },
827
+ /** Signed links for files, cached until shortly before they expire. */
828
+ async fileUrls(paths) {
829
+ const now = Date.now();
830
+ const out = {};
831
+ const missing = [];
832
+ for (const p of new Set(paths)) {
833
+ const hit = signedCache.get(p);
834
+ if (hit && hit.until > now) out[p] = hit.url;
835
+ else missing.push(p);
836
+ }
837
+ if (missing.length) {
838
+ const { data, error } = await supabase.storage.from(CHAT_BUCKET).createSignedUrls(missing, signedUrlSeconds);
839
+ if (error) throw new ChatError(error.message, void 0, "fileUrls");
840
+ for (const d of data ?? []) {
841
+ if (d.signedUrl && d.path) {
842
+ out[d.path] = d.signedUrl;
843
+ signedCache.set(d.path, { url: d.signedUrl, until: now + (signedUrlSeconds - 60) * 1e3 });
844
+ }
845
+ }
846
+ }
847
+ return out;
848
+ },
849
+ /** Your inbox's live hints (a new message anywhere you are, a membership change). */
850
+ subscribeInbox(userId, onSignal) {
851
+ const channel = supabase.channel(userTopic(userId), { config: { private: true } }).on(
852
+ "broadcast",
853
+ { event: "inbox" },
854
+ ({ payload }) => onSignal({ type: "inbox", conversationId: String(payload?.conversation_id), seq: payload?.seq })
855
+ );
856
+ return { unsubscribe: join(channel) };
857
+ },
858
+ /**
859
+ * A conversation's live hints, plus who is typing or recording. `onJoin` runs every
860
+ * time the channel joins, the first time included: the moment to fetch what arrived
861
+ * before the subscription was live. `onReconnect` (kept for older callers) runs on
862
+ * re-joins only.
863
+ */
864
+ subscribeConversation(conversationId, me, handlers) {
865
+ let joinedOnce = false;
866
+ const channel = supabase.channel(conversationTopic(conversationId), { config: { private: true, broadcast: { self: false } } }).on(
867
+ "broadcast",
868
+ { event: "message" },
869
+ ({ payload }) => handlers.onSignal({ type: "message", conversationId, id: String(payload?.id), seq: payload?.seq, op: payload?.op ?? "insert" })
870
+ ).on(
871
+ "broadcast",
872
+ { event: "reaction" },
873
+ ({ payload }) => handlers.onSignal({ type: "reaction", conversationId, messageId: String(payload?.message_id) })
874
+ ).on("broadcast", { event: "members" }, () => handlers.onSignal({ type: "members", conversationId })).on("broadcast", { event: "conversation" }, () => handlers.onSignal({ type: "conversation", conversationId })).on("broadcast", { event: "activity" }, ({ payload }) => {
875
+ if (payload?.userId && payload.userId !== me) handlers.onActivity?.({ userId: payload.userId, activity: payload.activity, at: Date.now() });
876
+ });
877
+ let joined = false;
878
+ const leave = join(channel, (status) => {
879
+ joined = status === "SUBSCRIBED";
880
+ if (status === "SUBSCRIBED") {
881
+ handlers.onJoin?.();
882
+ if (joinedOnce) handlers.onReconnect?.();
883
+ joinedOnce = true;
884
+ }
885
+ });
886
+ return {
887
+ signal(activity) {
888
+ if (!joined) return;
889
+ void channel.send({ type: "broadcast", event: "activity", payload: { userId: me, activity } });
890
+ },
891
+ unsubscribe: leave
892
+ };
893
+ }
894
+ };
895
+ }
896
+ function attachmentArg(a) {
897
+ return {
898
+ kind: a.kind,
899
+ path: a.path,
900
+ provider: a.provider,
901
+ provider_id: a.providerId,
902
+ url: a.url,
903
+ ref: a.ref,
904
+ status: a.status,
905
+ name: a.name,
906
+ mime: a.mime,
907
+ bytes: a.bytes,
908
+ width: a.width,
909
+ height: a.height,
910
+ duration_ms: a.durationMs,
911
+ waveform: a.waveform,
912
+ poster_path: a.posterPath,
913
+ thumbhash: a.thumbhash
914
+ };
915
+ }
916
+
917
+ // src/core/transfer.ts
918
+ var sizeOf = (body) => typeof Blob !== "undefined" && body instanceof Blob ? body.size : body.byteLength;
919
+ var refusal = (status) => status >= 500 || status === 0 || status === 408 ? void 0 : status === 429 ? "GS429" : "22023";
920
+ var aborted = () => new ChatError("The upload was cancelled", "GSABORT", "upload");
921
+ function sendBytes(url, body, opts = {}) {
922
+ const method = opts.method ?? "PUT";
923
+ const total = sizeOf(body);
924
+ if (opts.signal?.aborted) return Promise.reject(aborted());
925
+ if (typeof XMLHttpRequest === "undefined" || opts.fetch) {
926
+ const f = opts.fetch ?? fetch;
927
+ return f(url, { method, body, headers: opts.headers, signal: opts.signal }).then(async (res) => {
928
+ if (!res.ok) throw new ChatError(`The upload failed (${res.status})`, refusal(res.status), "upload");
929
+ opts.onProgress?.(total, total);
930
+ return res.text().catch(() => "");
931
+ });
932
+ }
933
+ return new Promise((resolve, reject) => {
934
+ const xhr = new XMLHttpRequest();
935
+ const onAbort = () => xhr.abort();
936
+ const done = () => opts.signal?.removeEventListener("abort", onAbort);
937
+ xhr.open(method, url);
938
+ for (const [k, v] of Object.entries(opts.headers ?? {})) xhr.setRequestHeader(k, v);
939
+ if (opts.onProgress && xhr.upload) {
940
+ xhr.upload.onprogress = (e) => opts.onProgress?.(e.loaded, e.lengthComputable ? e.total : total);
941
+ }
942
+ xhr.onload = () => {
943
+ done();
944
+ if (xhr.status >= 200 && xhr.status < 300) {
945
+ opts.onProgress?.(total, total);
946
+ resolve(typeof xhr.responseText === "string" ? xhr.responseText : "");
947
+ } else {
948
+ reject(new ChatError(`The upload failed (${xhr.status})`, refusal(xhr.status), "upload"));
949
+ }
950
+ };
951
+ xhr.onerror = () => {
952
+ done();
953
+ reject(new ChatError("The upload didn't reach the server", void 0, "upload"));
954
+ };
955
+ xhr.onabort = () => {
956
+ done();
957
+ reject(aborted());
958
+ };
959
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
960
+ xhr.send(body);
961
+ });
962
+ }
963
+
964
+ export { ALLOWED_EXTENSIONS, CHAT_BUCKET, CHAT_LIMITS, CHAT_SCHEMA, ChatError, MAX_RANGES_PER_DAY, PLAYBACK_RATES, WEEKDAYS, attachmentKindFor, awayLine, awaySummary, conversationTopic, createChatClient, fileExtension, formatDuration, hoursSummary, isAllowedExtension, isAway, isWithinWorkingHours, localClock, minutesOf, namesLine, nextPlaybackRate, normalizeHours, randomId, recordingLimitMs, sendBytes, timeZoneLabel, toAttachment, toAvailability, toLinkPreview, toMessage, toScheduled, toSearchHit, toTopic, untilLabel, userTopic, waveformFromMetering, waveformPeaks };
965
+ //# sourceMappingURL=chunk-GBJ3YI4E.js.map
966
+ //# sourceMappingURL=chunk-GBJ3YI4E.js.map