@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,299 @@
1
+ /**
2
+ * @feat:chat
3
+ * Availability on the web: `ChatAwayBanner`, the line a thread shows when the other person is
4
+ * away or outside their hours, and `ChatAvailabilitySettings`, where you set your own away
5
+ * message, its end, your hours per weekday and your time zone.
6
+ */
7
+ import { useEffect, useMemo, useState } from "react";
8
+ import { Moon } from "lucide-react";
9
+ import {
10
+ Alert,
11
+ AlertDescription,
12
+ Button,
13
+ Input,
14
+ Label,
15
+ Select,
16
+ SelectContent,
17
+ SelectItem,
18
+ SelectTrigger,
19
+ SelectValue,
20
+ Skeleton,
21
+ Switch,
22
+ Textarea,
23
+ cn,
24
+ toast,
25
+ } from "@marktiderman/genesis-ui";
26
+ import { WEEKDAYS, awayLine, awaySummary } from "../core/availability";
27
+ import type { ChatAvailability, Weekday, WorkingHours } from "../core/types";
28
+ import { useAvailability } from "../react/index";
29
+ import { toLocalInput } from "./schedule";
30
+
31
+ export interface ChatAwayBannerProps {
32
+ /** One person (a direct conversation). */
33
+ availability?: ChatAvailability | null;
34
+ /** The person's name, as the app shows it. */
35
+ name?: string;
36
+ /** Several people (a group): everyone away or outside their hours is named, two at most. */
37
+ people?: { availability: ChatAvailability | null | undefined; name: string }[];
38
+ now?: Date;
39
+ className?: string;
40
+ }
41
+
42
+ /** "Mark replies Mon–Fri, 9–5 (Eastern)", "Away until Monday: Back Monday", "Sam, Lee and 2 others are away"; nothing when everyone is around. */
43
+ export function ChatAwayBanner({ availability, name, people, now, className }: ChatAwayBannerProps) {
44
+ const line = people ? awaySummary(people, { now }) : awayLine(availability, name ?? "They", { now });
45
+ if (!line) return null;
46
+ return (
47
+ <Alert className={cn("rounded-none border-x-0 border-t-0 px-3 py-2", className)} role="status" data-testid="genesis-chat-away-banner">
48
+ <Moon className="size-4" aria-hidden />
49
+ <AlertDescription className="text-xs">{line}</AlertDescription>
50
+ </Alert>
51
+ );
52
+ }
53
+
54
+ const DAY_NAMES: Record<Weekday, string> = {
55
+ mon: "Monday",
56
+ tue: "Tuesday",
57
+ wed: "Wednesday",
58
+ thu: "Thursday",
59
+ fri: "Friday",
60
+ sat: "Saturday",
61
+ sun: "Sunday",
62
+ };
63
+
64
+ const FALLBACK_ZONES = [
65
+ "UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "America/Toronto", "America/Sao_Paulo",
66
+ "Europe/London", "Europe/Paris", "Europe/Berlin", "Africa/Johannesburg", "Asia/Dubai", "Asia/Kolkata", "Asia/Singapore",
67
+ "Asia/Tokyo", "Australia/Sydney", "Pacific/Auckland",
68
+ ];
69
+
70
+ function timeZones(): string[] {
71
+ const intl = Intl as unknown as { supportedValuesOf?: (key: string) => string[] };
72
+ try {
73
+ const all = intl.supportedValuesOf?.("timeZone");
74
+ if (all?.length) return all.includes("UTC") ? all : ["UTC", ...all];
75
+ } catch {
76
+ // older engines
77
+ }
78
+ return FALLBACK_ZONES;
79
+ }
80
+
81
+ const deviceZone = () => {
82
+ try {
83
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
84
+ } catch {
85
+ return "UTC";
86
+ }
87
+ };
88
+
89
+ interface DayRow {
90
+ on: boolean;
91
+ from: string;
92
+ to: string;
93
+ }
94
+
95
+ const defaultDays = (): Record<Weekday, DayRow> =>
96
+ Object.fromEntries(WEEKDAYS.map((d) => [d, { on: d !== "sat" && d !== "sun", from: "09:00", to: "17:00" }])) as Record<Weekday, DayRow>;
97
+
98
+ function daysFrom(hours: WorkingHours | null | undefined): Record<Weekday, DayRow> {
99
+ if (!hours) return defaultDays();
100
+ return Object.fromEntries(
101
+ WEEKDAYS.map((d) => {
102
+ const first = hours[d]?.[0];
103
+ return [d, first ? { on: true, from: first[0], to: first[1] } : { on: false, from: "09:00", to: "17:00" }];
104
+ }),
105
+ ) as Record<Weekday, DayRow>;
106
+ }
107
+
108
+ export interface ChatAvailabilitySettingsProps {
109
+ /** Called after a save. */
110
+ onSaved?: () => void;
111
+ className?: string;
112
+ }
113
+
114
+ /**
115
+ * Your availability: an away message (and until when), working hours per weekday, and the
116
+ * time zone they're read in. One range per day here; extra ranges saved elsewhere are kept
117
+ * for the days you don't change.
118
+ */
119
+ export function ChatAvailabilitySettings({ onSaved, className }: ChatAvailabilitySettingsProps) {
120
+ const { mine, mineLoading, setMine } = useAvailability();
121
+ const zones = useMemo(timeZones, []);
122
+ const [message, setMessage] = useState("");
123
+ const [until, setUntil] = useState("");
124
+ const [zone, setZone] = useState(deviceZone);
125
+ const [useHours, setUseHours] = useState(false);
126
+ const [days, setDays] = useState<Record<Weekday, DayRow>>(defaultDays);
127
+ const [busy, setBusy] = useState(false);
128
+
129
+ useEffect(() => {
130
+ if (mineLoading) return;
131
+ setMessage(mine?.awayMessage ?? "");
132
+ setUntil(mine?.awayUntil ? toLocalInput(new Date(mine.awayUntil)) : "");
133
+ setZone(mine?.timeZone ?? deviceZone());
134
+ setUseHours(Boolean(mine?.hours && Object.keys(mine.hours).length));
135
+ setDays(daysFrom(mine?.hours));
136
+ }, [mine, mineLoading]);
137
+
138
+ if (mineLoading) {
139
+ return (
140
+ <div className={cn("flex flex-col gap-3", className)} aria-busy aria-label="Loading availability">
141
+ <Skeleton className="h-20 w-full" />
142
+ <Skeleton className="h-40 w-full" />
143
+ </div>
144
+ );
145
+ }
146
+
147
+ const badRange = useHours && WEEKDAYS.some((d) => days[d].on && days[d].from === days[d].to);
148
+
149
+ const save = async () => {
150
+ if (badRange) return;
151
+ const hours: WorkingHours | null = useHours
152
+ ? Object.fromEntries(
153
+ WEEKDAYS.filter((d) => days[d].on).map((d) => {
154
+ const kept = (mine?.hours?.[d] ?? []).slice(1);
155
+ return [d, [[days[d].from, days[d].to] as [string, string], ...kept]];
156
+ }),
157
+ )
158
+ : null;
159
+ setBusy(true);
160
+ try {
161
+ await setMine({
162
+ awayMessage: message.trim() || null,
163
+ awayUntil: until ? new Date(until).toISOString() : null,
164
+ timeZone: zone,
165
+ hours,
166
+ });
167
+ toast.success("Availability saved");
168
+ onSaved?.();
169
+ } catch (e) {
170
+ toast.error(e instanceof Error ? e.message : "Couldn't save");
171
+ } finally {
172
+ setBusy(false);
173
+ }
174
+ };
175
+
176
+ return (
177
+ <form
178
+ className={cn("flex flex-col gap-6", className)}
179
+ onSubmit={(e) => {
180
+ e.preventDefault();
181
+ void save();
182
+ }}
183
+ data-testid="genesis-chat-availability-settings"
184
+ >
185
+ <fieldset className="flex flex-col gap-2">
186
+ <legend className="mb-1 text-sm font-semibold">Away message</legend>
187
+ <Label htmlFor="genesis-chat-away-message" className="sr-only">
188
+ Away message
189
+ </Label>
190
+ <Textarea
191
+ id="genesis-chat-away-message"
192
+ value={message}
193
+ onChange={(e) => setMessage(e.target.value)}
194
+ maxLength={280}
195
+ placeholder="Back Monday. For anything urgent, call the office."
196
+ className="min-h-20"
197
+ />
198
+ <div className="flex flex-wrap items-end gap-2">
199
+ <div className="flex flex-col gap-1">
200
+ <Label htmlFor="genesis-chat-away-until" className="text-xs text-muted-foreground">
201
+ Away until (optional)
202
+ </Label>
203
+ <Input id="genesis-chat-away-until" type="datetime-local" value={until} onChange={(e) => setUntil(e.target.value)} className="w-56" />
204
+ </div>
205
+ {until || message ? (
206
+ <Button
207
+ type="button"
208
+ variant="ghost"
209
+ size="sm"
210
+ onClick={() => {
211
+ setUntil("");
212
+ setMessage("");
213
+ }}
214
+ >
215
+ I'm back
216
+ </Button>
217
+ ) : null}
218
+ </div>
219
+ </fieldset>
220
+
221
+ <fieldset className="flex flex-col gap-3">
222
+ <legend className="mb-1 flex w-full items-center justify-between gap-2 text-sm font-semibold">
223
+ <span>Working hours</span>
224
+ </legend>
225
+ <div className="flex items-center justify-between gap-3">
226
+ <Label htmlFor="genesis-chat-use-hours" className="text-sm font-normal text-muted-foreground">
227
+ Tell people when you usually reply
228
+ </Label>
229
+ <Switch id="genesis-chat-use-hours" checked={useHours} onCheckedChange={setUseHours} />
230
+ </div>
231
+ {useHours ? (
232
+ <div className="flex flex-col gap-1.5" role="group" aria-label="Hours per day">
233
+ {WEEKDAYS.map((d) => (
234
+ <div key={d} className="flex flex-wrap items-center gap-2 text-sm" data-testid={`genesis-chat-hours-${d}`}>
235
+ <Switch
236
+ checked={days[d].on}
237
+ onCheckedChange={(on) => setDays((x) => ({ ...x, [d]: { ...x[d], on } }))}
238
+ aria-label={`Working on ${DAY_NAMES[d]}`}
239
+ />
240
+ <span className={cn("w-24", !days[d].on && "text-muted-foreground")}>{DAY_NAMES[d]}</span>
241
+ {days[d].on ? (
242
+ <>
243
+ <Input
244
+ type="time"
245
+ value={days[d].from}
246
+ onChange={(e) => setDays((x) => ({ ...x, [d]: { ...x[d], from: e.target.value } }))}
247
+ aria-label={`${DAY_NAMES[d]} from`}
248
+ className="h-8 w-28"
249
+ />
250
+ <span aria-hidden>–</span>
251
+ <Input
252
+ type="time"
253
+ value={days[d].to}
254
+ onChange={(e) => setDays((x) => ({ ...x, [d]: { ...x[d], to: e.target.value } }))}
255
+ aria-label={`${DAY_NAMES[d]} to`}
256
+ className="h-8 w-28"
257
+ />
258
+ </>
259
+ ) : (
260
+ <span className="text-xs text-muted-foreground">Not working</span>
261
+ )}
262
+ </div>
263
+ ))}
264
+ {badRange ? (
265
+ <p className="text-xs text-destructive" role="alert">
266
+ A day's start and end can't be the same time.
267
+ </p>
268
+ ) : (
269
+ <p className="text-xs text-muted-foreground">An end before the start runs past midnight.</p>
270
+ )}
271
+ </div>
272
+ ) : null}
273
+ <div className="flex flex-col gap-1">
274
+ <Label htmlFor="genesis-chat-time-zone" className="text-xs text-muted-foreground">
275
+ Time zone
276
+ </Label>
277
+ <Select value={zone} onValueChange={setZone}>
278
+ <SelectTrigger id="genesis-chat-time-zone" className="w-72" aria-label="Time zone">
279
+ <SelectValue />
280
+ </SelectTrigger>
281
+ <SelectContent className="max-h-72">
282
+ {(zones.includes(zone) ? zones : [zone, ...zones]).map((z) => (
283
+ <SelectItem key={z} value={z}>
284
+ {z.replace(/_/g, " ")}
285
+ </SelectItem>
286
+ ))}
287
+ </SelectContent>
288
+ </Select>
289
+ </div>
290
+ </fieldset>
291
+
292
+ <div>
293
+ <Button type="submit" loading={busy} disabled={badRange} data-testid="genesis-chat-availability-save">
294
+ Save
295
+ </Button>
296
+ </div>
297
+ </form>
298
+ );
299
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @feat:chat
3
+ * `ChatBroadcast`: write one message and send it into many of your conversations at once
4
+ * (each client's direct conversation), so each person gets it on their own and their reply
5
+ * comes back to you privately. Pick conversations from the inbox (filter, select all), then send.
6
+ */
7
+ import { useMemo, useRef, useState } from "react";
8
+ import { Megaphone, Search } from "lucide-react";
9
+ import { Alert, AlertDescription, Button, Checkbox, EmptyState, Input, Label, Skeleton, Textarea, UserAvatar, cn, toast } from "@marktiderman/genesis-ui";
10
+ import { CHAT_LIMITS, randomId } from "../core/media";
11
+ import type { ChatInboxItem } from "../core/types";
12
+ import { useBroadcast, useInbox } from "../react/index";
13
+ import type { ResolveUser } from "./format";
14
+ import { inboxTitle } from "./inbox";
15
+
16
+ export interface ChatBroadcastProps {
17
+ resolveUser: ResolveUser;
18
+ /** Only this kind of conversation (`direct`, say). */
19
+ kind?: string;
20
+ /** Which inbox rows may be picked; every one by default. */
21
+ filter?: (item: ChatInboxItem) => boolean;
22
+ /** Called with how many it went to. */
23
+ onSent?: (sent: number, picked: number) => void;
24
+ className?: string;
25
+ }
26
+
27
+ export function ChatBroadcast({ resolveUser, kind, filter, onSent, className }: ChatBroadcastProps) {
28
+ const { items, isLoading } = useInbox({ kind, limit: 200 });
29
+ const { send, sending } = useBroadcast();
30
+ const [picked, setPicked] = useState<Set<string>>(new Set());
31
+ const [text, setText] = useState("");
32
+ const [q, setQ] = useState("");
33
+ // One clientId per draft, kept until it goes: Send pressed again after a timeout (when the
34
+ // server may already have posted it) is the same broadcast, never a second one.
35
+ const draftId = useRef<string | null>(null);
36
+ const busy = useRef(false);
37
+
38
+ const rows = useMemo(
39
+ () =>
40
+ items
41
+ .filter((i) => (filter ? filter(i) : true))
42
+ .map((i) => ({ item: i, title: inboxTitle(i, resolveUser) }))
43
+ .sort((a, b) => a.title.localeCompare(b.title)),
44
+ [items, filter, resolveUser],
45
+ );
46
+ const shown = rows.filter((r) => !q.trim() || r.title.toLowerCase().includes(q.trim().toLowerCase()));
47
+ const allShown = shown.length > 0 && shown.every((r) => picked.has(r.item.conversationId));
48
+
49
+ const toggle = (id: string, on: boolean) =>
50
+ setPicked((p) => {
51
+ const next = new Set(p);
52
+ if (on) next.add(id);
53
+ else next.delete(id);
54
+ return next;
55
+ });
56
+
57
+ const submit = async () => {
58
+ const body = text.trim();
59
+ if (!body || picked.size === 0 || busy.current) return;
60
+ busy.current = true;
61
+ draftId.current ??= randomId();
62
+ try {
63
+ const ids = await send([...picked], body, { clientId: draftId.current });
64
+ draftId.current = null;
65
+ const n = ids.length;
66
+ if (n === picked.size) toast.success(n === 1 ? "Sent to 1 conversation" : `Sent to ${n} conversations`);
67
+ else toast.warning(`Sent to ${n} of ${picked.size}`, { description: "You can't post in the others." });
68
+ onSent?.(n, picked.size);
69
+ setText("");
70
+ setPicked(new Set());
71
+ } catch (e) {
72
+ toast.error(e instanceof Error ? e.message : "Couldn't send");
73
+ } finally {
74
+ busy.current = false;
75
+ }
76
+ };
77
+
78
+ return (
79
+ <form
80
+ className={cn("flex flex-col gap-3", className)}
81
+ onSubmit={(e) => {
82
+ e.preventDefault();
83
+ void submit();
84
+ }}
85
+ data-testid="genesis-chat-broadcast"
86
+ >
87
+ <Alert>
88
+ <Megaphone className="size-4" aria-hidden />
89
+ <AlertDescription>Each person gets this in their own conversation with you, so replies come back to you privately. No one sees who else got it.</AlertDescription>
90
+ </Alert>
91
+
92
+ <div className="flex flex-col gap-1.5">
93
+ <Label htmlFor="genesis-chat-broadcast-text">Message</Label>
94
+ <Textarea
95
+ id="genesis-chat-broadcast-text"
96
+ value={text}
97
+ onChange={(e) => setText(e.target.value)}
98
+ maxLength={CHAT_LIMITS.bodyChars}
99
+ className="min-h-24"
100
+ placeholder="Write once, send to everyone you pick"
101
+ />
102
+ </div>
103
+
104
+ <fieldset className="flex min-h-0 flex-col gap-2">
105
+ <legend className="mb-1 text-sm font-medium">Send to</legend>
106
+ <div className="flex items-center gap-2">
107
+ <div className="relative flex-1">
108
+ <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
109
+ <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Filter" aria-label="Filter conversations" className="pl-8" />
110
+ </div>
111
+ <Button
112
+ type="button"
113
+ variant="ghost"
114
+ size="sm"
115
+ disabled={shown.length === 0}
116
+ onClick={() =>
117
+ setPicked((p) => {
118
+ const next = new Set(p);
119
+ for (const r of shown) {
120
+ if (allShown) next.delete(r.item.conversationId);
121
+ else next.add(r.item.conversationId);
122
+ }
123
+ return next;
124
+ })
125
+ }
126
+ >
127
+ {allShown ? "Clear" : "Select all"}
128
+ </Button>
129
+ </div>
130
+ {isLoading ? (
131
+ <div className="flex flex-col gap-2" aria-busy aria-label="Loading conversations">
132
+ <Skeleton className="h-10 w-full" />
133
+ <Skeleton className="h-10 w-full" />
134
+ </div>
135
+ ) : shown.length === 0 ? (
136
+ <EmptyState icon={Megaphone} title={rows.length ? "No matches" : "No conversations yet"} />
137
+ ) : (
138
+ <ul className="max-h-72 overflow-y-auto rounded-md border" aria-label="Conversations">
139
+ {shown.map(({ item, title }) => {
140
+ const id = `genesis-chat-broadcast-${item.conversationId}`;
141
+ const avatar = item.otherUserId ? resolveUser(item.otherUserId).avatarUrl : null;
142
+ return (
143
+ <li key={item.conversationId} className="flex items-center gap-3 border-b px-3 py-2 last:border-b-0">
144
+ <Checkbox id={id} checked={picked.has(item.conversationId)} onCheckedChange={(v) => toggle(item.conversationId, v === true)} />
145
+ <UserAvatar name={title} src={avatar} size="sm" />
146
+ <Label htmlFor={id} className="min-w-0 flex-1 cursor-pointer truncate font-normal">
147
+ {title}
148
+ </Label>
149
+ </li>
150
+ );
151
+ })}
152
+ </ul>
153
+ )}
154
+ </fieldset>
155
+
156
+ <div className="flex items-center justify-between gap-2">
157
+ <span className="text-sm text-muted-foreground" aria-live="polite">
158
+ {picked.size === 0 ? "Pick who gets it" : picked.size === 1 ? "1 conversation" : `${picked.size} conversations`}
159
+ </span>
160
+ <Button type="submit" loading={sending} disabled={!text.trim() || picked.size === 0} data-testid="genesis-chat-broadcast-send">
161
+ {picked.size > 1 ? `Send to ${picked.size}` : "Send"}
162
+ </Button>
163
+ </div>
164
+ </form>
165
+ );
166
+ }