@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.
Files changed (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.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-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -1,3 +1,161 @@
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
+
1
159
  // src/core/media.ts
2
160
  function randomId() {
3
161
  const c = globalThis.crypto;
@@ -147,6 +305,14 @@ var ChatError = class extends Error {
147
305
  get transient() {
148
306
  return this.code === void 0 || this.code === "" || /^(08|57|53)/.test(this.code) || this.code === "PGRST000";
149
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
+ }
150
316
  };
151
317
  function must(operation, result) {
152
318
  if (result.error) throw new ChatError(result.error.message, result.error.code, operation);
@@ -204,6 +370,7 @@ function toMessage(r) {
204
370
  createdAt: String(r.created_at),
205
371
  editedAt: str(r.edited_at),
206
372
  deletedAt: str(r.deleted_at),
373
+ moderation: r.moderation === "hidden" || r.moderation === "flagged" ? r.moderation : null,
207
374
  attachments,
208
375
  reactions
209
376
  };
@@ -256,6 +423,94 @@ function toInboxItem(r) {
256
423
  } : null
257
424
  };
258
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
+ };
501
+ }
502
+ function toLinkPreview(r) {
503
+ const image = str(r.image_url);
504
+ return {
505
+ url: String(r.url),
506
+ status: r.status ?? "ok",
507
+ title: str(r.title),
508
+ description: str(r.description),
509
+ imageUrl: image && /^https:\/\//i.test(image) ? image : null,
510
+ siteName: str(r.site_name),
511
+ fetchedAt: String(r.fetched_at)
512
+ };
513
+ }
259
514
  var MESSAGE_COLUMNS = "*, attachments(*), reactions(emoji, user_id)";
