@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.
- 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,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Scheduled send on native. `ChatScheduleSheet`: tomorrow 9 AM, Monday 9 AM, or a date and time
|
|
4
|
+
* you pick (genesis-ui-native's NativeDatePicker and NativeTimePicker, which draw
|
|
5
|
+
* @react-native-community/datetimepicker, or @expo/ui's picker passed as GenesisNativeApp's
|
|
6
|
+
* `modules.datePicker`; without either the time reads as text and the quick picks still work).
|
|
7
|
+
* `ChatScheduledList`: your messages waiting to go, each with Cancel. `ChatScheduledBar`: "2
|
|
8
|
+
* scheduled messages" over the composer, opening that list. The thread opens the sheet when Send
|
|
9
|
+
* is touched and held. The same rules as the web's `ChatScheduleMenu` and `ChatScheduledList`.
|
|
10
|
+
*/
|
|
11
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
+
import { Pressable, View } from "react-native";
|
|
13
|
+
import {
|
|
14
|
+
NativeButton,
|
|
15
|
+
NativeDatePicker,
|
|
16
|
+
NativeEmptyState,
|
|
17
|
+
NativeIcon,
|
|
18
|
+
NativeListItem,
|
|
19
|
+
NativeSheet,
|
|
20
|
+
NativeSkeleton,
|
|
21
|
+
NativeText,
|
|
22
|
+
NativeTimePicker,
|
|
23
|
+
useToast,
|
|
24
|
+
} from "@marktiderman/genesis-ui-native/components";
|
|
25
|
+
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
26
|
+
import { isSchedulable, scheduleLabel, scheduleQuickPicks } from "../core/extras";
|
|
27
|
+
import { previewText } from "../core/thread";
|
|
28
|
+
import type { ScheduleInput, ScheduledMessage } from "../core/types";
|
|
29
|
+
import { useScheduled } from "../react/index";
|
|
30
|
+
import { toSend, type ComposerState } from "./composer-state";
|
|
31
|
+
import { haptic } from "./modules";
|
|
32
|
+
import type { ChatComposerHandle } from "./use-composer";
|
|
33
|
+
import { fill } from "./copy";
|
|
34
|
+
import { useChatNative } from "./context";
|
|
35
|
+
import { displayBody } from "./message-bubble";
|
|
36
|
+
|
|
37
|
+
/** An hour from now, on the next five minutes: where the custom picker starts. */
|
|
38
|
+
export function defaultScheduleAt(now: Date = new Date()): Date {
|
|
39
|
+
const d = new Date(now.getTime() + 60 * 60_000);
|
|
40
|
+
d.setSeconds(0, 0);
|
|
41
|
+
d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5);
|
|
42
|
+
return d;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `base` on the day of `day` (its time kept). */
|
|
46
|
+
export function withDay(base: Date, day: Date): Date {
|
|
47
|
+
const d = new Date(base);
|
|
48
|
+
d.setFullYear(day.getFullYear(), day.getMonth(), day.getDate());
|
|
49
|
+
return d;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `base` at the time of `time` (its day kept). */
|
|
53
|
+
export function withTime(base: Date, time: Date): Date {
|
|
54
|
+
const d = new Date(base);
|
|
55
|
+
d.setHours(time.getHours(), time.getMinutes(), 0, 0);
|
|
56
|
+
return d;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What the composer holds, as a message to schedule, or why it can't be: nothing to send, or
|
|
61
|
+
* files (a native file is a URI, and `useScheduled` uploads bytes only).
|
|
62
|
+
*/
|
|
63
|
+
export function scheduleInputFrom(
|
|
64
|
+
state: ComposerState,
|
|
65
|
+
opts: { conversationId: string; topic?: string | null; at: Date },
|
|
66
|
+
): { input: Omit<ScheduleInput, "clientId"> } | { error: "empty" | "files" } {
|
|
67
|
+
const { input, files } = toSend(state);
|
|
68
|
+
if (files.length) return { error: "files" };
|
|
69
|
+
if (!input.body) return { error: "empty" };
|
|
70
|
+
return { input: { conversationId: opts.conversationId, body: input.body, mentions: input.mentions, replyToId: input.replyToId, topic: opts.topic ?? null, sendAt: opts.at } };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Scheduled send from a thread's composer: `request` (Send held) checks there's text and no
|
|
75
|
+
* files and opens the sheet; `scheduleAt` schedules what's written, then empties the composer
|
|
76
|
+
* if it still holds just that. Pass `enabled: false` where scheduling isn't offered.
|
|
77
|
+
*/
|
|
78
|
+
export function useScheduleSend(opts: { conversationId: string; topic?: string | null; composer: ChatComposerHandle; enabled: boolean }) {
|
|
79
|
+
const { copy, modules } = useChatNative();
|
|
80
|
+
const toast = useToast();
|
|
81
|
+
const { schedule } = useScheduled(opts.conversationId, { enabled: opts.enabled });
|
|
82
|
+
const [open, setOpen] = useState(false);
|
|
83
|
+
const latest = useRef(opts);
|
|
84
|
+
latest.current = opts;
|
|
85
|
+
|
|
86
|
+
const request = useCallback(() => {
|
|
87
|
+
const check = scheduleInputFrom(latest.current.composer.state, { conversationId: latest.current.conversationId, at: new Date() });
|
|
88
|
+
if ("error" in check) {
|
|
89
|
+
if (check.error === "files") toast.show({ title: copy.scheduleNoFiles, variant: "destructive" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
haptic(modules.haptics, "select");
|
|
93
|
+
setOpen(true);
|
|
94
|
+
}, [toast, copy.scheduleNoFiles, modules.haptics]);
|
|
95
|
+
|
|
96
|
+
const scheduleAt = useCallback(
|
|
97
|
+
async (at: Date) => {
|
|
98
|
+
const { composer, conversationId, topic } = latest.current;
|
|
99
|
+
const made = scheduleInputFrom(composer.state, { conversationId, topic, at });
|
|
100
|
+
if ("error" in made) return;
|
|
101
|
+
try {
|
|
102
|
+
await schedule(made.input);
|
|
103
|
+
// More may have been typed while it saved: only what was scheduled is cleared.
|
|
104
|
+
const now = latest.current.composer;
|
|
105
|
+
if (now.state.text.trim() === made.input.body) now.take();
|
|
106
|
+
haptic(modules.haptics, "send");
|
|
107
|
+
toast.show({ title: fill(copy.scheduledFor, { when: scheduleLabel(at) }), variant: "success", duration: 2000 });
|
|
108
|
+
} catch (e) {
|
|
109
|
+
haptic(modules.haptics, "error");
|
|
110
|
+
toast.show({ title: e instanceof Error && e.message ? e.message : copy.scheduleFailed, variant: "destructive" });
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
[schedule, modules.haptics, toast, copy.scheduledFor, copy.scheduleFailed],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
return { open, setOpen, request, scheduleAt };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export interface ChatScheduleSheetProps {
|
|
120
|
+
open: boolean;
|
|
121
|
+
onOpenChange: (open: boolean) => void;
|
|
122
|
+
/** The time chosen; the sheet closes first. */
|
|
123
|
+
onSchedule: (at: Date) => void;
|
|
124
|
+
/** For tests and demos. */
|
|
125
|
+
now?: Date;
|
|
126
|
+
testID?: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function ChatScheduleSheet({ open, onOpenChange, onSchedule, now, testID = "chat-schedule" }: ChatScheduleSheetProps) {
|
|
130
|
+
const { copy } = useChatNative();
|
|
131
|
+
const colors = useThemeRoles();
|
|
132
|
+
const clock = now ?? new Date();
|
|
133
|
+
const picks = useMemo(() => scheduleQuickPicks(now ?? new Date()), [now, open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
134
|
+
const [custom, setCustom] = useState(false);
|
|
135
|
+
const [at, setAt] = useState(() => defaultScheduleAt(clock));
|
|
136
|
+
// Each opening starts again: the quick picks, and an hour from now for a custom time.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (!open) return;
|
|
139
|
+
setCustom(false);
|
|
140
|
+
setAt(defaultScheduleAt(now ?? new Date()));
|
|
141
|
+
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
142
|
+
const ok = isSchedulable(at, clock);
|
|
143
|
+
|
|
144
|
+
const choose = (when: Date) => {
|
|
145
|
+
onOpenChange(false);
|
|
146
|
+
setCustom(false);
|
|
147
|
+
onSchedule(when);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<NativeSheet
|
|
152
|
+
open={open}
|
|
153
|
+
onOpenChange={onOpenChange}
|
|
154
|
+
testID={testID}
|
|
155
|
+
>
|
|
156
|
+
<View style={{ paddingVertical: 8 }}>
|
|
157
|
+
<View style={{ paddingHorizontal: 16, paddingBottom: 8, gap: 4 }}>
|
|
158
|
+
<NativeText preset="h4" accessibilityRole="header">
|
|
159
|
+
{copy.scheduleTitle}
|
|
160
|
+
</NativeText>
|
|
161
|
+
<NativeText preset="caption" style={{ color: colors.secondaryLabel }}>
|
|
162
|
+
{copy.scheduleHint}
|
|
163
|
+
</NativeText>
|
|
164
|
+
</View>
|
|
165
|
+
{custom ? (
|
|
166
|
+
<View style={{ paddingHorizontal: 16, gap: 12 }} testID={`${testID}-custom`}>
|
|
167
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12, minHeight: 44 }}>
|
|
168
|
+
<NativeText preset="body">{copy.scheduleDate}</NativeText>
|
|
169
|
+
<NativeDatePicker testID={`${testID}-date`} value={at} onChange={(d) => setAt((x) => withDay(x, d))} />
|
|
170
|
+
</View>
|
|
171
|
+
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 12, minHeight: 44 }}>
|
|
172
|
+
<NativeText preset="body">{copy.scheduleTime}</NativeText>
|
|
173
|
+
<NativeTimePicker testID={`${testID}-time`} value={at} onChange={(d) => setAt((x) => withTime(x, d))} />
|
|
174
|
+
</View>
|
|
175
|
+
<NativeText testID={`${testID}-when`} preset="caption" accessibilityLiveRegion="polite" style={{ color: ok ? colors.secondaryLabel : colors.destructive }}>
|
|
176
|
+
{ok ? scheduleLabel(at, clock) : copy.scheduleOutOfRange}
|
|
177
|
+
</NativeText>
|
|
178
|
+
<NativeButton testID={`${testID}-confirm`} onPress={() => ok && choose(at)} disabled={!ok}>
|
|
179
|
+
{copy.scheduleConfirm}
|
|
180
|
+
</NativeButton>
|
|
181
|
+
</View>
|
|
182
|
+
) : (
|
|
183
|
+
<View>
|
|
184
|
+
{picks.map((p, i) => (
|
|
185
|
+
<NativeListItem key={p.label} testID={`${testID}-pick-${i}`} title={p.label} leading={<NativeIcon name="clock" />} onPress={() => choose(p.at)} />
|
|
186
|
+
))}
|
|
187
|
+
<NativeListItem testID={`${testID}-pick-custom`} title={copy.schedulePick} leading={<NativeIcon name="calendar" />} showChevron onPress={() => setCustom(true)} />
|
|
188
|
+
</View>
|
|
189
|
+
)}
|
|
190
|
+
</View>
|
|
191
|
+
</NativeSheet>
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface ChatScheduledListProps {
|
|
196
|
+
/** One conversation's; all of yours without it. */
|
|
197
|
+
conversationId?: string | null;
|
|
198
|
+
/** Names a conversation in the all-conversations list. */
|
|
199
|
+
conversationTitle?: (conversationId: string) => string;
|
|
200
|
+
testID?: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Your scheduled messages, soonest first, each with when it goes and Cancel. */
|
|
204
|
+
export function ChatScheduledList({ conversationId, conversationTitle, testID = "chat-scheduled" }: ChatScheduledListProps) {
|
|
205
|
+
const { copy } = useChatNative();
|
|
206
|
+
const toast = useToast();
|
|
207
|
+
const { items, isLoading, cancel } = useScheduled(conversationId);
|
|
208
|
+
if (isLoading) {
|
|
209
|
+
return (
|
|
210
|
+
<View testID={`${testID}-loading`} style={{ padding: 16, gap: 8 }}>
|
|
211
|
+
<NativeSkeleton height={44} className="w-full" />
|
|
212
|
+
<NativeSkeleton height={44} className="w-full" />
|
|
213
|
+
</View>
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (items.length === 0) return <NativeEmptyState variant="first-run" title={copy.scheduledEmpty} body={copy.scheduledEmptyBody} testID={`${testID}-empty`} />;
|
|
217
|
+
return (
|
|
218
|
+
<View testID={testID} accessibilityLabel={copy.scheduledTitle}>
|
|
219
|
+
{items.map((s) => (
|
|
220
|
+
<ScheduledRow
|
|
221
|
+
key={s.id}
|
|
222
|
+
item={s}
|
|
223
|
+
where={!conversationId && conversationTitle ? conversationTitle(s.conversationId) : null}
|
|
224
|
+
onCancel={() =>
|
|
225
|
+
void cancel(s.id).then(
|
|
226
|
+
() => toast.show({ title: copy.scheduledCancelled, variant: "success", duration: 1500 }),
|
|
227
|
+
() => toast.show({ title: copy.actionFailed, variant: "destructive" }),
|
|
228
|
+
)
|
|
229
|
+
}
|
|
230
|
+
testID={`${testID}-item-${s.id}`}
|
|
231
|
+
/>
|
|
232
|
+
))}
|
|
233
|
+
</View>
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function ScheduledRow({ item, where, onCancel, testID }: { item: ScheduledMessage; where: string | null; onCancel: () => void; testID: string }) {
|
|
238
|
+
const { copy } = useChatNative();
|
|
239
|
+
const colors = useThemeRoles();
|
|
240
|
+
const failed = item.status === "failed";
|
|
241
|
+
const when = failed ? copy.scheduledCouldntSend : scheduleLabel(item.sendAt);
|
|
242
|
+
const text = previewText(displayBody(item.body), item.attachments[0]?.kind ?? null);
|
|
243
|
+
return (
|
|
244
|
+
<View testID={testID} style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingLeft: 16, paddingRight: 4, paddingVertical: 8, minHeight: 56 }}>
|
|
245
|
+
<NativeIcon name={failed ? "warning" : "clock"} size={18} color={failed ? colors.destructive : colors.secondaryLabel} />
|
|
246
|
+
<View style={{ flex: 1, gap: 2 }} accessible accessibilityLabel={[when, where, text, item.error].filter(Boolean).join(", ")}>
|
|
247
|
+
<NativeText preset="caption" style={{ color: failed ? colors.destructive : colors.secondaryLabel, fontWeight: "600" }}>
|
|
248
|
+
{where ? `${when} · ${where}` : when}
|
|
249
|
+
</NativeText>
|
|
250
|
+
<NativeText preset="body-sm" numberOfLines={2}>
|
|
251
|
+
{text}
|
|
252
|
+
</NativeText>
|
|
253
|
+
{item.error ? (
|
|
254
|
+
<NativeText preset="caption" style={{ color: colors.destructive }}>
|
|
255
|
+
{item.error}
|
|
256
|
+
</NativeText>
|
|
257
|
+
) : null}
|
|
258
|
+
</View>
|
|
259
|
+
{item.status === "scheduled" || failed ? (
|
|
260
|
+
// A failed one can't be sent again, but it can be removed (cancel_scheduled takes it).
|
|
261
|
+
<Pressable
|
|
262
|
+
testID={`${testID}-cancel`}
|
|
263
|
+
accessibilityRole="button"
|
|
264
|
+
accessibilityLabel={failed ? `${copy.scheduledRemove}, ${text}` : `${copy.scheduledCancel}, ${fill(copy.scheduledFor, { when })}`}
|
|
265
|
+
onPress={onCancel}
|
|
266
|
+
style={{ minHeight: 44, minWidth: 44, paddingHorizontal: 12, alignItems: "center", justifyContent: "center" }}
|
|
267
|
+
>
|
|
268
|
+
<NativeText preset="label" style={{ color: colors.destructive }}>
|
|
269
|
+
{failed ? copy.scheduledRemove : copy.scheduledCancel}
|
|
270
|
+
</NativeText>
|
|
271
|
+
</Pressable>
|
|
272
|
+
) : null}
|
|
273
|
+
</View>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export interface ChatScheduledBarProps {
|
|
278
|
+
conversationId: string;
|
|
279
|
+
testID?: string;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** "2 scheduled messages" over the composer when there are any, opening their list. */
|
|
283
|
+
export function ChatScheduledBar({ conversationId, testID = "chat-scheduled-bar" }: ChatScheduledBarProps) {
|
|
284
|
+
const { copy } = useChatNative();
|
|
285
|
+
const colors = useThemeRoles();
|
|
286
|
+
const { items } = useScheduled(conversationId);
|
|
287
|
+
const [open, setOpen] = useState(false);
|
|
288
|
+
if (items.length === 0) return null;
|
|
289
|
+
const label = items.length === 1 ? copy.scheduledCountOne : fill(copy.scheduledCount, { count: items.length });
|
|
290
|
+
return (
|
|
291
|
+
<>
|
|
292
|
+
<Pressable
|
|
293
|
+
testID={testID}
|
|
294
|
+
accessibilityRole="button"
|
|
295
|
+
accessibilityLabel={label}
|
|
296
|
+
onPress={() => setOpen(true)}
|
|
297
|
+
style={{ flexDirection: "row", alignItems: "center", gap: 6, minHeight: 44, paddingHorizontal: 16, backgroundColor: colors.groupedBackground }}
|
|
298
|
+
>
|
|
299
|
+
<NativeIcon name="clock" size={15} color={colors.secondaryLabel} />
|
|
300
|
+
<NativeText preset="caption" style={{ color: colors.secondaryLabel, fontWeight: "600" }}>
|
|
301
|
+
{label}
|
|
302
|
+
</NativeText>
|
|
303
|
+
</Pressable>
|
|
304
|
+
<NativeSheet open={open} onOpenChange={setOpen} testID={`${testID}-sheet`}>
|
|
305
|
+
<View style={{ paddingVertical: 8 }}>
|
|
306
|
+
<NativeText preset="h4" accessibilityRole="header" style={{ paddingHorizontal: 16, paddingBottom: 4 }}>
|
|
307
|
+
{copy.scheduledTitle}
|
|
308
|
+
</NativeText>
|
|
309
|
+
<ChatScheduledList conversationId={conversationId} testID={`${testID}-list`} />
|
|
310
|
+
</View>
|
|
311
|
+
</NativeSheet>
|
|
312
|
+
</>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* `ChatSearchScreen`: a search field and its results, across all your conversations or in one.
|
|
4
|
+
* Each result says who, when and (across conversations) where, with the snippet's matched words
|
|
5
|
+
* highlighted, and a Transcript badge when it was found in a voice note's or video's transcript.
|
|
6
|
+
* Tapping one calls `onOpen`, which the app uses to open the thread at that message
|
|
7
|
+
* (`ChatThreadScreen jumpTo`, which loads earlier pages until it is there). The same rules as
|
|
8
|
+
* the web's `ChatSearch`.
|
|
9
|
+
*/
|
|
10
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
11
|
+
import { AccessibilityInfo, FlatList, Pressable, View } from "react-native";
|
|
12
|
+
import { SafeAreaView } from "react-native-safe-area-context";
|
|
13
|
+
import { NativeBadge, NativeEmptyState, NativeIcon, NativeSearchBar, NativeSkeleton, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
14
|
+
import { formatInboxTime } from "@marktiderman/genesis-ui-native/layouts";
|
|
15
|
+
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
16
|
+
import { snippetParts } from "../core/extras";
|
|
17
|
+
import type { ChatSearchHit } from "../core/types";
|
|
18
|
+
import { useChat, useInbox, usePeople, useSearch } from "../react/index";
|
|
19
|
+
import { fill } from "./copy";
|
|
20
|
+
import { useChatNative } from "./context";
|
|
21
|
+
|
|
22
|
+
/** A snippet with its « » marks drawn highlighted, inside one Text (so it wraps as one). */
|
|
23
|
+
export function SearchSnippet({ snippet, numberOfLines = 2, testID }: { snippet: string; numberOfLines?: number; testID?: string }) {
|
|
24
|
+
const colors = useThemeRoles();
|
|
25
|
+
return (
|
|
26
|
+
<NativeText testID={testID} preset="body-sm" numberOfLines={numberOfLines}>
|
|
27
|
+
{snippetParts(snippet).map((p, i) =>
|
|
28
|
+
p.match ? (
|
|
29
|
+
<NativeText key={i} preset="body-sm" style={{ fontWeight: "700", color: colors.label, backgroundColor: colors.fill }}>
|
|
30
|
+
{p.text}
|
|
31
|
+
</NativeText>
|
|
32
|
+
) : (
|
|
33
|
+
<NativeText key={i} preset="body-sm" style={{ color: colors.secondaryLabel }}>
|
|
34
|
+
{p.text}
|
|
35
|
+
</NativeText>
|
|
36
|
+
),
|
|
37
|
+
)}
|
|
38
|
+
</NativeText>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The snippet without its marks, for a screen reader. */
|
|
43
|
+
export function snippetText(snippet: string): string {
|
|
44
|
+
return snippetParts(snippet)
|
|
45
|
+
.map((p) => p.text)
|
|
46
|
+
.join("");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ChatSearchScreenProps {
|
|
50
|
+
/** Search one conversation; all of yours without it. */
|
|
51
|
+
conversationId?: string | null;
|
|
52
|
+
/** Called with the chosen result (open the thread at it). */
|
|
53
|
+
onOpen: (hit: ChatSearchHit) => void;
|
|
54
|
+
/** Names a conversation on results across conversations; the inbox's names by default. */
|
|
55
|
+
conversationTitle?: (conversationId: string) => string;
|
|
56
|
+
debounceMs?: number;
|
|
57
|
+
/** The clock, for tests. */
|
|
58
|
+
now?: () => Date;
|
|
59
|
+
testID?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ChatSearchScreen({ conversationId, onOpen, conversationTitle, debounceMs, now = () => new Date(), testID = "chat-search" }: ChatSearchScreenProps) {
|
|
63
|
+
const { copy, person: known } = useChatNative();
|
|
64
|
+
const { userId } = useChat();
|
|
65
|
+
const colors = useThemeRoles();
|
|
66
|
+
const [value, setValue] = useState("");
|
|
67
|
+
const { hits, query, isSearching } = useSearch(value, { conversationId, debounceMs });
|
|
68
|
+
// Names for "in …" across conversations, as the inbox shows them (one read, cached with it).
|
|
69
|
+
const inbox = useInbox({});
|
|
70
|
+
const ids = useMemo(() => [...hits.map((h) => h.senderId), ...inbox.items.map((i) => i.otherUserId)], [hits, inbox.items]);
|
|
71
|
+
const people = usePeople(ids);
|
|
72
|
+
const nameOf = useCallback(
|
|
73
|
+
(id: string | null) => (id === userId ? copy.you : ((known(id) ?? (id ? people[id] : undefined))?.name ?? copy.someone)),
|
|
74
|
+
[userId, copy, known, people],
|
|
75
|
+
);
|
|
76
|
+
const titleOf = useCallback(
|
|
77
|
+
(id: string) => {
|
|
78
|
+
if (conversationTitle) return conversationTitle(id);
|
|
79
|
+
const item = inbox.items.find((i) => i.conversationId === id);
|
|
80
|
+
return item?.title ?? (item?.otherUserId ? nameOf(item.otherUserId) : copy.untitled);
|
|
81
|
+
},
|
|
82
|
+
[conversationTitle, inbox.items, nameOf, copy.untitled],
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
const short = value.trim().length < 2;
|
|
86
|
+
const announce = short || isSearching ? "" : hits.length === 0 ? copy.searchNoResults : hits.length === 1 ? copy.searchResultsOne : fill(copy.searchResults, { count: hits.length });
|
|
87
|
+
// How many results, said once they settle (VoiceOver and TalkBack).
|
|
88
|
+
useEffect(() => {
|
|
89
|
+
if (announce) AccessibilityInfo.announceForAccessibility?.(announce);
|
|
90
|
+
}, [announce]);
|
|
91
|
+
return (
|
|
92
|
+
<SafeAreaView testID={testID} edges={["left", "right"]} style={{ flex: 1, backgroundColor: colors.groupedBackground }}>
|
|
93
|
+
<View style={{ paddingHorizontal: 16, paddingVertical: 8 }}>
|
|
94
|
+
<NativeSearchBar
|
|
95
|
+
testID={`${testID}-field`}
|
|
96
|
+
value={value}
|
|
97
|
+
onChange={setValue}
|
|
98
|
+
placeholder={conversationId ? copy.searchThreadPlaceholder : copy.searchPlaceholder}
|
|
99
|
+
cancelLabel={copy.cancel}
|
|
100
|
+
showCancel={false}
|
|
101
|
+
/>
|
|
102
|
+
</View>
|
|
103
|
+
{short ? (
|
|
104
|
+
<NativeText preset="caption" style={{ paddingHorizontal: 20, color: colors.secondaryLabel }} testID={`${testID}-hint`}>
|
|
105
|
+
{copy.searchHint}
|
|
106
|
+
</NativeText>
|
|
107
|
+
) : isSearching && hits.length === 0 ? (
|
|
108
|
+
<View testID={`${testID}-loading`} style={{ padding: 16, gap: 10 }}>
|
|
109
|
+
<NativeSkeleton height={52} className="w-full" />
|
|
110
|
+
<NativeSkeleton height={52} className="w-full" />
|
|
111
|
+
</View>
|
|
112
|
+
) : hits.length === 0 ? (
|
|
113
|
+
<NativeEmptyState variant="no-results" title={copy.searchNoResults} body={fill(copy.searchNoResultsBody, { query })} testID={`${testID}-empty`} />
|
|
114
|
+
) : (
|
|
115
|
+
<FlatList
|
|
116
|
+
testID={`${testID}-results`}
|
|
117
|
+
data={hits}
|
|
118
|
+
keyExtractor={(h) => `${h.messageId}:${h.inTranscript}`}
|
|
119
|
+
keyboardShouldPersistTaps="handled"
|
|
120
|
+
keyboardDismissMode="on-drag"
|
|
121
|
+
renderItem={({ item: h }) => {
|
|
122
|
+
const who = nameOf(h.senderId);
|
|
123
|
+
const where = conversationId ? null : fill(copy.searchIn, { name: titleOf(h.conversationId) });
|
|
124
|
+
const when = formatInboxTime(new Date(h.createdAt), now());
|
|
125
|
+
return (
|
|
126
|
+
<Pressable
|
|
127
|
+
testID={`${testID}-hit-${h.messageId}`}
|
|
128
|
+
accessibilityRole="button"
|
|
129
|
+
accessibilityLabel={[who, where, when, h.inTranscript ? copy.transcript : null, snippetText(h.snippet)].filter(Boolean).join(", ")}
|
|
130
|
+
onPress={() => onOpen(h)}
|
|
131
|
+
style={({ pressed }: { pressed: boolean }) => ({ minHeight: 56, paddingHorizontal: 16, paddingVertical: 10, gap: 4, backgroundColor: pressed ? colors.fill : "transparent" })}
|
|
132
|
+
>
|
|
133
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
134
|
+
<NativeText preset="label" numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
135
|
+
{who}
|
|
136
|
+
</NativeText>
|
|
137
|
+
{where ? (
|
|
138
|
+
<NativeText preset="caption" numberOfLines={1} style={{ flexShrink: 1, color: colors.secondaryLabel }}>
|
|
139
|
+
{where}
|
|
140
|
+
</NativeText>
|
|
141
|
+
) : null}
|
|
142
|
+
<NativeText preset="caption" style={{ marginLeft: "auto", color: colors.tertiaryLabel }}>
|
|
143
|
+
{when}
|
|
144
|
+
</NativeText>
|
|
145
|
+
</View>
|
|
146
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 6 }}>
|
|
147
|
+
{h.inTranscript ? (
|
|
148
|
+
<NativeBadge variant="muted" testID={`${testID}-transcript-${h.messageId}`}>
|
|
149
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 3 }}>
|
|
150
|
+
<NativeIcon name="mic" size={11} color={colors.secondaryLabel} />
|
|
151
|
+
<NativeText preset="caption" style={{ color: colors.secondaryLabel }}>
|
|
152
|
+
{copy.transcript}
|
|
153
|
+
</NativeText>
|
|
154
|
+
</View>
|
|
155
|
+
</NativeBadge>
|
|
156
|
+
) : null}
|
|
157
|
+
<View style={{ flex: 1 }}>
|
|
158
|
+
<SearchSnippet snippet={h.snippet} />
|
|
159
|
+
</View>
|
|
160
|
+
</View>
|
|
161
|
+
</Pressable>
|
|
162
|
+
);
|
|
163
|
+
}}
|
|
164
|
+
/>
|
|
165
|
+
)}
|
|
166
|
+
</SafeAreaView>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* `<Stack.Screen options>` for a screen that renders `ChatThreadScreen`.
|
|
4
|
+
*
|
|
5
|
+
* The bar is opaque. It was made so while the thread was an inverted list
|
|
6
|
+
* (`transform: scaleY(-1)` on the scroll view), which it no longer is; it
|
|
7
|
+
* stays opaque until a see-through bar is checked over the upright list. On
|
|
8
|
+
* iOS 26 and later the navigation bar is see-through and draws a scroll
|
|
9
|
+
* edge effect over the content scroll view under it; UIKit lays that effect
|
|
10
|
+
* out in the flipped scroll view's own coordinates, so once the thread is
|
|
11
|
+
* long enough to reach the bar the whole list renders blurred. An opaque bar
|
|
12
|
+
* keeps the screen's content below it: nothing scrolls under the bar, and no
|
|
13
|
+
* edge effect is drawn over the flipped list. Seen on iOS 27 with FlashList
|
|
14
|
+
* and with FlatList.
|
|
15
|
+
*/
|
|
16
|
+
import type { ColorValue } from "react-native";
|
|
17
|
+
import { getThreadPageStackOptions, type ScreenHeaderProps } from "@marktiderman/genesis-ui-native/layouts";
|
|
18
|
+
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
19
|
+
|
|
20
|
+
export type ChatThreadStackOptionsProps = ScreenHeaderProps & { headerTitle?: string };
|
|
21
|
+
|
|
22
|
+
/** The thread's header options with an opaque bar in `background` (the thread's own background). */
|
|
23
|
+
export function chatThreadStackOptions(props: ChatThreadStackOptionsProps, background: ColorValue): Record<string, unknown> {
|
|
24
|
+
return getThreadPageStackOptions({
|
|
25
|
+
...props,
|
|
26
|
+
// Large titles collapse into the content scroll view, which is the flipped list here.
|
|
27
|
+
large: false,
|
|
28
|
+
headerOptions: { headerTransparent: false, headerStyle: { backgroundColor: background }, ...props.headerOptions },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `chatThreadStackOptions` in the theme's grouped background, the color `ChatThreadScreen` draws. */
|
|
33
|
+
export function useChatThreadStackOptions(props: ChatThreadStackOptionsProps): Record<string, unknown> {
|
|
34
|
+
const colors = useThemeRoles();
|
|
35
|
+
return chatThreadStackOptions(props, colors.groupedBackground);
|
|
36
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* Starting a conversation: a header button, and a sheet listing the people
|
|
4
|
+
* the app says you may message (its own query; the kind's start check decides
|
|
5
|
+
* on the server). Picking someone starts the conversation, or finds the one
|
|
6
|
+
* you already have, and opens it.
|
|
7
|
+
*/
|
|
8
|
+
import { useState } from "react";
|
|
9
|
+
import { Pressable, View } from "react-native";
|
|
10
|
+
import { NativeAvatar, NativeIcon, NativeListItem, NativeSheet, NativeSheetScrollView, NativeText, useToast } from "@marktiderman/genesis-ui-native/components";
|
|
11
|
+
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
12
|
+
import { useStartConversation } from "../react/index";
|
|
13
|
+
import { initials, useChatNative } from "./context";
|
|
14
|
+
|
|
15
|
+
export interface ChatStartPerson {
|
|
16
|
+
userId: string;
|
|
17
|
+
name: string;
|
|
18
|
+
avatarUrl?: string | null;
|
|
19
|
+
/** A second line ("Coach", "Team"). */
|
|
20
|
+
subtitle?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ChatStartSheetProps {
|
|
24
|
+
open: boolean;
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
people: ChatStartPerson[];
|
|
27
|
+
/** Default `direct`. */
|
|
28
|
+
kind?: string;
|
|
29
|
+
onStarted: (conversationId: string) => void;
|
|
30
|
+
testID?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ChatStartSheet({ open, onClose, people, kind = "direct", onStarted, testID = "chat-start" }: ChatStartSheetProps) {
|
|
34
|
+
const { copy } = useChatNative();
|
|
35
|
+
const colors = useThemeRoles();
|
|
36
|
+
const start = useStartConversation();
|
|
37
|
+
const toast = useToast();
|
|
38
|
+
const [busy, setBusy] = useState<string | null>(null);
|
|
39
|
+
|
|
40
|
+
const pick = async (person: ChatStartPerson) => {
|
|
41
|
+
if (busy) return;
|
|
42
|
+
setBusy(person.userId);
|
|
43
|
+
try {
|
|
44
|
+
const id = await start(kind, [person.userId]);
|
|
45
|
+
onClose();
|
|
46
|
+
onStarted(id);
|
|
47
|
+
} catch {
|
|
48
|
+
toast.show({ title: copy.actionFailed, variant: "destructive" });
|
|
49
|
+
} finally {
|
|
50
|
+
setBusy(null);
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<NativeSheet open={open} onOpenChange={(o) => (o ? undefined : onClose())} testID={testID}>
|
|
56
|
+
<NativeText preset="h4" accessibilityRole="header" style={{ paddingHorizontal: 4, paddingBottom: 8, color: colors.label }}>
|
|
57
|
+
{copy.startConversationTitle}
|
|
58
|
+
</NativeText>
|
|
59
|
+
<NativeSheetScrollView>
|
|
60
|
+
<View>
|
|
61
|
+
{people.map((p) => (
|
|
62
|
+
<NativeListItem
|
|
63
|
+
key={p.userId}
|
|
64
|
+
testID={`${testID}-person-${p.userId}`}
|
|
65
|
+
title={p.name}
|
|
66
|
+
subtitle={p.subtitle}
|
|
67
|
+
leading={<NativeAvatar size={36} src={p.avatarUrl ?? null} fallback={initials(p.name)} alt={p.name} />}
|
|
68
|
+
disabled={busy !== null}
|
|
69
|
+
onPress={() => void pick(p)}
|
|
70
|
+
/>
|
|
71
|
+
))}
|
|
72
|
+
</View>
|
|
73
|
+
</NativeSheetScrollView>
|
|
74
|
+
</NativeSheet>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A 44pt compose button for the inbox's header (`headerRight`). */
|
|
79
|
+
export function ChatStartButton({ onPress, testID = "chat-start-button" }: { onPress: () => void; testID?: string }) {
|
|
80
|
+
const { copy } = useChatNative();
|
|
81
|
+
const colors = useThemeRoles();
|
|
82
|
+
return (
|
|
83
|
+
<Pressable testID={testID} accessibilityRole="button" accessibilityLabel={copy.startConversation} onPress={onPress} hitSlop={6} style={{ width: 44, height: 44, alignItems: "center", justifyContent: "center" }}>
|
|
84
|
+
<NativeIcon name="compose" size={22} color={colors.tint} />
|
|
85
|
+
</Pressable>
|
|
86
|
+
);
|
|
87
|
+
}
|