@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
@@ -0,0 +1,55 @@
1
+ /**
2
+ * @feat:chat
3
+ * Small formatting helpers for the web renderers: times, sizes, and the
4
+ * one-line description of a message a screen reader hears.
5
+ */
6
+ import { previewText } from "../core/thread";
7
+ import type { ChatMessage } from "../core/types";
8
+
9
+ /** A person as the app names them. The package knows no people tables: the app supplies this. */
10
+ export interface ChatUser {
11
+ name: string;
12
+ avatarUrl?: string | null;
13
+ }
14
+
15
+ export type ResolveUser = (userId: string) => ChatUser;
16
+
17
+ /** 10:42 (in the reader's locale). */
18
+ export function clockTime(iso: string, locale?: string): string {
19
+ return new Date(iso).toLocaleTimeString(locale, { hour: "numeric", minute: "2-digit" });
20
+ }
21
+
22
+ /** The inbox's time: "now", "5m", "10:42" today, "Yesterday", "Mon", or "12 Mar". */
23
+ export function relativeTime(iso: string | null | undefined, now = new Date(), locale?: string): string {
24
+ if (!iso) return "";
25
+ const d = new Date(iso);
26
+ const diff = now.getTime() - d.getTime();
27
+ if (diff < 60_000) return "now";
28
+ if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m`;
29
+ const startOf = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
30
+ const days = Math.round((startOf(now) - startOf(d)) / 86_400_000);
31
+ if (days === 0) return clockTime(iso, locale);
32
+ if (days === 1) return "Yesterday";
33
+ if (days > 1 && days < 7) return d.toLocaleDateString(locale, { weekday: "short" });
34
+ return d.toLocaleDateString(locale, {
35
+ day: "numeric",
36
+ month: "short",
37
+ year: d.getFullYear() === now.getFullYear() ? undefined : "numeric",
38
+ });
39
+ }
40
+
41
+ /** 12 KB, 3.4 MB. */
42
+ export function formatBytes(bytes: number | null | undefined): string {
43
+ if (bytes == null || !Number.isFinite(bytes)) return "";
44
+ if (bytes < 1024) return `${bytes} B`;
45
+ if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
46
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
47
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
48
+ }
49
+
50
+ /** What a screen reader hears for one bubble: "Sam, 10:42: See you then". */
51
+ export function messageLabel(message: Pick<ChatMessage, "body" | "createdAt" | "deletedAt" | "attachments" | "editedAt" | "visibility">, sender: string, locale?: string): string {
52
+ const what = previewText(message.body, message.attachments[0]?.kind ?? null, Boolean(message.deletedAt)) || "Message";
53
+ const extra = [message.visibility === "observers" ? "internal note" : null, message.editedAt && !message.deletedAt ? "edited" : null].filter(Boolean);
54
+ return `${sender}, ${clockTime(message.createdAt, locale)}: ${what}${extra.length ? ` (${extra.join(", ")})` : ""}`;
55
+ }
@@ -0,0 +1,195 @@
1
+ /**
2
+ * @feat:chat
3
+ * The inbox: your conversations, newest first, each a button with an avatar,
4
+ * the title (the other person's name in a two-person conversation), a one-line
5
+ * preview, the time, an unread count, an @ when you were mentioned, and muted
6
+ * and pinned marks. Arrow keys move between rows.
7
+ */
8
+ import { useRef, type KeyboardEvent } from "react";
9
+ import { AtSign, BellOff, MessageSquare, Pin } from "lucide-react";
10
+ import { Badge, Button, EmptyState, Skeleton, UserAvatar, cn } from "@marktiderman/genesis-ui";
11
+ import { renderSlot, type SlotOverride } from "@marktiderman/genesis-core/slots";
12
+ import { previewText } from "../core/thread";
13
+ import type { ChatInboxItem } from "../core/types";
14
+ import { useChat, useInbox } from "../react/index";
15
+ import { relativeTime, type ResolveUser } from "./format";
16
+
17
+ export interface ChatInboxEmptyProps {
18
+ archived: boolean;
19
+ }
20
+
21
+ export interface ChatInboxProps {
22
+ /** Names people (the app knows them; the package doesn't). */
23
+ resolveUser: ResolveUser;
24
+ /** Called with a conversation's id when its row is chosen. */
25
+ onSelect: (conversationId: string) => void;
26
+ /** The open conversation, marked as current. */
27
+ selectedId?: string | null;
28
+ /** Only this kind of conversation. */
29
+ kind?: string;
30
+ /** The archived conversations instead. */
31
+ archived?: boolean;
32
+ /** A conversation's title when it has none and isn't two-person. */
33
+ fallbackTitle?: (item: ChatInboxItem) => string;
34
+ /** Replace or eject (`null`) the empty state. */
35
+ slots?: { empty?: SlotOverride<ChatInboxEmptyProps> };
36
+ "aria-label"?: string;
37
+ className?: string;
38
+ }
39
+
40
+ function DefaultEmpty({ archived }: ChatInboxEmptyProps) {
41
+ return (
42
+ <EmptyState
43
+ icon={MessageSquare}
44
+ title={archived ? "No archived conversations" : "No conversations yet"}
45
+ description={archived ? undefined : "When someone messages you, it shows up here."}
46
+ />
47
+ );
48
+ }
49
+
50
+ /** The title an inbox row shows. */
51
+ export function inboxTitle(item: ChatInboxItem, resolveUser: ResolveUser, fallback?: (item: ChatInboxItem) => string): string {
52
+ if (item.title) return item.title;
53
+ if (item.otherUserId) return resolveUser(item.otherUserId).name;
54
+ return fallback?.(item) ?? "Conversation";
55
+ }
56
+
57
+ export function ChatInbox({ resolveUser, onSelect, selectedId, kind, archived = false, fallbackTitle, slots, className, ...rest }: ChatInboxProps) {
58
+ const { userId: me } = useChat();
59
+ const { items, isLoading, error, refetch } = useInbox({ kind, archived });
60
+ const list = useRef<HTMLUListElement>(null);
61
+
62
+ // Rows are native buttons (Tab reaches each); the arrows are added here
63
+ // because no Radix primitive is a list of buttons with vertical roving focus.
64
+ const onKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {
65
+ const buttons = Array.from(list.current?.querySelectorAll<HTMLButtonElement>("button[data-inbox-row]") ?? []);
66
+ const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
67
+ const to =
68
+ e.key === "ArrowDown" ? Math.min(buttons.length - 1, at + 1) : e.key === "ArrowUp" ? Math.max(0, at - 1) : e.key === "Home" ? 0 : e.key === "End" ? buttons.length - 1 : -2;
69
+ if (to === -2 || buttons.length === 0) return;
70
+ e.preventDefault();
71
+ buttons[to < 0 ? 0 : to]?.focus();
72
+ };
73
+
74
+ if (isLoading) {
75
+ return (
76
+ <div className={cn("flex flex-col", className)} aria-busy aria-label="Loading conversations" data-testid="genesis-chat-inbox-loading">
77
+ {Array.from({ length: 6 }, (_, i) => (
78
+ <div key={i} className="flex items-center gap-3 px-3 py-3">
79
+ <Skeleton className="size-10 shrink-0 rounded-full" />
80
+ <div className="flex-1 space-y-2">
81
+ <Skeleton className="h-3.5 w-2/5" />
82
+ <Skeleton className="h-3 w-4/5" />
83
+ </div>
84
+ </div>
85
+ ))}
86
+ </div>
87
+ );
88
+ }
89
+
90
+ if (error) {
91
+ return (
92
+ <div className={className}>
93
+ <EmptyState
94
+ icon={MessageSquare}
95
+ title="Couldn't load your conversations"
96
+ description="Check your connection and try again."
97
+ action={
98
+ <Button type="button" variant="outline" onClick={() => void refetch()}>
99
+ Try again
100
+ </Button>
101
+ }
102
+ />
103
+ </div>
104
+ );
105
+ }
106
+
107
+ if (items.length === 0) {
108
+ return <div className={className}>{renderSlot(DefaultEmpty, slots?.empty, { archived })}</div>;
109
+ }
110
+
111
+ return (
112
+ <nav aria-label={rest["aria-label"] ?? "Conversations"} className={cn("min-h-0", className)} data-testid="genesis-chat-inbox">
113
+ <ul ref={list} className="flex flex-col" onKeyDown={onKeyDown}>
114
+ {items.map((item) => (
115
+ <li key={item.conversationId}>
116
+ <InboxRow item={item} me={me} resolveUser={resolveUser} fallbackTitle={fallbackTitle} selected={item.conversationId === selectedId} onSelect={onSelect} />
117
+ </li>
118
+ ))}
119
+ </ul>
120
+ </nav>
121
+ );
122
+ }
123
+
124
+ interface InboxRowProps {
125
+ item: ChatInboxItem;
126
+ me: string;
127
+ resolveUser: ResolveUser;
128
+ fallbackTitle?: (item: ChatInboxItem) => string;
129
+ selected: boolean;
130
+ onSelect: (id: string) => void;
131
+ }
132
+
133
+ function InboxRow({ item, me, resolveUser, fallbackTitle, selected, onSelect }: InboxRowProps) {
134
+ const title = inboxTitle(item, resolveUser, fallbackTitle);
135
+ const other = item.otherUserId ? resolveUser(item.otherUserId) : null;
136
+ const last = item.lastMessage;
137
+ const unread = item.unread > 0;
138
+ let preview = last ? previewText(last.body, last.attachment, last.deleted) : "";
139
+ if (last && last.kind !== "system" && !last.deleted && last.senderId) {
140
+ if (last.senderId === me) preview = `You: ${preview}`;
141
+ else if (!item.otherUserId && item.memberCount > 2) preview = `${resolveUser(last.senderId).name}: ${preview}`;
142
+ }
143
+ const time = relativeTime(item.lastMessageAt ?? last?.createdAt);
144
+ const count = item.unread >= 99 ? "99+" : String(item.unread);
145
+ const label = [
146
+ title,
147
+ unread ? `${count} unread` : null,
148
+ item.mentioned ? "you were mentioned" : null,
149
+ item.muted ? "muted" : null,
150
+ item.pinned ? "pinned" : null,
151
+ preview || null,
152
+ time || null,
153
+ ]
154
+ .filter(Boolean)
155
+ .join(", ");
156
+
157
+ return (
158
+ <button
159
+ type="button"
160
+ data-inbox-row
161
+ aria-current={selected ? "true" : undefined}
162
+ aria-label={label}
163
+ onClick={() => onSelect(item.conversationId)}
164
+ className={cn(
165
+ "flex w-full items-center gap-3 px-3 py-2.5 text-left transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
166
+ selected && "bg-accent",
167
+ )}
168
+ data-testid={`genesis-chat-inbox-row-${item.conversationId}`}
169
+ >
170
+ <UserAvatar name={title} src={other?.avatarUrl} className="shrink-0" />
171
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5" aria-hidden>
172
+ <span className="flex items-center gap-2">
173
+ <span className={cn("min-w-0 flex-1 truncate text-sm", unread ? "font-semibold text-foreground" : "font-medium text-foreground")}>{title}</span>
174
+ <span className={cn("shrink-0 text-xs tabular-nums", unread && !item.muted ? "font-medium text-primary" : "text-muted-foreground")}>{time}</span>
175
+ </span>
176
+ <span className="flex items-center gap-1.5">
177
+ <span className={cn("min-w-0 flex-1 truncate text-sm", unread ? "text-foreground" : "text-muted-foreground")}>{preview || " "}</span>
178
+ {item.pinned ? <Pin className="size-3.5 shrink-0 text-muted-foreground" /> : null}
179
+ {item.muted ? <BellOff className="size-3.5 shrink-0 text-muted-foreground" /> : null}
180
+ {item.mentioned ? (
181
+ <span className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground">
182
+ <AtSign className="size-3" />
183
+ </span>
184
+ ) : null}
185
+ {unread ? (
186
+ <Badge variant={item.muted ? "muted" : "default"} className="h-5 min-w-5 shrink-0 justify-center rounded-full px-1.5 text-[11px] tabular-nums">
187
+ {count}
188
+ </Badge>
189
+ ) : null}
190
+ </span>
191
+ </span>
192
+ </button>
193
+ );
194
+ }
195
+
@@ -0,0 +1,75 @@
1
+ /**
2
+ * @feat:chat
3
+ * `@marktiderman/genesis-chat/web`: chat on Genesis UI. `ChatPanel` (inbox and
4
+ * thread side by side), `ChatInbox`, `ChatThread` and `ChatComposer`, the
5
+ * voice note player and recorder, topics, scheduled send, availability,
6
+ * broadcast, search, link previews, video, the emoji picker, the safe
7
+ * markdown renderer, and an in-memory client for demos and tests. Render them under `ChatProvider`
8
+ * (from `/react`) inside a TanStack QueryClientProvider.
9
+ *
10
+ * @stability Beta
11
+ */
12
+ export { ChatPanel, type ChatPanelProps, type ChatPanelNoSelectionProps } from "./panel";
13
+ export { ChatInbox, inboxTitle, type ChatInboxProps, type ChatInboxEmptyProps } from "./inbox";
14
+ export {
15
+ ChatThread,
16
+ type ChatThreadProps,
17
+ type ChatThreadSlots,
18
+ type ChatThreadHeaderProps,
19
+ type ChatThreadEmptyProps,
20
+ type ChatComposerAccessoryProps,
21
+ } from "./thread";
22
+ export { ChatComposer, type ChatComposerProps, type ChatComposerHandle } from "./composer";
23
+ export { MessageRow, plainText, type MessageRowProps } from "./bubble";
24
+ export {
25
+ GenericAttachment,
26
+ ImageGrid,
27
+ Lightbox,
28
+ FileChip,
29
+ VideoAttachment,
30
+ MessageAttachments,
31
+ toAttachmentView,
32
+ type AttachmentView,
33
+ type ChatAttachmentSlotProps,
34
+ type ImageGridProps,
35
+ type LightboxProps,
36
+ type MessageAttachmentsProps,
37
+ } from "./attachments";
38
+ export { VoiceNote, type VoiceNoteProps } from "./voice-note";
39
+ export {
40
+ useVoiceRecorder,
41
+ pickVoiceMimeType,
42
+ voiceFile,
43
+ type UseVoiceRecorderOptions,
44
+ type VoiceRecorder,
45
+ type VoiceRecorderState,
46
+ type VoiceRecording,
47
+ } from "./use-voice-recorder";
48
+ export { ChatMarkdown, parseMarkdown, isSafeHref, type ChatMarkdownProps, type MarkdownNode } from "./markdown";
49
+ export { clockTime, relativeTime, formatBytes, messageLabel, type ChatUser, type ResolveUser } from "./format";
50
+ export { QUICK_REACTIONS } from "./thread-context";
51
+ export { createMemoryChatClient, type MemoryChatClient, type MemoryChatOptions, type MemoryConversationSeed } from "./memory-client";
52
+ export { ChatTopicBar, type ChatTopicBarProps } from "./topics";
53
+ export { ChatScheduleMenu, ChatScheduledList, toLocalInput, type ChatScheduleMenuProps, type ChatScheduledListProps } from "./schedule";
54
+ export { ChatAwayBanner, ChatAvailabilitySettings, type ChatAwayBannerProps, type ChatAvailabilitySettingsProps } from "./availability";
55
+ export { ChatBroadcast, type ChatBroadcastProps } from "./broadcast";
56
+ export { ChatSearch, SearchSnippet, type ChatSearchProps } from "./search";
57
+ export { ChatLinkPreview, ChatAttachmentCard, linkPreviewImageSrc, type ChatLinkPreviewProps, type ChatAttachmentCardProps, type LinkPreviewImages } from "./cards";
58
+ export { ChatErrorBoundary, type ChatErrorBoundaryProps } from "./error-boundary";
59
+ export { ChatEmojiPicker, canPickEmoji, type ChatEmojiPickerProps } from "./emoji-picker";
60
+ export {
61
+ ChatVideoRecorder,
62
+ ChatVideoPlayer,
63
+ useVideoRecorder,
64
+ pickVideoMimeType,
65
+ videoFile,
66
+ uploadVideo,
67
+ muxStreamUrl,
68
+ muxPosterUrl,
69
+ type ChatVideoRecorderProps,
70
+ type ChatVideoPlayerProps,
71
+ type UseVideoRecorderOptions,
72
+ type VideoRecorder,
73
+ type VideoRecorderState,
74
+ type VideoRecording,
75
+ } from "./video";
@@ -0,0 +1,245 @@
1
+ /**
2
+ * @feat:chat
3
+ * The safe inline markdown a message body is written in: **bold**, *italic*
4
+ * (or _italic_), `code`, [links](https://…), bare https links, line breaks and
5
+ * mentions written `@[Name](user:<uuid>)`. A small tokenizer turns the text
6
+ * into nodes and the nodes into React elements, so every character of the
7
+ * body reaches the page as text: no HTML is ever parsed, and nothing uses
8
+ * dangerouslySetInnerHTML. Links open only for http and https.
9
+ */
10
+ import { Fragment, type ReactNode } from "react";
11
+ import { cn } from "@marktiderman/genesis-ui";
12
+
13
+ export type MarkdownNode =
14
+ | { type: "text"; text: string }
15
+ | { type: "bold"; children: MarkdownNode[] }
16
+ | { type: "italic"; children: MarkdownNode[] }
17
+ | { type: "code"; text: string }
18
+ | { type: "link"; href: string; children: MarkdownNode[] }
19
+ | { type: "mention"; name: string; userId: string }
20
+ | { type: "break" };
21
+
22
+ /** True for an absolute http or https URL; everything else (javascript:, data:, relative) is refused. */
23
+ export function isSafeHref(href: string): boolean {
24
+ try {
25
+ const url = new URL(href);
26
+ return url.protocol === "http:" || url.protocol === "https:";
27
+ } catch {
28
+ return false;
29
+ }
30
+ }
31
+
32
+ const MENTION = /^@\[([^\]\n]{1,80})\]\(user:([0-9a-f-]{36})\)/i;
33
+ const LINK = /^\[([^\]\n]{1,300})\]\(((?:[^()\s]|\([^()\s]*\)){1,2000})\)/;
34
+ const BARE_URL = /^https?:\/\/[^\s<>"]+/i;
35
+ const TRAILING_PUNCTUATION = /[.,;:!?)\]'"]+$/;
36
+
37
+ /** Parses one message body into inline nodes. Never throws. */
38
+ export function parseMarkdown(text: string): MarkdownNode[] {
39
+ const out: MarkdownNode[] = [];
40
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
41
+ lines.forEach((line, i) => {
42
+ if (i > 0) out.push({ type: "break" });
43
+ out.push(...parseInline(line, 0));
44
+ });
45
+ return mergeText(out);
46
+ }
47
+
48
+ function parseInline(src: string, depth: number): MarkdownNode[] {
49
+ const out: MarkdownNode[] = [];
50
+ let text = "";
51
+ const flush = () => {
52
+ if (text) out.push({ type: "text", text });
53
+ text = "";
54
+ };
55
+ let i = 0;
56
+ while (i < src.length) {
57
+ const ch = src[i]!;
58
+ const rest = src.slice(i);
59
+ const prev = i > 0 ? src[i - 1]! : "";
60
+
61
+ if (ch === "\\" && i + 1 < src.length && /[*_`[\]@\\]/.test(src[i + 1]!)) {
62
+ text += src[i + 1];
63
+ i += 2;
64
+ continue;
65
+ }
66
+
67
+ if (ch === "`") {
68
+ const end = src.indexOf("`", i + 1);
69
+ if (end > i + 1) {
70
+ flush();
71
+ out.push({ type: "code", text: src.slice(i + 1, end) });
72
+ i = end + 1;
73
+ continue;
74
+ }
75
+ }
76
+
77
+ if (ch === "@") {
78
+ const m = MENTION.exec(rest);
79
+ if (m) {
80
+ flush();
81
+ out.push({ type: "mention", name: m[1]!, userId: m[2]!.toLowerCase() });
82
+ i += m[0].length;
83
+ continue;
84
+ }
85
+ }
86
+
87
+ if (ch === "[") {
88
+ const m = LINK.exec(rest);
89
+ if (m) {
90
+ flush();
91
+ const label = depth < 3 ? parseInline(m[1]!, depth + 1) : [{ type: "text" as const, text: m[1]! }];
92
+ if (isSafeHref(m[2]!)) out.push({ type: "link", href: m[2]!, children: label });
93
+ // A link we won't open keeps its words and drops the target.
94
+ else out.push(...label);
95
+ i += m[0].length;
96
+ continue;
97
+ }
98
+ }
99
+
100
+ if ((ch === "h" || ch === "H") && !/[\w/]/.test(prev)) {
101
+ const m = BARE_URL.exec(rest);
102
+ if (m) {
103
+ const url = m[0].replace(TRAILING_PUNCTUATION, "");
104
+ if (isSafeHref(url)) {
105
+ flush();
106
+ out.push({ type: "link", href: url, children: [{ type: "text", text: url }] });
107
+ i += url.length;
108
+ continue;
109
+ }
110
+ }
111
+ }
112
+
113
+ if ((ch === "*" || ch === "_") && depth < 3) {
114
+ const double = src[i + 1] === ch;
115
+ const marker = double ? ch + ch : ch;
116
+ // `_` only opens at a word edge, so snake_case stays as written.
117
+ const opens = ch === "*" || !/\w/.test(prev);
118
+ const after = src[i + marker.length];
119
+ if (opens && after && after !== " " && after !== ch) {
120
+ const end = findClose(src, i + marker.length, marker);
121
+ if (end > 0) {
122
+ flush();
123
+ const inner = parseInline(src.slice(i + marker.length, end), depth + 1);
124
+ out.push(double ? { type: "bold", children: inner } : { type: "italic", children: inner });
125
+ i = end + marker.length;
126
+ continue;
127
+ }
128
+ }
129
+ }
130
+
131
+ text += ch;
132
+ i += 1;
133
+ }
134
+ flush();
135
+ return out;
136
+ }
137
+
138
+ /** Where `marker` closes: not after a space, and (for `_`) at a word edge. */
139
+ function findClose(src: string, from: number, marker: string): number {
140
+ let j = from;
141
+ while (j < src.length) {
142
+ const k = src.indexOf(marker, j);
143
+ if (k < 0) return -1;
144
+ const before = src[k - 1]!;
145
+ const next = src[k + marker.length];
146
+ const doubled = marker.length === 1 && next === marker;
147
+ const edge = marker[0] !== "_" || !next || !/\w/.test(next);
148
+ if (k > from && before !== " " && !doubled && edge) return k;
149
+ j = k + (doubled ? 2 : 1);
150
+ }
151
+ return -1;
152
+ }
153
+
154
+ function mergeText(nodes: MarkdownNode[]): MarkdownNode[] {
155
+ const out: MarkdownNode[] = [];
156
+ for (const n of nodes) {
157
+ const last = out[out.length - 1];
158
+ if (n.type === "text" && last?.type === "text") last.text += n.text;
159
+ else out.push(n);
160
+ }
161
+ return out;
162
+ }
163
+
164
+ export interface ChatMarkdownProps {
165
+ /** The message body. */
166
+ text: string;
167
+ /** Draws a mention; a highlighted chip by default. */
168
+ renderMention?: (mention: { name: string; userId: string }) => ReactNode;
169
+ /** Mentions of this user are drawn stronger. */
170
+ me?: string;
171
+ /** Bubble colours: on `primary`, links and chips use the primary foreground. */
172
+ tone?: "default" | "primary";
173
+ className?: string;
174
+ }
175
+
176
+ /** Renders a message body's inline markdown as React elements. */
177
+ export function ChatMarkdown({ text, renderMention, me, tone = "default", className }: ChatMarkdownProps) {
178
+ const nodes = parseMarkdown(text);
179
+ return <span className={cn("whitespace-pre-wrap break-words [overflow-wrap:anywhere]", className)}>{renderNodes(nodes, { renderMention, me, tone })}</span>;
180
+ }
181
+
182
+ interface RenderOptions {
183
+ renderMention?: ChatMarkdownProps["renderMention"];
184
+ me?: string;
185
+ tone: "default" | "primary";
186
+ }
187
+
188
+ function renderNodes(nodes: MarkdownNode[], opts: RenderOptions): ReactNode[] {
189
+ return nodes.map((n, i) => {
190
+ switch (n.type) {
191
+ case "text":
192
+ return <Fragment key={i}>{n.text}</Fragment>;
193
+ case "break":
194
+ return <br key={i} />;
195
+ case "bold":
196
+ return <strong key={i} className="font-semibold">{renderNodes(n.children, opts)}</strong>;
197
+ case "italic":
198
+ return <em key={i}>{renderNodes(n.children, opts)}</em>;
199
+ case "code":
200
+ return (
201
+ <code
202
+ key={i}
203
+ className={cn(
204
+ "rounded px-1 py-0.5 font-mono text-[0.85em]",
205
+ opts.tone === "primary" ? "bg-primary-foreground/15" : "bg-background/70",
206
+ )}
207
+ >
208
+ {n.text}
209
+ </code>
210
+ );
211
+ case "link":
212
+ return (
213
+ <a
214
+ key={i}
215
+ href={n.href}
216
+ target="_blank"
217
+ rel="noopener noreferrer"
218
+ className={cn("underline underline-offset-2", opts.tone === "primary" ? "text-primary-foreground" : "text-primary")}
219
+ >
220
+ {renderNodes(n.children, opts)}
221
+ </a>
222
+ );
223
+ case "mention": {
224
+ if (opts.renderMention) return <Fragment key={i}>{opts.renderMention(n)}</Fragment>;
225
+ const mine = opts.me && n.userId === opts.me.toLowerCase();
226
+ return (
227
+ <span
228
+ key={i}
229
+ data-mention={n.userId}
230
+ className={cn(
231
+ "rounded px-1 font-medium",
232
+ opts.tone === "primary"
233
+ ? "bg-primary-foreground/20 text-primary-foreground"
234
+ : mine
235
+ ? "bg-primary/20 text-primary"
236
+ : "bg-primary/10 text-primary",
237
+ )}
238
+ >
239
+ @{n.name}
240
+ </span>
241
+ );
242
+ }
243
+ }
244
+ });
245
+ }