@marktiderman/genesis-chat 0.1.1 → 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
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @feat:chat
3
+ * Pure thread logic, the same on every platform: merging the server's messages
4
+ * with ones still on their way, grouping a run of messages from one sender,
5
+ * day dividers, the unread line, reactions by emoji, and the typing line.
6
+ */
7
+ import type { ChatActivity, ChatMessage, PendingMessage } from "./types";
8
+
9
+ export type ThreadItem =
10
+ | { type: "message"; message: ChatMessage; pending?: undefined }
11
+ | { type: "pending"; pending: PendingMessage; message?: undefined };
12
+
13
+ /**
14
+ * The server's messages (by seq), then what is still sending, oldest first. A
15
+ * pending message the server already has (same clientId) is dropped, so a send
16
+ * that landed never shows twice.
17
+ */
18
+ export function mergeThread(server: ChatMessage[], pending: PendingMessage[]): ThreadItem[] {
19
+ const byId = new Map<string, ChatMessage>();
20
+ for (const m of server) byId.set(m.id, m);
21
+ const sorted = [...byId.values()].sort((a, b) => a.seq - b.seq);
22
+ const landed = new Set(sorted.map((m) => m.clientId));
23
+ const waiting = pending
24
+ .filter((p) => !landed.has(p.clientId))
25
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
26
+ return [
27
+ ...sorted.map((message) => ({ type: "message" as const, message })),
28
+ ...waiting.map((p) => ({ type: "pending" as const, pending: p })),
29
+ ];
30
+ }
31
+
32
+ /** Adds or replaces messages in a page, keeping it sorted and free of duplicates. */
33
+ export function upsertMessages(existing: ChatMessage[], incoming: ChatMessage[]): ChatMessage[] {
34
+ const byId = new Map(existing.map((m) => [m.id, m]));
35
+ for (const m of incoming) byId.set(m.id, m);
36
+ return [...byId.values()].sort((a, b) => a.seq - b.seq);
37
+ }
38
+
39
+ export function itemSender(item: ThreadItem): string | null {
40
+ return item.type === "message" ? item.message.senderId : item.pending.senderId;
41
+ }
42
+
43
+ export function itemTime(item: ThreadItem): string {
44
+ return item.type === "message" ? item.message.createdAt : item.pending.createdAt;
45
+ }
46
+
47
+ /** Runs of one sender within five minutes share a header (name, avatar). */
48
+ export function startsGroup(items: ThreadItem[], index: number, gapMs = 5 * 60_000): boolean {
49
+ if (index === 0) return true;
50
+ const prev = items[index - 1]!;
51
+ const cur = items[index]!;
52
+ if (cur.type === "message" && cur.message.kind === "system") return true;
53
+ if (prev.type === "message" && prev.message.kind === "system") return true;
54
+ if (itemSender(prev) !== itemSender(cur)) return true;
55
+ return Date.parse(itemTime(cur)) - Date.parse(itemTime(prev)) > gapMs || !sameDay(itemTime(prev), itemTime(cur));
56
+ }
57
+
58
+ export function sameDay(a: string, b: string): boolean {
59
+ const x = new Date(a);
60
+ const y = new Date(b);
61
+ return x.getFullYear() === y.getFullYear() && x.getMonth() === y.getMonth() && x.getDate() === y.getDate();
62
+ }
63
+
64
+ /** A day divider goes before this item. */
65
+ export function startsDay(items: ThreadItem[], index: number): boolean {
66
+ return index === 0 || !sameDay(itemTime(items[index - 1]!), itemTime(items[index]!));
67
+ }
68
+
69
+ /** "Today", "Yesterday", "Monday", or "12 Mar 2026". */
70
+ export function dayLabel(iso: string, now = new Date(), locale?: string): string {
71
+ const d = new Date(iso);
72
+ const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
73
+ const days = Math.round((startOf(now) - startOf(d)) / 86_400_000);
74
+ if (days === 0) return "Today";
75
+ if (days === 1) return "Yesterday";
76
+ if (days > 1 && days < 7) return d.toLocaleDateString(locale, { weekday: "long" });
77
+ return d.toLocaleDateString(locale, { day: "numeric", month: "short", year: d.getFullYear() === now.getFullYear() ? undefined : "numeric" });
78
+ }
79
+
80
+ /** The first message after what you had read, from someone else: where "New" goes. */
81
+ export function firstUnreadId(messages: ChatMessage[], lastReadSeq: number, me: string): string | null {
82
+ return messages.find((m) => m.seq > lastReadSeq && m.senderId !== me && !m.deletedAt)?.id ?? null;
83
+ }
84
+
85
+ export interface ReactionSummary {
86
+ emoji: string;
87
+ count: number;
88
+ mine: boolean;
89
+ userIds: string[];
90
+ }
91
+
92
+ /** Reactions by emoji, most first, then in first-used order. */
93
+ export function summarizeReactions(message: ChatMessage, me: string): ReactionSummary[] {
94
+ const order: string[] = [];
95
+ const by = new Map<string, string[]>();
96
+ for (const r of message.reactions) {
97
+ if (!by.has(r.emoji)) {
98
+ by.set(r.emoji, []);
99
+ order.push(r.emoji);
100
+ }
101
+ by.get(r.emoji)!.push(r.userId);
102
+ }
103
+ return order
104
+ .map((emoji) => ({ emoji, count: by.get(emoji)!.length, mine: by.get(emoji)!.includes(me), userIds: by.get(emoji)! }))
105
+ .sort((a, b) => b.count - a.count || order.indexOf(a.emoji) - order.indexOf(b.emoji));
106
+ }
107
+
108
+ /** Who is doing what now: activity older than six seconds has stopped. */
109
+ export function currentActivity(activity: ChatActivity[], now = Date.now(), staleMs = 6000): ChatActivity[] {
110
+ const latest = new Map<string, ChatActivity>();
111
+ for (const a of activity) {
112
+ if (now - a.at > staleMs) continue;
113
+ const prev = latest.get(a.userId);
114
+ if (!prev || prev.at < a.at) latest.set(a.userId, a);
115
+ }
116
+ return [...latest.values()];
117
+ }
118
+
119
+ /** "Sam is typing…", "Sam and Lee are typing…", "Sam is recording a voice message…". */
120
+ export function activityLine(activity: ChatActivity[], nameOf: (userId: string) => string): string | null {
121
+ if (activity.length === 0) return null;
122
+ const recording = activity.find((a) => a.activity !== "typing");
123
+ if (recording) {
124
+ return `${nameOf(recording.userId)} is recording ${recording.activity === "recording-video" ? "a video" : "a voice message"}…`;
125
+ }
126
+ const names = activity.map((a) => nameOf(a.userId));
127
+ if (names.length === 1) return `${names[0]} is typing…`;
128
+ if (names.length === 2) return `${names[0]} and ${names[1]} are typing…`;
129
+ return "Several people are typing…";
130
+ }
131
+
132
+ /** Sends activity at most once every `everyMs`. */
133
+ export function throttle(fn: () => void, everyMs = 3000): () => void {
134
+ let last = 0;
135
+ return () => {
136
+ const now = Date.now();
137
+ if (now - last >= everyMs) {
138
+ last = now;
139
+ fn();
140
+ }
141
+ };
142
+ }
143
+
144
+ /** The ids @mentioned in markdown written as `@[Name](user:<uuid>)`. */
145
+ export function parseMentions(body: string): string[] {
146
+ const ids = new Set<string>();
147
+ for (const m of body.matchAll(/@\[[^\]]{1,80}\]\(user:([0-9a-f-]{36})\)/gi)) ids.add(m[1]!.toLowerCase());
148
+ return [...ids];
149
+ }
150
+
151
+ /** A one-line preview: text, or what was attached. */
152
+ export function previewText(body: string, attachment: string | null | undefined, deleted = false): string {
153
+ if (deleted) return "Message deleted";
154
+ const text = body.replace(/@\[([^\]]+)\]\(user:[0-9a-f-]{36}\)/gi, "@$1").replace(/[*_`#>~]/g, "").trim();
155
+ if (text) return text.length > 140 ? `${text.slice(0, 139)}…` : text;
156
+ switch (attachment) {
157
+ case "image":
158
+ return "Photo";
159
+ case "voice":
160
+ return "Voice message";
161
+ case "video":
162
+ return "Video";
163
+ case "file":
164
+ return "File";
165
+ case null:
166
+ case undefined:
167
+ return "";
168
+ default:
169
+ return "Attachment";
170
+ }
171
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * @feat:chat
3
+ * The chat contract: what the genesis_chat schema (migrations 0001 and 0002) stores, in
4
+ * the app's words. Everything here is plain data, shared by web, native and
5
+ * the server.
6
+ */
7
+
8
+ /** owner, admin or member of a conversation; observer when the kind's observer check lets you read it. */
9
+ export type ChatRole = "owner" | "admin" | "member" | "observer";
10
+
11
+ /** Built in: image, file, voice, video, link. Apps add their own (form, lesson, worksheet…). */
12
+ export type AttachmentKind = "image" | "file" | "voice" | "video" | "link" | (string & {});
13
+
14
+ export type AttachmentProvider = "storage" | "mux" | "cloudflare" | "external" | "none";
15
+
16
+ export interface ChatAttachment {
17
+ id: string;
18
+ messageId: string;
19
+ position: number;
20
+ kind: AttachmentKind;
21
+ /** The app's reference for its own kinds, e.g. `{ formId, versionHash }`. */
22
+ ref: Record<string, unknown>;
23
+ provider: AttachmentProvider;
24
+ /** The object in the genesis-chat bucket (`<conversation>/<uploader>/<name>.<ext>`). */
25
+ path: string | null;
26
+ providerId: string | null;
27
+ playbackId: string | null;
28
+ url: string | null;
29
+ status: "pending" | "ready" | "failed";
30
+ name: string | null;
31
+ mime: string | null;
32
+ bytes: number | null;
33
+ width: number | null;
34
+ height: number | null;
35
+ durationMs: number | null;
36
+ /** Peaks 0–100, at most 128, drawn before the audio loads. */
37
+ waveform: number[] | null;
38
+ posterPath: string | null;
39
+ thumbhash: string | null;
40
+ transcript: string | null;
41
+ transcriptStatus: "pending" | "ready" | "failed" | "off" | null;
42
+ }
43
+
44
+ export interface ChatReaction {
45
+ emoji: string;
46
+ userId: string;
47
+ }
48
+
49
+ export interface ChatMessage {
50
+ id: string;
51
+ conversationId: string;
52
+ seq: number;
53
+ clientId: string;
54
+ senderId: string | null;
55
+ senderRole: ChatRole | "system";
56
+ kind: "text" | "system";
57
+ /** Markdown. Empty for a deleted message. */
58
+ body: string;
59
+ mentions: string[];
60
+ replyToId: string | null;
61
+ threadRootId: string | null;
62
+ threadCount: number;
63
+ threadLastAt: string | null;
64
+ topic: string | null;
65
+ visibility: "everyone" | "observers";
66
+ silent: boolean;
67
+ meta: Record<string, unknown>;
68
+ createdAt: string;
69
+ editedAt: string | null;
70
+ deletedAt: string | null;
71
+ /**
72
+ * What moderation said (migration 0002): `hidden` messages reach only their sender and the
73
+ * conversation's observers; `flagged` ones are visible to everyone and reported to observers.
74
+ */
75
+ moderation?: "flagged" | "hidden" | null;
76
+ attachments: ChatAttachment[];
77
+ reactions: ChatReaction[];
78
+ }
79
+
80
+ export interface ChatConversation {
81
+ id: string;
82
+ kind: string;
83
+ title: string | null;
84
+ subjectType: string | null;
85
+ subjectId: string | null;
86
+ settings: { who_posts?: "members" | "admins" } & Record<string, unknown>;
87
+ status: "open" | "pending" | "solved" | "closed" | null;
88
+ assigneeId: string | null;
89
+ lastSeq: number;
90
+ lastMessageAt: string | null;
91
+ createdAt: string;
92
+ archivedAt: string | null;
93
+ }
94
+
95
+ export interface ChatMember {
96
+ conversationId: string;
97
+ userId: string;
98
+ role: ChatRole;
99
+ lastReadSeq: number;
100
+ joinedAt: string;
101
+ }
102
+
103
+ /** A kind's rules, as genesis_chat.kinds holds them. */
104
+ export interface ChatKindRules {
105
+ kind: string;
106
+ label: string;
107
+ /** 2 for a direct conversation. */
108
+ maxMembers: number | null;
109
+ whoPosts: "members" | "admins";
110
+ membership: "app" | "admins";
111
+ threads: boolean;
112
+ observersPost: boolean;
113
+ }
114
+
115
+ /** The latest message, as the inbox previews it. */
116
+ export interface ChatPreview {
117
+ id: string;
118
+ seq: number;
119
+ senderId: string | null;
120
+ kind: "text" | "system";
121
+ body: string;
122
+ deleted: boolean;
123
+ createdAt: string;
124
+ attachment: AttachmentKind | null;
125
+ }
126
+
127
+ export interface ChatInboxItem {
128
+ conversationId: string;
129
+ kind: string;
130
+ title: string | null;
131
+ subjectType: string | null;
132
+ subjectId: string | null;
133
+ status: ChatConversation["status"];
134
+ role: ChatRole;
135
+ /** The other person, in a two-person kind. */
136
+ otherUserId: string | null;
137
+ memberCount: number;
138
+ lastSeq: number;
139
+ lastReadSeq: number;
140
+ /** Counted to 99. */
141
+ unread: number;
142
+ mentioned: boolean;
143
+ muted: boolean;
144
+ pinned: boolean;
145
+ archived: boolean;
146
+ lastMessageAt: string | null;
147
+ lastMessage: ChatPreview | null;
148
+ }
149
+
150
+ /** What a sender attaches; files are uploaded first (ChatClient.upload). */
151
+ export interface AttachmentInput {
152
+ kind: AttachmentKind;
153
+ path?: string;
154
+ provider?: AttachmentProvider;
155
+ providerId?: string;
156
+ url?: string;
157
+ ref?: Record<string, unknown>;
158
+ status?: "pending" | "ready";
159
+ name?: string;
160
+ mime?: string;
161
+ bytes?: number;
162
+ width?: number;
163
+ height?: number;
164
+ durationMs?: number;
165
+ waveform?: number[];
166
+ posterPath?: string;
167
+ thumbhash?: string;
168
+ }
169
+
170
+ export interface SendInput {
171
+ conversationId: string;
172
+ /** Made once per message on the device, so a retry never posts twice. */
173
+ clientId: string;
174
+ body?: string;
175
+ attachments?: AttachmentInput[];
176
+ replyToId?: string | null;
177
+ threadRootId?: string | null;
178
+ topic?: string | null;
179
+ mentions?: string[];
180
+ visibility?: "everyone" | "observers";
181
+ silent?: boolean;
182
+ }
183
+
184
+ /** A message on its way: shown at once, swapped for the server's when it lands. */
185
+ export interface PendingMessage {
186
+ clientId: string;
187
+ input: SendInput;
188
+ senderId: string;
189
+ createdAt: string;
190
+ status: "sending" | "failed";
191
+ error?: string;
192
+ /** The server refused it (not allowed, bad data): only a manual retry sends it again. */
193
+ permanent?: boolean;
194
+ /** Local previews for files still uploading (object URLs or file URIs). */
195
+ previews?: Record<number, string>;
196
+ /** What each file is, in order, so a pending bubble can draw it (a voice note's duration and waveform, a file's name). */
197
+ files?: PendingFile[];
198
+ }
199
+
200
+ export interface PendingFile {
201
+ name: string;
202
+ contentType: string;
203
+ kind: AttachmentKind;
204
+ meta?: Partial<AttachmentInput>;
205
+ /** Not kept between launches (bytes with nowhere to save them): attach it again. */
206
+ lost?: boolean;
207
+ }
208
+
209
+ /** A live hint from Realtime: something changed; fetch to see what. */
210
+ export type ChatSignal =
211
+ | { type: "message"; conversationId: string; id: string; seq?: number; op: "insert" | "update" }
212
+ | { type: "reaction"; conversationId: string; messageId: string }
213
+ | { type: "members"; conversationId: string }
214
+ | { type: "conversation"; conversationId: string }
215
+ | { type: "inbox"; conversationId: string; seq?: number };
216
+
217
+ /** Ephemeral, never stored: someone typing or recording. */
218
+ export interface ChatActivity {
219
+ userId: string;
220
+ activity: "typing" | "recording-voice" | "recording-video";
221
+ at: number;
222
+ }
223
+
224
+ // ── Migration 0002: topics, scheduled send, availability, search, link previews ──
225
+
226
+ /** A named sub-thread of a conversation (a cohort's lesson threads). Messages join one through `topic`. */
227
+ export interface ChatTopic {
228
+ conversationId: string;
229
+ /** The key messages carry in `topic`. */
230
+ topic: string;
231
+ title: string;
232
+ position: number;
233
+ /** Set when closed: only owners, admins and observers post in it then. */
234
+ closedAt: string | null;
235
+ createdBy: string | null;
236
+ createdAt: string;
237
+ }
238
+
239
+ /** What `setTopic` changes; anything left out keeps its value (a new topic's title is its key). */
240
+ export interface TopicInput {
241
+ title?: string;
242
+ position?: number;
243
+ closed?: boolean;
244
+ }
245
+
246
+ /** A message to send later, as its sender. Only its sender reads it. */
247
+ export interface ScheduledMessage {
248
+ id: string;
249
+ conversationId: string;
250
+ clientId: string;
251
+ body: string;
252
+ attachments: AttachmentInput[];
253
+ replyToId: string | null;
254
+ topic: string | null;
255
+ mentions: string[];
256
+ silent: boolean;
257
+ sendAt: string;
258
+ sentMessageId: string | null;
259
+ cancelledAt: string | null;
260
+ /** Why it couldn't be sent when due (the sender left the conversation, say). */
261
+ error: string | null;
262
+ createdAt: string;
263
+ status: "scheduled" | "sent" | "cancelled" | "failed";
264
+ }
265
+
266
+ /** Between a minute and a year ahead. Files in `attachments` are uploaded first, as for `send`. */
267
+ export interface ScheduleInput extends Omit<SendInput, "threadRootId" | "visibility"> {
268
+ sendAt: string | Date;
269
+ }
270
+
271
+ export type Weekday = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun";
272
+
273
+ /** Working hours per weekday as `[["09:00","17:00"]]`, 24-hour local times; a missing day is not working. An end at or before its start runs past midnight. */
274
+ export type WorkingHours = Partial<Record<Weekday, [string, string][]>>;
275
+
276
+ /** A person's away message and working hours. */
277
+ export interface ChatAvailability {
278
+ userId: string;
279
+ awayMessage: string | null;
280
+ /** Away until then; with an away message and no date, away until they clear it. */
281
+ awayUntil: string | null;
282
+ /** An IANA zone (`America/New_York`); hours are read in it. */
283
+ timeZone: string | null;
284
+ hours: WorkingHours | null;
285
+ }
286
+
287
+ export type AvailabilityInput = Partial<Omit<ChatAvailability, "userId">>;
288
+
289
+ /** One search result. `snippet` marks the matched words with « and » (the text's own « and » arrive as ‹ and ›, so marks are always real). */
290
+ export interface ChatSearchHit {
291
+ messageId: string;
292
+ conversationId: string;
293
+ seq: number;
294
+ senderId: string | null;
295
+ createdAt: string;
296
+ snippet: string;
297
+ /** Found in a voice note's or video's transcript, not the text. */
298
+ inTranscript: boolean;
299
+ }
300
+
301
+ /** Open Graph data for a link (fetched by the server's runner). Plain text: never render it as HTML. */
302
+ export interface ChatLinkPreview {
303
+ url: string;
304
+ status: "ok" | "failed" | "blocked";
305
+ title: string | null;
306
+ description: string | null;
307
+ /** Always https when present. */
308
+ imageUrl: string | null;
309
+ siteName: string | null;
310
+ fetchedAt: string;
311
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * @feat:chat
3
+ * @marktiderman/genesis-chat: conversations, messages, media and live updates
4
+ * in the app's own Supabase. The core has no React; `/react` adds hooks,
5
+ * `/web` and `/native` render on Genesis UI, `/server` works the outbox.
6
+ */
7
+ export * from "./core/types";
8
+ export * from "./core/client";
9
+ export * from "./core/media";
10
+ export * from "./core/thread";
11
+ export * from "./core/outbox";
12
+ export * from "./core/availability";
13
+ export * from "./core/extras";
@@ -0,0 +1,128 @@
1
+ // @vitest-environment happy-dom
2
+ // @feat:chat
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { act, cleanup, render, waitFor } from "@testing-library/react";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import type { ChatClient } from "../../core/client";
7
+ import type { OutboxStore, QueuedMessage } from "../../core/outbox";
8
+ import { ChatProvider } from "../../react/index";
9
+ import { ChatAutoRetry, isOnline, type AppStateLike } from "../auto-retry";
10
+ import { ChatNativeProvider } from "../context";
11
+ import type { NetInfoLike } from "../modules";
12
+
13
+ afterEach(cleanup);
14
+
15
+ function fakes() {
16
+ let appListener: ((s: string) => void) | null = null;
17
+ let netListener: ((s: { isConnected: boolean | null; isInternetReachable?: boolean | null }) => void) | null = null;
18
+ const appState: AppStateLike = {
19
+ currentState: "active",
20
+ addEventListener: (_t, l) => {
21
+ appListener = l;
22
+ return { remove: () => (appListener = null) };
23
+ },
24
+ };
25
+ const netInfo: NetInfoLike = {
26
+ addEventListener: (l) => {
27
+ netListener = l;
28
+ // NetInfo reports the current state as soon as you subscribe.
29
+ l({ isConnected: true, isInternetReachable: true });
30
+ return () => (netListener = null);
31
+ },
32
+ };
33
+ return {
34
+ appState,
35
+ netInfo,
36
+ app: (s: string) => appListener?.(s),
37
+ net: (online: boolean) => netListener?.({ isConnected: online, isInternetReachable: online }),
38
+ unsubscribed: () => appListener === null && netListener === null,
39
+ };
40
+ }
41
+
42
+ /** One message that failed to send, waiting in the saved outbox; the server keeps refusing it. */
43
+ function failing(extra: Partial<QueuedMessage> = {}) {
44
+ const queued: QueuedMessage = {
45
+ clientId: "q1",
46
+ input: { conversationId: "c1", clientId: "q1", body: "hello", replyToId: null, mentions: [] } as QueuedMessage["input"],
47
+ senderId: "u1",
48
+ createdAt: "2026-10-25T10:00:00Z",
49
+ status: "failed",
50
+ files: [],
51
+ attempts: 1,
52
+ ...extra,
53
+ };
54
+ let saved = [queued];
55
+ const store: OutboxStore = { load: async () => saved, save: async (q) => void (saved = q) };
56
+ const send = vi.fn(async () => {
57
+ throw new Error("offline");
58
+ });
59
+ const client = { send, subscribeInbox: () => ({ unsubscribe: () => undefined }) } as unknown as ChatClient;
60
+ return { store, client, send };
61
+ }
62
+
63
+ function mount(f: ReturnType<typeof fakes>, o: ReturnType<typeof failing>) {
64
+ return render(
65
+ <QueryClientProvider client={new QueryClient()}>
66
+ <ChatProvider client={o.client} userId="u1" outboxStore={o.store}>
67
+ <ChatNativeProvider modules={{ netInfo: f.netInfo }}>
68
+ <ChatAutoRetry appState={f.appState} />
69
+ </ChatNativeProvider>
70
+ </ChatProvider>
71
+ </QueryClientProvider>,
72
+ );
73
+ }
74
+
75
+ describe("ChatAutoRetry", () => {
76
+ it("retries unsent messages when the app comes back to the front", async () => {
77
+ const f = fakes();
78
+ const o = failing();
79
+ mount(f, o);
80
+ // The provider's own retry of what the last launch left.
81
+ await waitFor(() => expect(o.send).toHaveBeenCalledTimes(1));
82
+ await act(async () => f.app("active"));
83
+ expect(o.send).toHaveBeenCalledTimes(1);
84
+ await act(async () => f.app("background"));
85
+ await act(async () => f.app("active"));
86
+ await waitFor(() => expect(o.send).toHaveBeenCalledTimes(2));
87
+ });
88
+
89
+ it("retries when the connection comes back, not on NetInfo's first report", async () => {
90
+ const f = fakes();
91
+ const o = failing();
92
+ mount(f, o);
93
+ await waitFor(() => expect(o.send).toHaveBeenCalledTimes(1));
94
+ await act(async () => f.net(true));
95
+ expect(o.send).toHaveBeenCalledTimes(1);
96
+ await act(async () => f.net(false));
97
+ await act(async () => f.net(true));
98
+ await waitFor(() => expect(o.send).toHaveBeenCalledTimes(2));
99
+ });
100
+
101
+ it("leaves alone a message the server refused for good", async () => {
102
+ const f = fakes();
103
+ const o = failing({ permanent: true, error: "You can't post here" });
104
+ mount(f, o);
105
+ await act(async () => f.app("background"));
106
+ await act(async () => f.app("active"));
107
+ await act(async () => f.net(false));
108
+ await act(async () => f.net(true));
109
+ expect(o.send).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it("stops listening when unmounted", async () => {
113
+ const f = fakes();
114
+ const o = failing();
115
+ const view = mount(f, o);
116
+ view.unmount();
117
+ expect(f.unsubscribed()).toBe(true);
118
+ });
119
+ });
120
+
121
+ describe("isOnline", () => {
122
+ it("counts unknown as online, so only a real offline waits", () => {
123
+ expect(isOnline({ isConnected: true, isInternetReachable: true })).toBe(true);
124
+ expect(isOnline({ isConnected: null, isInternetReachable: null })).toBe(true);
125
+ expect(isOnline({ isConnected: false })).toBe(false);
126
+ expect(isOnline({ isConnected: true, isInternetReachable: false })).toBe(false);
127
+ });
128
+ });