@marktiderman/genesis-chat 0.1.1 → 0.2.1
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 +379 -13
- package/dist/chunk-A4YJX7KT.js +544 -0
- package/dist/chunk-A4YJX7KT.js.map +1 -0
- package/dist/chunk-AZS3HNLM.js +30 -0
- package/dist/chunk-AZS3HNLM.js.map +1 -0
- package/dist/chunk-BJCE5O7V.js +70 -0
- package/dist/chunk-BJCE5O7V.js.map +1 -0
- package/dist/chunk-GBJ3YI4E.js +966 -0
- package/dist/chunk-GBJ3YI4E.js.map +1 -0
- package/dist/chunk-JU2FAPBI.js +609 -0
- package/dist/chunk-JU2FAPBI.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-DUIG1hum.d.ts} +187 -11
- package/dist/index.d.ts +78 -163
- package/dist/index.js +3 -2
- package/dist/native.d.ts +1939 -1
- package/dist/native.js +5023 -0
- package/dist/native.js.map +1 -1
- package/dist/outbox-B9NAWQ58.d.ts +219 -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 +176 -7
- package/dist/react.js +3 -3
- package/dist/server.d.ts +551 -21
- package/dist/server.js +976 -34
- package/dist/server.js.map +1 -1
- package/dist/thread-oPra7pkW.d.ts +57 -0
- package/dist/web.d.ts +335 -12
- package/dist/web.js +1784 -64
- package/dist/web.js.map +1 -1
- package/migrations/0002_genesis_chat_coaching.sql +934 -0
- package/migrations/0003_genesis_chat_search_threads.sql +85 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +269 -0
- package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
- package/package.json +78 -6
- package/src/__tests__/coaching-core.test.ts +334 -0
- package/src/__tests__/coaching-react.test.tsx +319 -0
- package/src/__tests__/coaching-web.test.tsx +521 -0
- package/src/__tests__/core.test.ts +91 -0
- package/src/__tests__/fake-xhr.ts +54 -0
- package/src/__tests__/outbox-hosted.test.ts +269 -0
- package/src/__tests__/outbox.test.ts +122 -0
- package/src/__tests__/realtime.test.ts +70 -0
- package/src/__tests__/review-279.test.ts +41 -0
- package/src/__tests__/review-fixes-core.test.ts +193 -0
- package/src/__tests__/review-fixes-web.test.tsx +405 -0
- package/src/__tests__/server-effects.test.ts +158 -0
- package/src/__tests__/server-fake-video.test.ts +81 -0
- package/src/__tests__/server-fakes.ts +96 -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 +303 -0
- package/src/__tests__/web-markdown.test.tsx +88 -0
- package/src/__tests__/web.test.tsx +349 -0
- package/src/core/availability.ts +223 -0
- package/src/core/client.ts +783 -0
- package/src/core/extras.ts +106 -0
- package/src/core/media.ts +144 -0
- package/src/core/outbox.ts +635 -0
- package/src/core/thread.ts +171 -0
- package/src/core/transfer.ts +71 -0
- package/src/core/types.ts +328 -0
- package/src/index.ts +14 -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 +254 -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 +424 -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 +498 -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 +132 -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 +122 -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 +126 -0
- package/src/native/message-bubble.tsx +366 -0
- package/src/native/moderation.tsx +42 -0
- package/src/native/modules.ts +277 -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 +328 -0
- package/src/native/search-screen.tsx +189 -0
- package/src/native/stack-options.ts +36 -0
- package/src/native/start-sheet.tsx +87 -0
- package/src/native/thread-screen.tsx +647 -0
- package/src/native/topics.tsx +234 -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 +410 -0
- package/src/native/video-recorder-state.ts +140 -0
- package/src/native/video-recorder.tsx +274 -0
- package/src/native/video-upload-tray.tsx +77 -0
- package/src/native/video-upload.ts +432 -0
- package/src/native/voice-player.tsx +301 -0
- package/src/react/index.tsx +894 -0
- package/src/react/previews.ts +51 -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/fake-video.ts +171 -0
- package/src/server/handler.ts +32 -0
- package/src/server/index.ts +58 -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 +585 -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 +196 -0
- package/src/web/index.ts +75 -0
- package/src/web/markdown.tsx +245 -0
- package/src/web/memory-client.ts +714 -0
- package/src/web/panel.tsx +135 -0
- package/src/web/schedule.tsx +204 -0
- package/src/web/search.tsx +129 -0
- package/src/web/thread-context.ts +64 -0
- package/src/web/thread.tsx +830 -0
- package/src/web/topics.tsx +160 -0
- package/src/web/use-voice-recorder.ts +264 -0
- package/src/web/video.tsx +516 -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 +0 -525
- package/dist/chunk-JQXZUEEL.js.map +0 -1
- package/dist/chunk-SAY24E6V.js +0 -309
- package/dist/chunk-SAY24E6V.js.map +0 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Availability on native. `useAwayLine` and `ChatAwayLine`: the line a thread shows under its
|
|
4
|
+
* header when the other person is away or outside their hours ("Mark replies Mon–Fri, 9–5
|
|
5
|
+
* (Eastern)", "Away until Monday: Back Monday"; in a group, "Sam and Lee are away"), refreshed
|
|
6
|
+
* each minute so it comes and goes with their hours. `ChatAvailabilityScreen`: your own away
|
|
7
|
+
* message and its end, working hours per weekday and the time zone they're read in, for a
|
|
8
|
+
* Settings route. The same rules as the web's `ChatAwayBanner` and `ChatAvailabilitySettings`.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
|
11
|
+
import { View } from "react-native";
|
|
12
|
+
import {
|
|
13
|
+
NativeButton,
|
|
14
|
+
NativeCombobox,
|
|
15
|
+
NativeDatePicker,
|
|
16
|
+
NativeIcon,
|
|
17
|
+
NativeSwitch,
|
|
18
|
+
NativeText,
|
|
19
|
+
NativeTextarea,
|
|
20
|
+
NativeTimePicker,
|
|
21
|
+
useToast,
|
|
22
|
+
} from "@marktiderman/genesis-ui-native/components";
|
|
23
|
+
import { SettingsSubPage } from "@marktiderman/genesis-ui-native/layouts";
|
|
24
|
+
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
25
|
+
import { WEEKDAYS, awaySummary, minutesOf } from "../core/availability";
|
|
26
|
+
import type { AvailabilityInput, ChatAvailability, ChatMember, Weekday, WorkingHours } from "../core/types";
|
|
27
|
+
import { useAvailability } from "../react/index";
|
|
28
|
+
import { fill } from "./copy";
|
|
29
|
+
import { useChatNative } from "./context";
|
|
30
|
+
import { withDay, withTime } from "./schedule-sheet";
|
|
31
|
+
|
|
32
|
+
/** Ticks once a minute, so a line that depends on the clock keeps up with it. */
|
|
33
|
+
function useMinute(): Date {
|
|
34
|
+
const [now, setNow] = useState(() => new Date());
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
const t = setInterval(() => setNow(new Date()), 60_000);
|
|
37
|
+
return () => clearInterval(t);
|
|
38
|
+
}, []);
|
|
39
|
+
return now;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The away line for a conversation, or null when everyone is around: the other person in a
|
|
44
|
+
* two-person conversation; in a group, everyone (but you) away or outside their hours.
|
|
45
|
+
*/
|
|
46
|
+
export function awayLineFor(
|
|
47
|
+
members: readonly Pick<ChatMember, "userId" | "role">[],
|
|
48
|
+
me: string,
|
|
49
|
+
forUser: (userId: string) => ChatAvailability | null,
|
|
50
|
+
nameOf: (userId: string) => string,
|
|
51
|
+
now: Date = new Date(),
|
|
52
|
+
): string | null {
|
|
53
|
+
const ids = members.filter((m) => m.userId !== me && m.role !== "observer").map((m) => m.userId);
|
|
54
|
+
const people = ids.flatMap((id) => {
|
|
55
|
+
const a = forUser(id);
|
|
56
|
+
return a ? [{ availability: a, name: nameOf(id) }] : [];
|
|
57
|
+
});
|
|
58
|
+
return awaySummary(people, { now });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `awayLineFor` over the conversation's members, live (`useAvailability`), re-read each minute. */
|
|
62
|
+
export function useAwayLine(conversationId: string | null | undefined, members: readonly Pick<ChatMember, "userId" | "role">[], me: string, nameOf: (userId: string) => string): string | null {
|
|
63
|
+
const { forUser, members: list } = useAvailability(conversationId);
|
|
64
|
+
const now = useMinute();
|
|
65
|
+
return useMemo(() => (list.length ? awayLineFor(members, me, forUser, nameOf, now) : null), [list, members, me, forUser, nameOf, now]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ChatAwayLineProps {
|
|
69
|
+
text: string | null;
|
|
70
|
+
testID?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The away line, as a quiet strip under the header; nothing when `text` is null. */
|
|
74
|
+
export function ChatAwayLine({ text, testID = "chat-away-line" }: ChatAwayLineProps) {
|
|
75
|
+
const colors = useThemeRoles();
|
|
76
|
+
if (!text) return null;
|
|
77
|
+
return (
|
|
78
|
+
<View
|
|
79
|
+
testID={testID}
|
|
80
|
+
accessible
|
|
81
|
+
accessibilityRole="text"
|
|
82
|
+
accessibilityLabel={text}
|
|
83
|
+
accessibilityLiveRegion="polite"
|
|
84
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 16, paddingVertical: 8, backgroundColor: colors.fill }}
|
|
85
|
+
>
|
|
86
|
+
<NativeIcon name="clock" size={15} color={colors.secondaryLabel} />
|
|
87
|
+
<NativeText preset="caption" style={{ flex: 1, color: colors.secondaryLabel }}>
|
|
88
|
+
{text}
|
|
89
|
+
</NativeText>
|
|
90
|
+
</View>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── Your availability ─────────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
export interface DayHours {
|
|
97
|
+
on: boolean;
|
|
98
|
+
from: string;
|
|
99
|
+
to: string;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export type WeekHours = Record<Weekday, DayHours>;
|
|
103
|
+
|
|
104
|
+
/** Monday to Friday, 9 to 5: where a first setup starts. */
|
|
105
|
+
export function defaultWeek(): WeekHours {
|
|
106
|
+
return Object.fromEntries(WEEKDAYS.map((d) => [d, { on: d !== "sat" && d !== "sun", from: "09:00", to: "17:00" }])) as WeekHours;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Each day's first range from saved hours (a day with none is off). */
|
|
110
|
+
export function weekFromHours(hours: WorkingHours | null | undefined): WeekHours {
|
|
111
|
+
if (!hours) return defaultWeek();
|
|
112
|
+
return Object.fromEntries(
|
|
113
|
+
WEEKDAYS.map((d) => {
|
|
114
|
+
const first = hours[d]?.[0];
|
|
115
|
+
return [d, first ? { on: true, from: first[0], to: first[1] } : { on: false, from: "09:00", to: "17:00" }];
|
|
116
|
+
}),
|
|
117
|
+
) as WeekHours;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The hours to save: each day that's on, its range first, then any extra ranges saved elsewhere. */
|
|
121
|
+
export function hoursFromWeek(week: WeekHours, saved: WorkingHours | null | undefined): WorkingHours {
|
|
122
|
+
return Object.fromEntries(
|
|
123
|
+
WEEKDAYS.filter((d) => week[d].on).map((d) => [d, [[week[d].from, week[d].to] as [string, string], ...(saved?.[d] ?? []).slice(1)]]),
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A day that's on with the same start and end (the database refuses it). */
|
|
128
|
+
export function sameTimeDays(week: WeekHours): Weekday[] {
|
|
129
|
+
return WEEKDAYS.filter((d) => week[d].on && week[d].from === week[d].to);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** "09:30" as a Date today at that time (for a time picker), and back. */
|
|
133
|
+
export function clockToDate(hhmm: string, base: Date = new Date()): Date {
|
|
134
|
+
const m = minutesOf(hhmm) ?? 9 * 60;
|
|
135
|
+
const d = new Date(base);
|
|
136
|
+
d.setHours(Math.floor(m / 60) % 24, m % 60, 0, 0);
|
|
137
|
+
return d;
|
|
138
|
+
}
|
|
139
|
+
export function dateToClock(d: Date): string {
|
|
140
|
+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface AvailabilityForm {
|
|
144
|
+
message: string;
|
|
145
|
+
until: Date | null;
|
|
146
|
+
zone: string;
|
|
147
|
+
useHours: boolean;
|
|
148
|
+
week: WeekHours;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function formFrom(mine: ChatAvailability | null, deviceZone: string): AvailabilityForm {
|
|
152
|
+
return {
|
|
153
|
+
message: mine?.awayMessage ?? "",
|
|
154
|
+
until: mine?.awayUntil ? new Date(mine.awayUntil) : null,
|
|
155
|
+
zone: mine?.timeZone ?? deviceZone,
|
|
156
|
+
useHours: Boolean(mine?.hours && Object.keys(mine.hours).length),
|
|
157
|
+
week: weekFromHours(mine?.hours),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What `setAvailability` gets from the form. */
|
|
162
|
+
export function availabilityInput(form: AvailabilityForm, saved: ChatAvailability | null): AvailabilityInput {
|
|
163
|
+
return {
|
|
164
|
+
awayMessage: form.message.trim() || null,
|
|
165
|
+
awayUntil: form.until ? form.until.toISOString() : null,
|
|
166
|
+
timeZone: form.zone,
|
|
167
|
+
hours: form.useHours ? hoursFromWeek(form.week, saved?.hours) : null,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const FALLBACK_ZONES = [
|
|
172
|
+
"UTC", "America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles", "America/Toronto", "America/Sao_Paulo",
|
|
173
|
+
"Europe/London", "Europe/Paris", "Europe/Berlin", "Africa/Johannesburg", "Asia/Dubai", "Asia/Kolkata", "Asia/Singapore",
|
|
174
|
+
"Asia/Tokyo", "Australia/Sydney", "Pacific/Auckland",
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
/** Every IANA zone the engine knows (Hermes has `Intl.supportedValuesOf`), else a short list. */
|
|
178
|
+
export function timeZones(): string[] {
|
|
179
|
+
const intl = Intl as unknown as { supportedValuesOf?: (key: string) => string[] };
|
|
180
|
+
try {
|
|
181
|
+
const all = intl.supportedValuesOf?.("timeZone");
|
|
182
|
+
if (all?.length) return all.includes("UTC") ? all : ["UTC", ...all];
|
|
183
|
+
} catch {
|
|
184
|
+
// an older engine
|
|
185
|
+
}
|
|
186
|
+
return FALLBACK_ZONES;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function deviceTimeZone(): string {
|
|
190
|
+
try {
|
|
191
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
192
|
+
} catch {
|
|
193
|
+
return "UTC";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const DAY_NAMES: Record<Weekday, string> = { mon: "Monday", tue: "Tuesday", wed: "Wednesday", thu: "Thursday", fri: "Friday", sat: "Saturday", sun: "Sunday" };
|
|
198
|
+
|
|
199
|
+
/** A weekday's name in the device's language (`mon` → "Monday"). */
|
|
200
|
+
function dayName(d: Weekday, locale?: string): string {
|
|
201
|
+
// 2024-01-01 was a Monday.
|
|
202
|
+
const date = new Date(2024, 0, 1 + WEEKDAYS.indexOf(d));
|
|
203
|
+
try {
|
|
204
|
+
return date.toLocaleDateString(locale, { weekday: "long" });
|
|
205
|
+
} catch {
|
|
206
|
+
return DAY_NAMES[d];
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface ChatAvailabilityScreenProps {
|
|
211
|
+
/** Called after a save (go back, say). */
|
|
212
|
+
onSaved?: () => void;
|
|
213
|
+
testID?: string;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Your availability, as a Settings sub-page: an away message and, optionally, until when;
|
|
218
|
+
* working hours per weekday (one range a day here; extra ranges saved elsewhere are kept for
|
|
219
|
+
* the days you don't turn off); the time zone they're read in. Save at the bottom.
|
|
220
|
+
*/
|
|
221
|
+
export function ChatAvailabilityScreen({ onSaved, testID = "chat-availability" }: ChatAvailabilityScreenProps) {
|
|
222
|
+
const { copy } = useChatNative();
|
|
223
|
+
const colors = useThemeRoles();
|
|
224
|
+
const toast = useToast();
|
|
225
|
+
const { mine, mineLoading, setMine } = useAvailability();
|
|
226
|
+
const zones = useMemo(() => timeZones(), []);
|
|
227
|
+
const [form, setForm] = useState<AvailabilityForm>(() => formFrom(null, deviceTimeZone()));
|
|
228
|
+
const [loaded, setLoaded] = useState(false);
|
|
229
|
+
const [busy, setBusy] = useState(false);
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
if (mineLoading || loaded) return;
|
|
233
|
+
setForm(formFrom(mine, deviceTimeZone()));
|
|
234
|
+
setLoaded(true);
|
|
235
|
+
}, [mine, mineLoading, loaded]);
|
|
236
|
+
|
|
237
|
+
const zoneOptions = useMemo(() => {
|
|
238
|
+
const list = zones.includes(form.zone) ? zones : [form.zone, ...zones];
|
|
239
|
+
return list.map((z) => ({ value: z, label: z.replace(/_/g, " ") }));
|
|
240
|
+
}, [zones, form.zone]);
|
|
241
|
+
|
|
242
|
+
// A moment while yours is read (cached after the first time): the form fills in once it's here.
|
|
243
|
+
if (mineLoading || !loaded) {
|
|
244
|
+
return (
|
|
245
|
+
<SettingsSubPage testID={testID}>
|
|
246
|
+
<View testID={`${testID}-loading`} accessibilityState={{ busy: true }} />
|
|
247
|
+
</SettingsSubPage>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const bad = form.useHours ? sameTimeDays(form.week) : [];
|
|
252
|
+
const set = (patch: Partial<AvailabilityForm>) => setForm((f) => ({ ...f, ...patch }));
|
|
253
|
+
const setDay = (d: Weekday, patch: Partial<DayHours>) => setForm((f) => ({ ...f, week: { ...f.week, [d]: { ...f.week[d], ...patch } } }));
|
|
254
|
+
|
|
255
|
+
const save = async () => {
|
|
256
|
+
if (bad.length) return;
|
|
257
|
+
setBusy(true);
|
|
258
|
+
try {
|
|
259
|
+
await setMine(availabilityInput(form, mine));
|
|
260
|
+
toast.show({ title: copy.availabilitySaved, variant: "success", duration: 1500 });
|
|
261
|
+
onSaved?.();
|
|
262
|
+
} catch (e) {
|
|
263
|
+
toast.show({ title: e instanceof Error ? e.message : copy.actionFailed, variant: "destructive" });
|
|
264
|
+
} finally {
|
|
265
|
+
setBusy(false);
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const tomorrow = () => {
|
|
270
|
+
const d = new Date();
|
|
271
|
+
d.setDate(d.getDate() + 1);
|
|
272
|
+
d.setHours(9, 0, 0, 0);
|
|
273
|
+
return d;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
return (
|
|
277
|
+
<SettingsSubPage testID={testID}>
|
|
278
|
+
<View style={{ gap: 8 }}>
|
|
279
|
+
<Section title={copy.awayMessage} />
|
|
280
|
+
<NativeTextarea
|
|
281
|
+
testID={`${testID}-message`}
|
|
282
|
+
accessibilityLabel={copy.awayMessage}
|
|
283
|
+
value={form.message}
|
|
284
|
+
onChangeText={(message) => set({ message })}
|
|
285
|
+
maxLength={280}
|
|
286
|
+
placeholder={copy.awayMessagePlaceholder}
|
|
287
|
+
rows={3}
|
|
288
|
+
/>
|
|
289
|
+
<Row label={copy.awayUntilOn}>
|
|
290
|
+
<NativeSwitch testID={`${testID}-until-on`} accessibilityLabel={copy.awayUntilOn} checked={form.until !== null} onCheckedChange={(on) => set({ until: on ? tomorrow() : null })} />
|
|
291
|
+
</Row>
|
|
292
|
+
{form.until ? (
|
|
293
|
+
<Row label={copy.awayUntil}>
|
|
294
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
295
|
+
<NativeDatePicker testID={`${testID}-until-date`} value={form.until} onChange={(d) => set({ until: withDay(form.until!, d) })} />
|
|
296
|
+
<NativeTimePicker testID={`${testID}-until-time`} value={form.until} onChange={(d) => set({ until: withTime(form.until!, d) })} />
|
|
297
|
+
</View>
|
|
298
|
+
</Row>
|
|
299
|
+
) : null}
|
|
300
|
+
{form.message || form.until ? (
|
|
301
|
+
<View style={{ alignItems: "flex-start" }}>
|
|
302
|
+
<NativeButton testID={`${testID}-back`} variant="ghost" onPress={() => set({ message: "", until: null })}>
|
|
303
|
+
{copy.imBack}
|
|
304
|
+
</NativeButton>
|
|
305
|
+
</View>
|
|
306
|
+
) : null}
|
|
307
|
+
</View>
|
|
308
|
+
|
|
309
|
+
<View style={{ gap: 8 }}>
|
|
310
|
+
<Section title={copy.workingHours} />
|
|
311
|
+
<Row label={copy.workingHoursOn}>
|
|
312
|
+
<NativeSwitch testID={`${testID}-hours-on`} accessibilityLabel={copy.workingHoursOn} checked={form.useHours} onCheckedChange={(useHours) => set({ useHours })} />
|
|
313
|
+
</Row>
|
|
314
|
+
{form.useHours ? (
|
|
315
|
+
<View accessibilityLabel={copy.workingHours} style={{ gap: 4 }}>
|
|
316
|
+
{WEEKDAYS.map((d) => {
|
|
317
|
+
const name = dayName(d);
|
|
318
|
+
const day = form.week[d];
|
|
319
|
+
return (
|
|
320
|
+
<View key={d} testID={`${testID}-day-${d}`} style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 8, minHeight: 44 }}>
|
|
321
|
+
<NativeSwitch testID={`${testID}-day-${d}-on`} accessibilityLabel={fill(copy.workingDay, { name })} checked={day.on} onCheckedChange={(on) => setDay(d, { on })} />
|
|
322
|
+
<NativeText preset="body" style={{ minWidth: 96, color: day.on ? colors.label : colors.secondaryLabel }}>
|
|
323
|
+
{name}
|
|
324
|
+
</NativeText>
|
|
325
|
+
{day.on ? (
|
|
326
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
|
327
|
+
<NativeTimePicker testID={`${testID}-day-${d}-from`} value={clockToDate(day.from)} onChange={(t) => setDay(d, { from: dateToClock(t) })} />
|
|
328
|
+
<NativeText accessibilityElementsHidden importantForAccessibility="no">–</NativeText>
|
|
329
|
+
<NativeTimePicker testID={`${testID}-day-${d}-to`} value={clockToDate(day.to)} onChange={(t) => setDay(d, { to: dateToClock(t) })} />
|
|
330
|
+
</View>
|
|
331
|
+
) : (
|
|
332
|
+
<NativeText preset="caption" style={{ color: colors.secondaryLabel }}>
|
|
333
|
+
{copy.notWorking}
|
|
334
|
+
</NativeText>
|
|
335
|
+
)}
|
|
336
|
+
</View>
|
|
337
|
+
);
|
|
338
|
+
})}
|
|
339
|
+
<NativeText testID={`${testID}-hours-hint`} preset="caption" accessibilityLiveRegion="polite" style={{ color: bad.length ? colors.destructive : colors.secondaryLabel }}>
|
|
340
|
+
{bad.length ? copy.hoursSameTime : copy.workingHoursHint}
|
|
341
|
+
</NativeText>
|
|
342
|
+
</View>
|
|
343
|
+
) : null}
|
|
344
|
+
</View>
|
|
345
|
+
|
|
346
|
+
<View style={{ gap: 8 }}>
|
|
347
|
+
<Section title={copy.timeZone} />
|
|
348
|
+
<NativeCombobox testID={`${testID}-zone`} options={zoneOptions} value={form.zone} onChange={(zone) => set({ zone })} placeholder={copy.timeZoneSearch} />
|
|
349
|
+
</View>
|
|
350
|
+
|
|
351
|
+
<NativeButton testID={`${testID}-save`} onPress={() => void save()} loading={busy} disabled={bad.length > 0}>
|
|
352
|
+
{copy.availabilitySave}
|
|
353
|
+
</NativeButton>
|
|
354
|
+
</SettingsSubPage>
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function Section({ title }: { title: string }) {
|
|
359
|
+
const colors = useThemeRoles();
|
|
360
|
+
return (
|
|
361
|
+
<NativeText preset="label" accessibilityRole="header" style={{ color: colors.secondaryLabel, textTransform: "uppercase", letterSpacing: 0.5, marginTop: 8 }}>
|
|
362
|
+
{title}
|
|
363
|
+
</NativeText>
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function Row({ label, children }: { label: string; children: ReactNode }) {
|
|
368
|
+
return (
|
|
369
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: 8, minHeight: 44 }}>
|
|
370
|
+
<NativeText preset="body" style={{ flexShrink: 1 }}>
|
|
371
|
+
{label}
|
|
372
|
+
</NativeText>
|
|
373
|
+
{children}
|
|
374
|
+
</View>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* `ChatBroadcastSheet`: write one message and send it into many of your conversations at once
|
|
4
|
+
* (each person's own conversation with you), so each gets it on their own and their reply comes
|
|
5
|
+
* back to you privately. Pick conversations from the inbox (filter, select all), then send. Show
|
|
6
|
+
* it only to the people your app lets broadcast; the database still checks each conversation
|
|
7
|
+
* (`can_post`) and says how many it reached. The same rules as the web's `ChatBroadcast`.
|
|
8
|
+
*/
|
|
9
|
+
import { useMemo, useState } from "react";
|
|
10
|
+
import { Pressable, View } from "react-native";
|
|
11
|
+
import {
|
|
12
|
+
NativeAvatar,
|
|
13
|
+
NativeButton,
|
|
14
|
+
NativeCheckbox,
|
|
15
|
+
NativeEmptyState,
|
|
16
|
+
NativeInlineAlert,
|
|
17
|
+
NativeSearchBar,
|
|
18
|
+
NativeSheet,
|
|
19
|
+
NativeSheetScrollView,
|
|
20
|
+
NativeSkeleton,
|
|
21
|
+
NativeText,
|
|
22
|
+
NativeTextarea,
|
|
23
|
+
useToast,
|
|
24
|
+
} from "@marktiderman/genesis-ui-native/components";
|
|
25
|
+
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
26
|
+
import { CHAT_LIMITS } from "../core/media";
|
|
27
|
+
import type { ChatInboxItem } from "../core/types";
|
|
28
|
+
import { useBroadcast, useInbox, usePeople } from "../react/index";
|
|
29
|
+
import { fill } from "./copy";
|
|
30
|
+
import { initials, useChatNative, type ChatPerson } from "./context";
|
|
31
|
+
|
|
32
|
+
/** What a broadcast's toast says: all reached, one, or some of them. */
|
|
33
|
+
export function broadcastOutcome(sent: number, picked: number, copy: { broadcastSent: string; broadcastSentOne: string; broadcastSentSome: string }): { title: string; partial: boolean } {
|
|
34
|
+
if (sent < picked) return { title: fill(copy.broadcastSentSome, { count: sent, total: picked }), partial: true };
|
|
35
|
+
return { title: sent === 1 ? copy.broadcastSentOne : fill(copy.broadcastSent, { count: sent }), partial: false };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Rows to pick from: the filter's, named as the inbox names them, in name order, matching the query. */
|
|
39
|
+
export function broadcastRows(
|
|
40
|
+
items: readonly ChatInboxItem[],
|
|
41
|
+
opts: { filter?: (item: ChatInboxItem) => boolean; person: (id: string | null) => ChatPerson | undefined; untitled: string; query?: string },
|
|
42
|
+
): { item: ChatInboxItem; title: string; avatarUrl: string | null }[] {
|
|
43
|
+
const q = opts.query?.trim().toLowerCase() ?? "";
|
|
44
|
+
return items
|
|
45
|
+
.filter((i) => !i.archived && (opts.filter ? opts.filter(i) : true))
|
|
46
|
+
.map((i) => {
|
|
47
|
+
const other = i.otherUserId ? opts.person(i.otherUserId) : undefined;
|
|
48
|
+
return { item: i, title: i.title ?? other?.name ?? opts.untitled, avatarUrl: other?.avatarUrl ?? null };
|
|
49
|
+
})
|
|
50
|
+
.filter((r) => !q || r.title.toLowerCase().includes(q))
|
|
51
|
+
.sort((a, b) => a.title.localeCompare(b.title));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ChatBroadcastSheetProps {
|
|
55
|
+
open: boolean;
|
|
56
|
+
onOpenChange: (open: boolean) => void;
|
|
57
|
+
/** Only this kind of conversation (`direct`, say). */
|
|
58
|
+
kind?: string;
|
|
59
|
+
/** Which inbox rows may be picked; every one by default. */
|
|
60
|
+
filter?: (item: ChatInboxItem) => boolean;
|
|
61
|
+
/** Called with how many it went to, after a send. */
|
|
62
|
+
onSent?: (sent: number, picked: number) => void;
|
|
63
|
+
testID?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function ChatBroadcastSheet({ open, onOpenChange, kind, filter, onSent, testID = "chat-broadcast" }: ChatBroadcastSheetProps) {
|
|
67
|
+
const { copy, person: known } = useChatNative();
|
|
68
|
+
const colors = useThemeRoles();
|
|
69
|
+
const toast = useToast();
|
|
70
|
+
const inbox = useInbox({ kind, limit: 200 });
|
|
71
|
+
const { send, sending } = useBroadcast();
|
|
72
|
+
const people = usePeople(useMemo(() => inbox.items.map((i) => i.otherUserId), [inbox.items]));
|
|
73
|
+
const person = (id: string | null) => known(id) ?? (id ? people[id] : undefined);
|
|
74
|
+
const [picked, setPicked] = useState<Set<string>>(new Set());
|
|
75
|
+
const [text, setText] = useState("");
|
|
76
|
+
const [q, setQ] = useState("");
|
|
77
|
+
|
|
78
|
+
const all = broadcastRows(inbox.items, { filter, person, untitled: copy.untitled });
|
|
79
|
+
const shown = broadcastRows(inbox.items, { filter, person, untitled: copy.untitled, query: q });
|
|
80
|
+
const allShown = shown.length > 0 && shown.every((r) => picked.has(r.item.conversationId));
|
|
81
|
+
|
|
82
|
+
const toggle = (id: string) =>
|
|
83
|
+
setPicked((p) => {
|
|
84
|
+
const next = new Set(p);
|
|
85
|
+
if (next.has(id)) next.delete(id);
|
|
86
|
+
else next.add(id);
|
|
87
|
+
return next;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const submit = async () => {
|
|
91
|
+
const body = text.trim();
|
|
92
|
+
if (!body || picked.size === 0 || sending) return;
|
|
93
|
+
try {
|
|
94
|
+
const ids = await send([...picked], body);
|
|
95
|
+
const outcome = broadcastOutcome(ids.length, picked.size, copy);
|
|
96
|
+
toast.show({ title: outcome.title, variant: outcome.partial ? "default" : "success" });
|
|
97
|
+
onSent?.(ids.length, picked.size);
|
|
98
|
+
setText("");
|
|
99
|
+
setPicked(new Set());
|
|
100
|
+
onOpenChange(false);
|
|
101
|
+
} catch (e) {
|
|
102
|
+
toast.show({ title: e instanceof Error ? e.message : copy.actionFailed, variant: "destructive" });
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const count = picked.size === 0 ? copy.broadcastPickSome : picked.size === 1 ? copy.broadcastPickedOne : fill(copy.broadcastPicked, { count: picked.size });
|
|
107
|
+
return (
|
|
108
|
+
<NativeSheet open={open} onOpenChange={onOpenChange} snapPoints={["90%"]} testID={testID}>
|
|
109
|
+
<NativeSheetScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 16, gap: 12 }}>
|
|
110
|
+
<NativeText preset="h4" accessibilityRole="header">
|
|
111
|
+
{copy.broadcastTitle}
|
|
112
|
+
</NativeText>
|
|
113
|
+
<NativeInlineAlert variant="info" description={copy.broadcastExplainer} testID={`${testID}-explainer`} />
|
|
114
|
+
<NativeTextarea
|
|
115
|
+
testID={`${testID}-text`}
|
|
116
|
+
label={copy.broadcastMessage}
|
|
117
|
+
accessibilityLabel={copy.broadcastMessage}
|
|
118
|
+
value={text}
|
|
119
|
+
onChangeText={setText}
|
|
120
|
+
maxLength={CHAT_LIMITS.bodyChars}
|
|
121
|
+
placeholder={copy.broadcastPlaceholder}
|
|
122
|
+
rows={4}
|
|
123
|
+
/>
|
|
124
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
|
125
|
+
<NativeText preset="label" accessibilityRole="header">
|
|
126
|
+
{copy.broadcastTo}
|
|
127
|
+
</NativeText>
|
|
128
|
+
<NativeButton
|
|
129
|
+
testID={`${testID}-all`}
|
|
130
|
+
variant="ghost"
|
|
131
|
+
size="sm"
|
|
132
|
+
disabled={shown.length === 0}
|
|
133
|
+
onPress={() =>
|
|
134
|
+
setPicked((p) => {
|
|
135
|
+
const next = new Set(p);
|
|
136
|
+
for (const r of shown) {
|
|
137
|
+
if (allShown) next.delete(r.item.conversationId);
|
|
138
|
+
else next.add(r.item.conversationId);
|
|
139
|
+
}
|
|
140
|
+
return next;
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
>
|
|
144
|
+
{allShown ? copy.broadcastClear : copy.broadcastSelectAll}
|
|
145
|
+
</NativeButton>
|
|
146
|
+
</View>
|
|
147
|
+
<NativeSearchBar testID={`${testID}-filter`} value={q} onChange={setQ} placeholder={copy.broadcastFilter} showCancel={false} />
|
|
148
|
+
{inbox.isLoading ? (
|
|
149
|
+
<View testID={`${testID}-loading`} style={{ gap: 8 }}>
|
|
150
|
+
<NativeSkeleton height={44} className="w-full" />
|
|
151
|
+
<NativeSkeleton height={44} className="w-full" />
|
|
152
|
+
</View>
|
|
153
|
+
) : shown.length === 0 ? (
|
|
154
|
+
<NativeEmptyState variant="no-results" title={all.length ? copy.broadcastNoMatches : copy.broadcastNoConversations} testID={`${testID}-empty`} />
|
|
155
|
+
) : (
|
|
156
|
+
<View testID={`${testID}-list`} style={{ borderRadius: 12, overflow: "hidden", backgroundColor: colors.surface }}>
|
|
157
|
+
{shown.map(({ item, title, avatarUrl }) => {
|
|
158
|
+
const on = picked.has(item.conversationId);
|
|
159
|
+
return (
|
|
160
|
+
<Pressable
|
|
161
|
+
key={item.conversationId}
|
|
162
|
+
testID={`${testID}-row-${item.conversationId}`}
|
|
163
|
+
accessibilityRole="checkbox"
|
|
164
|
+
accessibilityLabel={title}
|
|
165
|
+
accessibilityState={{ checked: on }}
|
|
166
|
+
onPress={() => toggle(item.conversationId)}
|
|
167
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 52, paddingHorizontal: 12, paddingVertical: 6 }}
|
|
168
|
+
>
|
|
169
|
+
<View pointerEvents="none" accessibilityElementsHidden importantForAccessibility="no-hide-descendants">
|
|
170
|
+
<NativeCheckbox checked={on} />
|
|
171
|
+
</View>
|
|
172
|
+
<NativeAvatar size={32} src={avatarUrl} fallback={initials(title)} alt={title} recyclingKey={item.conversationId} />
|
|
173
|
+
<NativeText preset="body" numberOfLines={1} style={{ flex: 1 }}>
|
|
174
|
+
{title}
|
|
175
|
+
</NativeText>
|
|
176
|
+
</Pressable>
|
|
177
|
+
);
|
|
178
|
+
})}
|
|
179
|
+
</View>
|
|
180
|
+
)}
|
|
181
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12, paddingTop: 4 }}>
|
|
182
|
+
<NativeText testID={`${testID}-count`} preset="body-sm" accessibilityLiveRegion="polite" style={{ flex: 1, color: colors.secondaryLabel }}>
|
|
183
|
+
{count}
|
|
184
|
+
</NativeText>
|
|
185
|
+
<NativeButton testID={`${testID}-send`} onPress={() => void submit()} loading={sending} disabled={!text.trim() || picked.size === 0}>
|
|
186
|
+
{picked.size > 1 ? fill(copy.broadcastSendTo, { count: picked.size }) : copy.broadcastSend}
|
|
187
|
+
</NativeButton>
|
|
188
|
+
</View>
|
|
189
|
+
</NativeSheetScrollView>
|
|
190
|
+
</NativeSheet>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|