@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.
- package/README.md +304 -13
- package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
- package/dist/chunk-5D5A7I4Q.js.map +1 -0
- package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
- package/dist/chunk-7S6HYNWW.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-PZTOY2LY.js +576 -0
- package/dist/chunk-PZTOY2LY.js.map +1 -0
- package/dist/chunk-VEPZROQB.js +187 -0
- package/dist/chunk-VEPZROQB.js.map +1 -0
- package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1910 -1
- package/dist/native.js +4900 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B0y3phE0.d.ts +151 -0
- package/dist/push.d.ts +206 -0
- package/dist/push.js +3 -0
- package/dist/push.js.map +1 -0
- package/dist/react.d.ts +149 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +510 -21
- package/dist/server.js +863 -21
- package/dist/server.js.map +1 -1
- package/dist/thread-DWXHKPdw.d.ts +57 -0
- package/dist/web.d.ts +313 -10
- package/dist/web.js +1731 -60
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
- package/package.json +74 -6
- package/src/__tests__/coaching-core.test.ts +332 -0
- package/src/__tests__/coaching-react.test.tsx +248 -0
- package/src/__tests__/coaching-web.test.tsx +449 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +402 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fakes.ts +90 -0
- package/src/__tests__/server-integrations.test.ts +247 -0
- package/src/__tests__/server-providers.test.ts +246 -0
- package/src/__tests__/server-runner.test.ts +277 -0
- package/src/__tests__/server-safety.test.ts +290 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +333 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +757 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +429 -0
- package/src/core/thread.ts +171 -0
- package/src/core/types.ts +311 -0
- package/src/index.ts +13 -0
- package/src/native/__tests__/auto-retry.test.tsx +128 -0
- package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
- package/src/native/__tests__/composer-state.test.ts +188 -0
- package/src/native/__tests__/composer.test.tsx +133 -0
- package/src/native/__tests__/emoji.test.tsx +238 -0
- package/src/native/__tests__/inbox-screen.test.tsx +48 -0
- package/src/native/__tests__/message-bubble.test.tsx +127 -0
- package/src/native/__tests__/organise-logic.test.ts +164 -0
- package/src/native/__tests__/organise-screens.test.tsx +344 -0
- package/src/native/__tests__/photo-viewer.test.tsx +226 -0
- package/src/native/__tests__/push-payload.test.ts +66 -0
- package/src/native/__tests__/push.test.ts +209 -0
- package/src/native/__tests__/recorder-machine.test.ts +96 -0
- package/src/native/__tests__/rows-actions.test.ts +76 -0
- package/src/native/__tests__/thread-layout.test.ts +41 -0
- package/src/native/__tests__/thread-screen.test.tsx +116 -0
- package/src/native/__tests__/tus.test.ts +154 -0
- package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
- package/src/native/__tests__/video-media.test.tsx +421 -0
- package/src/native/__tests__/video-recorder-state.test.ts +87 -0
- package/src/native/__tests__/video-upload.test.ts +227 -0
- package/src/native/access-error.tsx +31 -0
- package/src/native/actions.ts +42 -0
- package/src/native/attachment-cards.tsx +116 -0
- package/src/native/auto-retry.ts +69 -0
- package/src/native/availability.tsx +376 -0
- package/src/native/broadcast-sheet.tsx +193 -0
- package/src/native/composer-state.ts +302 -0
- package/src/native/composer.tsx +453 -0
- package/src/native/context.tsx +70 -0
- package/src/native/copy.ts +296 -0
- package/src/native/emoji-data.ts +21 -0
- package/src/native/emoji-picker.tsx +229 -0
- package/src/native/emoji.ts +118 -0
- package/src/native/icon-button.tsx +40 -0
- package/src/native/inbox-screen.tsx +116 -0
- package/src/native/index.ts +184 -0
- package/src/native/link-preview.tsx +104 -0
- package/src/native/media-colors.ts +15 -0
- package/src/native/media.tsx +187 -0
- package/src/native/message-actions-sheet.tsx +121 -0
- package/src/native/message-bubble.tsx +374 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +275 -0
- package/src/native/organise-copy.ts +195 -0
- package/src/native/photo-viewer.tsx +531 -0
- package/src/native/push.ts +375 -0
- package/src/native/recorder-machine.ts +176 -0
- package/src/native/rows.ts +81 -0
- package/src/native/schedule-sheet.tsx +314 -0
- package/src/native/search-screen.tsx +168 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +620 -0
- package/src/native/topics.tsx +179 -0
- package/src/native/tus.ts +282 -0
- package/src/native/use-composer.ts +71 -0
- package/src/native/use-jump.ts +91 -0
- package/src/native/use-voice-recorder.ts +234 -0
- package/src/native/video-player.tsx +370 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +271 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +283 -0
- package/src/react/index.tsx +810 -0
- package/src/server/assistant.ts +235 -0
- package/src/server/crypto.ts +86 -0
- package/src/server/expo.ts +119 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +57 -0
- package/src/server/moderation.ts +155 -0
- package/src/server/mux.ts +290 -0
- package/src/server/notifications.ts +130 -0
- package/src/server/runner.ts +468 -0
- package/src/server/transcribers.ts +111 -0
- package/src/server/types.ts +171 -0
- package/src/server/unfurl.ts +480 -0
- package/src/server/upload.ts +158 -0
- package/src/web/attachments.tsx +379 -0
- package/src/web/availability.tsx +299 -0
- package/src/web/broadcast.tsx +166 -0
- package/src/web/bubble.tsx +563 -0
- package/src/web/cards.tsx +136 -0
- package/src/web/composer.tsx +571 -0
- package/src/web/emoji-picker.tsx +84 -0
- package/src/web/error-boundary.tsx +33 -0
- package/src/web/format.ts +55 -0
- package/src/web/inbox.tsx +195 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +706 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +192 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +802 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +522 -0
- package/src/web/voice-note.tsx +197 -0
- package/dist/chunk-27UTR5FW.js +0 -322
- package/dist/chunk-27UTR5FW.js.map +0 -1
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- 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
|
+
}
|