260
515
  function createChatClient(supabase, options = {}) {
261
516
  const db = () => supabase.schema(CHAT_SCHEMA);
@@ -277,20 +532,24 @@ function createChatClient(supabase, options = {}) {
277
532
  }
278
533
  return {
279
534
  supabase,
535
+ /** Your conversations: one `kind`, several `kinds`, or all but `excludeKinds` (e.g. Messages without `support`). */
280
536
  async inbox(opts = {}) {
281
537
  const rows = await rpc("inbox", {
282
538
  p_kind: opts.kind ?? null,
283
539
  p_archived: opts.archived ?? false,
284
540
  p_limit: opts.limit ?? 50,
285
- p_before: opts.before ?? null
541
+ p_before: opts.before ?? null,
542
+ ...opts.kinds?.length ? { p_kinds: opts.kinds } : {},
543
+ ...opts.excludeKinds?.length ? { p_exclude_kinds: opts.excludeKinds } : {}
286
544
  });
287
545
  return rows.map(toInboxItem);
288
546
  },
289
547
  async observed(kind, opts = {}) {
290
548
  return rpc("observed", { p_kind: kind, p_status: opts.status ?? null, p_limit: opts.limit ?? 50, p_before: opts.before ?? null });
291
549
  },
292
- async unreadTotal() {
293
- return rpc("unread_total", {});
550
+ /** Unread across your conversations (for a badge), leaving out `excludeKinds` to match an inbox that does. */
551
+ async unreadTotal(opts = {}) {
552
+ return rpc("unread_total", opts.excludeKinds?.length ? { p_exclude_kinds: opts.excludeKinds } : {});
294
553
  },
295
554
  async conversation(id) {
296
555
  const row = must("conversation", await db().from("conversations").select("*").eq("id", id).maybeSingle());
@@ -423,6 +682,115 @@ function createChatClient(supabase, options = {}) {
423
682
  async report(messageId, reason, note) {
424
683
  return rpc("report_message", { p_message_id: messageId, p_reason: reason, p_note: note ?? null });
425
684
  },
685
+ // ── Migration 0002 ──────────────────────────────────────────────────────
686
+ /** A conversation's topics, in order (position, then key). */
687
+ async topics(conversationId) {
688
+ const rows = must(
689
+ "topics",
690
+ await db().from("topics").select("*").eq("conversation_id", conversationId).order("position", { ascending: true }).order("topic", { ascending: true })
691
+ );
692
+ return rows.map(toTopic);
693
+ },
694
+ /** Adds a topic, or renames, reorders, closes (`closed: true`) or reopens it. Owners and admins. */
695
+ async setTopic(conversationId, topic, input = {}) {
696
+ await rpc("set_topic", {
697
+ p_conversation_id: conversationId,
698
+ p_topic: topic,
699
+ p_title: input.title ?? null,
700
+ p_position: input.position ?? null,
701
+ p_closed: input.closed ?? null
702
+ });
703
+ },
704
+ /** Schedules a message (a minute to a year ahead) and returns its id. Safe to retry with the same clientId. */
705
+ async schedule(input) {
706
+ const sendAt = typeof input.sendAt === "string" ? input.sendAt : input.sendAt.toISOString();
707
+ return rpc("schedule_message", {
708
+ p_conversation_id: input.conversationId,
709
+ p_client_id: input.clientId,
710
+ p_send_at: sendAt,
711
+ p_body: input.body ?? "",
712
+ p_attachments: (input.attachments ?? []).map(attachmentArg),
713
+ p_reply_to_id: input.replyToId ?? null,
714
+ p_topic: input.topic ?? null,
715
+ p_mentions: input.mentions ?? [],
716
+ p_silent: input.silent ?? false
717
+ });
718
+ },
719
+ /** Your messages still waiting to go (and ones that failed when due), soonest first; in one conversation or all. */
720
+ async scheduled(conversationId) {
721
+ let q = db().from("scheduled").select("*").is("sent_message_id", null).is("cancelled_at", null);
722
+ if (conversationId) q = q.eq("conversation_id", conversationId);
723
+ const rows = must("scheduled", await q.order("send_at", { ascending: true }).limit(200));
724
+ return rows.map(toScheduled);
725
+ },
726
+ async cancelScheduled(id) {
727
+ await rpc("cancel_scheduled", { p_id: id });
728
+ },
729
+ /** The away message and hours of a conversation's members who set them (not observers). */
730
+ async availabilityFor(conversationId) {
731
+ const rows = await rpc("availability_for", { p_conversation_id: conversationId });
732
+ return (rows ?? []).map(toAvailability);
733
+ },
734
+ /** Your own away message and hours, or null when never set. */
735
+ async myAvailability() {
736
+ const row = must("myAvailability", await db().from("availability").select("*").maybeSingle());
737
+ return row ? toAvailability(row) : null;
738
+ },
739
+ /** Replaces your away message and hours (a field left out is cleared). */
740
+ async setAvailability(input) {
741
+ const until = input.awayUntil ?? null;
742
+ await rpc("set_availability", {
743
+ p_away_message: input.awayMessage ?? null,
744
+ p_away_until: until,
745
+ p_time_zone: input.timeZone ?? null,
746
+ p_hours: input.hours ?? null
747
+ });
748
+ },
749
+ /**
750
+ * Sends one text message into each of these conversations where you may post (a coach to
751
+ * each client's direct conversation), so replies come back privately. Text only. Safe to
752
+ * retry with the same clientId. Returns the ids of the messages sent.
753
+ */
754
+ async broadcast(conversationIds, clientId, body, opts = {}) {
755
+ const ids = await rpc("broadcast_message", {
756
+ p_conversation_ids: conversationIds,
757
+ p_client_id: clientId,
758
+ p_body: body,
759
+ p_attachments: [],
760
+ p_silent: opts.silent ?? false
761
+ });
762
+ return (ids ?? []).map(String);
763
+ },
764
+ /** Messages (and transcripts) you can read that match, newest first: in one conversation or all of yours. Two characters at least. */
765
+ async search(query, conversationId, opts = {}) {
766
+ if (query.trim().length < 2) return [];
767
+ const rows = await rpc("search_messages", {
768
+ p_query: query,
769
+ p_conversation_id: conversationId ?? null,
770
+ p_limit: opts.limit ?? 30
771
+ });
772
+ return (rows ?? []).map(toSearchHit);
773
+ },
774
+ /**
775
+ * The previews the server has fetched for the links in these messages, by message id (only
776
+ * `ok` ones; links not fetched yet are left out). Read through link_previews_for, one call
777
+ * per message (up to 6 at once), which answers only for messages you can read: the preview
778
+ * table itself is closed, since a URL can carry a secret.
779
+ */
780
+ async linkPreviewsFor(messageIds) {
781
+ const ids = [...new Set(messageIds.filter(Boolean))].slice(0, 200);
782
+ const out = {};
783
+ let next = 0;
784
+ const worker = async () => {
785
+ while (next < ids.length) {
786
+ const id = ids[next++];
787
+ const rows = await rpc("link_previews_for", { p_message_id: id });
788
+ out[id] = (rows ?? []).map(toLinkPreview).filter((p) => p.status === "ok");
789
+ }
790
+ };
791
+ await Promise.all(Array.from({ length: Math.min(6, ids.length) }, worker));
792
+ return out;
793
+ },
426
794
  /**
427
795
  * Uploads a file into the conversation's folder for the signed-in person and
428
796
  * returns its path, ready to attach. Resumable for big files is the native
@@ -468,8 +836,10 @@ function createChatClient(supabase, options = {}) {
468
836
  return { unsubscribe: join(channel) };
469
837
  },
470
838
  /**
471
- * A conversation's live hints, plus who is typing or recording. `onReconnect`
472
- * runs whenever the channel (re)joins, the moment to fetch what was missed.
839
+ * A conversation's live hints, plus who is typing or recording. `onJoin` runs every
840
+ * time the channel joins, the first time included: the moment to fetch what arrived
841
+ * before the subscription was live. `onReconnect` (kept for older callers) runs on
842
+ * re-joins only.
473
843
  */
474
844
  subscribeConversation(conversationId, me, handlers) {
475
845
  let joinedOnce = false;
@@ -484,14 +854,18 @@ function createChatClient(supabase, options = {}) {
484
854
  ).on("broadcast", { event: "members" }, () => handlers.onSignal({ type: "members", conversationId })).on("broadcast", { event: "conversation" }, () => handlers.onSignal({ type: "conversation", conversationId })).on("broadcast", { event: "activity" }, ({ payload }) => {
485
855
  if (payload?.userId && payload.userId !== me) handlers.onActivity?.({ userId: payload.userId, activity: payload.activity, at: Date.now() });
486
856
  });
857
+ let joined = false;
487
858
  const leave = join(channel, (status) => {
859
+ joined = status === "SUBSCRIBED";
488
860
  if (status === "SUBSCRIBED") {
861
+ handlers.onJoin?.();
489
862
  if (joinedOnce) handlers.onReconnect?.();
490
863
  joinedOnce = true;
491
864
  }
492
865
  });
493
866
  return {
494
867
  signal(activity) {
868
+ if (!joined) return;
495
869
  void channel.send({ type: "broadcast", event: "activity", payload: { userId: me, activity } });
496
870
  },
497
871
  unsubscribe: leave
@@ -520,6 +894,6 @@ function attachmentArg(a) {
520
894
  };
521
895
  }
522
896
 
523
- export { ALLOWED_EXTENSIONS, CHAT_BUCKET, CHAT_LIMITS, CHAT_SCHEMA, ChatError, PLAYBACK_RATES, attachmentKindFor, conversationTopic, createChatClient, fileExtension, formatDuration, isAllowedExtension, nextPlaybackRate, randomId, recordingLimitMs, toAttachment, toMessage, userTopic, waveformFromMetering, waveformPeaks };
524
- //# sourceMappingURL=chunk-JQXZUEEL.js.map
525
- //# sourceMappingURL=chunk-JQXZUEEL.js.map
897
+ 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, timeZoneLabel, toAttachment, toAvailability, toLinkPreview, toMessage, toScheduled, toSearchHit, toTopic, untilLabel, userTopic, waveformFromMetering, waveformPeaks };
898
+ //# sourceMappingURL=chunk-7S6HYNWW.js.map
899
+ //# sourceMappingURL=chunk-7S6HYNWW.js.map