@marktiderman/genesis-chat 0.2.0 → 0.2.2
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 +82 -7
- package/dist/{chunk-5D5A7I4Q.js → chunk-A4YJX7KT.js} +130 -16
- 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-7S6HYNWW.js → chunk-GBJ3YI4E.js} +72 -5
- package/dist/chunk-GBJ3YI4E.js.map +1 -0
- package/dist/{chunk-PZTOY2LY.js → chunk-JU2FAPBI.js} +46 -13
- package/dist/chunk-JU2FAPBI.js.map +1 -0
- package/dist/{client-DMa2j7kz.d.ts → client-DUIG1hum.d.ts} +36 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/native.d.ts +36 -7
- package/dist/native.js +239 -116
- package/dist/native.js.map +1 -1
- package/dist/{outbox-B0y3phE0.d.ts → outbox-B9NAWQ58.d.ts} +74 -6
- package/dist/push.d.ts +2 -2
- package/dist/react.d.ts +48 -21
- package/dist/react.js +3 -3
- package/dist/server.d.ts +43 -2
- package/dist/server.js +115 -15
- package/dist/server.js.map +1 -1
- package/dist/{thread-DWXHKPdw.d.ts → thread-oPra7pkW.d.ts} +1 -1
- package/dist/web.d.ts +31 -11
- package/dist/web.js +97 -48
- package/dist/web.js.map +1 -1
- package/migrations/0003_genesis_chat_search_threads.sql +85 -0
- package/migrations/tests/0002_genesis_chat_coaching.test.sql +7 -3
- package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
- package/package.json +13 -9
- package/src/__tests__/coaching-core.test.ts +3 -1
- package/src/__tests__/coaching-react.test.tsx +73 -2
- package/src/__tests__/coaching-web.test.tsx +78 -6
- package/src/__tests__/fake-xhr.ts +54 -0
- package/src/__tests__/outbox-hosted.test.ts +269 -0
- package/src/__tests__/review-279.test.ts +41 -0
- package/src/__tests__/review-fixes-web.test.tsx +13 -10
- package/src/__tests__/server-fake-video.test.ts +81 -0
- package/src/__tests__/server-fakes.ts +7 -1
- package/src/__tests__/server-safety.test.ts +17 -4
- package/src/__tests__/web.test.tsx +16 -0
- package/src/core/client.ts +28 -2
- package/src/core/outbox.ts +222 -16
- package/src/core/transfer.ts +71 -0
- package/src/core/types.ts +17 -0
- package/src/index.ts +1 -0
- package/src/native/__tests__/emoji.test.tsx +16 -0
- package/src/native/__tests__/organise-screens.test.tsx +80 -0
- package/src/native/__tests__/video-media.test.tsx +81 -4
- package/src/native/attachment-cards.tsx +22 -6
- package/src/native/inbox-screen.tsx +12 -6
- package/src/native/index.ts +2 -2
- package/src/native/message-actions-sheet.tsx +10 -5
- package/src/native/message-bubble.tsx +3 -11
- package/src/native/modules.ts +2 -0
- package/src/native/schedule-sheet.tsx +23 -9
- package/src/native/search-screen.tsx +26 -5
- package/src/native/thread-screen.tsx +30 -3
- package/src/native/topics.tsx +70 -15
- package/src/native/video-player.tsx +46 -6
- package/src/native/video-recorder.tsx +6 -3
- package/src/native/voice-player.tsx +18 -0
- package/src/react/index.tsx +103 -19
- package/src/react/previews.ts +51 -0
- package/src/server/fake-video.ts +171 -0
- package/src/server/index.ts +1 -0
- package/src/web/attachments.tsx +6 -6
- package/src/web/bubble.tsx +22 -0
- package/src/web/inbox.tsx +7 -6
- package/src/web/memory-client.ts +11 -3
- package/src/web/panel.tsx +2 -2
- package/src/web/schedule.tsx +14 -2
- package/src/web/thread.tsx +36 -8
- package/src/web/video.tsx +28 -34
- package/dist/chunk-5D5A7I4Q.js.map +0 -1
- package/dist/chunk-7S6HYNWW.js.map +0 -1
- package/dist/chunk-PZTOY2LY.js.map +0 -1
package/src/native/topics.tsx
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* name field). `ChatThreadScreen` shows it when the conversation has topics, and reads the
|
|
7
7
|
* messages of the topic chosen. The same rules as the web's `ChatTopicBar`.
|
|
8
8
|
*/
|
|
9
|
-
import { useState } from "react";
|
|
10
|
-
import { ScrollView, View } from "react-native";
|
|
9
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
10
|
+
import { ScrollView, View, type LayoutChangeEvent } from "react-native";
|
|
11
11
|
import { NativeButton, NativeChip, NativeChipGroup, NativeIcon, NativeInput, NativeListItem, NativeSheet, NativeText, useToast } from "@marktiderman/genesis-ui-native/components";
|
|
12
12
|
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
13
13
|
import { topicKey } from "../core/extras";
|
|
@@ -48,6 +48,17 @@ export function checkTopicName(title: string, topics: readonly ChatTopic[], rena
|
|
|
48
48
|
return topics.some((t) => t.topic === key) ? { error: "exists" } : { key };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Where the bar scrolls to so a chip shows whole (with `margin` to spare), or null when it already
|
|
53
|
+
* does: a chip past the right edge comes in from the right, one before the left from the left.
|
|
54
|
+
*/
|
|
55
|
+
export function scrollToShow(chip: { x: number; width: number }, view: { offset: number; width: number }, margin = 8): number | null {
|
|
56
|
+
if (view.width <= 0) return null;
|
|
57
|
+
if (chip.x - margin < view.offset) return Math.max(0, chip.x - margin);
|
|
58
|
+
if (chip.x + chip.width + margin > view.offset + view.width) return Math.max(0, chip.x + chip.width + margin - view.width);
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
export interface ChatTopicBarProps {
|
|
52
63
|
topics: ChatTopic[];
|
|
53
64
|
/** The topic shown; null for "All". */
|
|
@@ -72,6 +83,31 @@ export function ChatTopicBar({ topics, value, onValueChange, canManage = false,
|
|
|
72
83
|
const [error, setError] = useState<string | null>(null);
|
|
73
84
|
const current = value ? (topics.find((t) => t.topic === value) ?? null) : null;
|
|
74
85
|
|
|
86
|
+
// The chosen chip is scrolled into view: a topic just added sits at the end, past the edge.
|
|
87
|
+
const scrollRef = useRef<{ scrollTo?: (o: { x: number; animated?: boolean }) => void } | null>(null);
|
|
88
|
+
const chips = useRef(new Map<string, { x: number; width: number }>());
|
|
89
|
+
const view = useRef({ offset: 0, width: 0 });
|
|
90
|
+
const reveal = useCallback((key: string | null) => {
|
|
91
|
+
const chip = chips.current.get(key ?? ALL);
|
|
92
|
+
if (!chip) return;
|
|
93
|
+
const to = scrollToShow(chip, view.current);
|
|
94
|
+
if (to === null) return;
|
|
95
|
+
view.current.offset = to;
|
|
96
|
+
scrollRef.current?.scrollTo?.({ x: to, animated: true });
|
|
97
|
+
}, []);
|
|
98
|
+
const latestValue = useRef(value);
|
|
99
|
+
latestValue.current = value;
|
|
100
|
+
useEffect(() => reveal(value), [value, reveal]);
|
|
101
|
+
const trackOffset = (e: { nativeEvent: { contentOffset: { x: number } } }) => {
|
|
102
|
+
view.current.offset = e.nativeEvent.contentOffset.x;
|
|
103
|
+
};
|
|
104
|
+
const onChipLayout = (key: string) => (e: LayoutChangeEvent) => {
|
|
105
|
+
const { x, width } = e.nativeEvent.layout;
|
|
106
|
+
chips.current.set(key, { x, width });
|
|
107
|
+
// A new topic is chosen before its chip exists: it is shown once the chip has its place.
|
|
108
|
+
if (key === (latestValue.current ?? ALL)) reveal(latestValue.current);
|
|
109
|
+
};
|
|
110
|
+
|
|
75
111
|
const close = () => {
|
|
76
112
|
if (busy) return;
|
|
77
113
|
setSheet(null);
|
|
@@ -117,25 +153,44 @@ export function ChatTopicBar({ topics, value, onValueChange, canManage = false,
|
|
|
117
153
|
|
|
118
154
|
return (
|
|
119
155
|
<View testID={testID} style={{ flexDirection: "row", alignItems: "center", backgroundColor: colors.groupedBackground, paddingLeft: 8 }}>
|
|
120
|
-
<ScrollView
|
|
156
|
+
<ScrollView
|
|
157
|
+
ref={scrollRef as never}
|
|
158
|
+
horizontal
|
|
159
|
+
showsHorizontalScrollIndicator={false}
|
|
160
|
+
accessibilityLabel={copy.topics}
|
|
161
|
+
contentContainerStyle={{ paddingVertical: 4, paddingRight: 8 }}
|
|
162
|
+
style={{ flex: 1 }}
|
|
163
|
+
onLayout={(e: LayoutChangeEvent) => {
|
|
164
|
+
view.current.width = e.nativeEvent.layout.width;
|
|
165
|
+
reveal(latestValue.current);
|
|
166
|
+
}}
|
|
167
|
+
onScroll={trackOffset}
|
|
168
|
+
onScrollEndDrag={trackOffset}
|
|
169
|
+
onMomentumScrollEnd={trackOffset}
|
|
170
|
+
scrollEventThrottle={64}
|
|
171
|
+
>
|
|
121
172
|
<NativeChipGroup mode="single" value={value ?? ALL} onChange={(v) => onValueChange(v === ALL ? null : (v as string))} className="flex-nowrap" testID={`${testID}-group`}>
|
|
122
|
-
<
|
|
123
|
-
{
|
|
124
|
-
|
|
125
|
-
{topics.map((t) => (
|
|
126
|
-
<NativeChip key={t.topic} value={t.topic} testID={`${testID}-${t.topic}`}>
|
|
127
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
|
128
|
-
{t.closedAt ? <NativeIcon name="lock" size={13} color={colors.secondaryLabel} label={copy.topicClosed} /> : null}
|
|
129
|
-
<NativeText preset="body-sm" numberOfLines={1} style={{ maxWidth: 180, fontWeight: value === t.topic ? "600" : "400", color: value === t.topic ? colors.tint : colors.label }}>
|
|
130
|
-
{t.title}
|
|
131
|
-
</NativeText>
|
|
132
|
-
</View>
|
|
173
|
+
<View onLayout={onChipLayout(ALL)}>
|
|
174
|
+
<NativeChip value={ALL} testID={`${testID}-all`}>
|
|
175
|
+
{copy.topicAll}
|
|
133
176
|
</NativeChip>
|
|
177
|
+
</View>
|
|
178
|
+
{topics.map((t) => (
|
|
179
|
+
<View key={t.topic} onLayout={onChipLayout(t.topic)}>
|
|
180
|
+
<NativeChip value={t.topic} testID={`${testID}-${t.topic}`}>
|
|
181
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 4 }}>
|
|
182
|
+
{t.closedAt ? <NativeIcon name="lock" size={13} color={colors.secondaryLabel} label={copy.topicClosed} /> : null}
|
|
183
|
+
<NativeText preset="body-sm" numberOfLines={1} style={{ maxWidth: 180, fontWeight: value === t.topic ? "600" : "400", color: value === t.topic ? colors.tint : colors.label }}>
|
|
184
|
+
{t.title}
|
|
185
|
+
</NativeText>
|
|
186
|
+
</View>
|
|
187
|
+
</NativeChip>
|
|
188
|
+
</View>
|
|
134
189
|
))}
|
|
135
190
|
</NativeChipGroup>
|
|
136
191
|
</ScrollView>
|
|
137
192
|
{canManage && onSetTopic ? <ChatIconButton testID={`${testID}-menu`} icon="more" label={copy.topicOptions} onPress={() => setSheet({ step: "menu" })} /> : null}
|
|
138
|
-
<NativeSheet open={sheet !== null} onOpenChange={(o) => (o ? undefined : close())} testID={`${testID}-sheet`}>
|
|
193
|
+
<NativeSheet open={sheet !== null} onOpenChange={(o) => (o ? undefined : close())} fitContent testID={`${testID}-sheet`}>
|
|
139
194
|
{sheet?.step === "name" ? (
|
|
140
195
|
<View style={{ padding: 16, gap: 12 }}>
|
|
141
196
|
<NativeText preset="h4" accessibilityRole="header">
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* playback id; a stored one plays from its signed link (or, while it uploads, from the
|
|
7
7
|
* phone). Until Mux has processed it, it says so. One plays at a time, voice notes included:
|
|
8
8
|
* it takes the same playback slot (`claimPlayback`), so starting it pauses a voice note and
|
|
9
|
-
* the other way round. The bubble's VoiceOver actions reach it by id (`toggleVideo`).
|
|
9
|
+
* the other way round. The bubble's VoiceOver actions reach it by id (`toggleVideo`). It pauses
|
|
10
|
+
* when the app goes to the background and when it unmounts; the thread pauses it when the
|
|
11
|
+
* screen loses focus (`ChatThreadScreen focused`), since a tab switch keeps the screen mounted.
|
|
10
12
|
*/
|
|
11
13
|
import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
12
|
-
import { ActivityIndicator, Pressable, View, useWindowDimensions } from "react-native";
|
|
14
|
+
import { ActivityIndicator, AppState, Pressable, View, useWindowDimensions } from "react-native";
|
|
13
15
|
import { NativeIcon, NativeImage, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
14
16
|
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
15
17
|
import { formatDuration, nextPlaybackRate } from "../core/media";
|
|
@@ -267,10 +269,18 @@ function ActiveVideo({
|
|
|
267
269
|
const [muted, setMuted] = useState(false);
|
|
268
270
|
const [rate, setRate] = useState(1);
|
|
269
271
|
const ended = useRef(false);
|
|
272
|
+
// Full screen on Android is another activity: the app reads as backgrounded while it shows.
|
|
273
|
+
const fullScreenOpen = useRef(false);
|
|
274
|
+
const setUp = useRef(false);
|
|
270
275
|
|
|
271
276
|
useEffect(() => {
|
|
272
|
-
|
|
273
|
-
|
|
277
|
+
// The first time, the player was just told to play; set up again (a remount that keeps the
|
|
278
|
+
// player), it says for itself whether it plays.
|
|
279
|
+
const now = setUp.current ? Boolean(player.playing) : true;
|
|
280
|
+
setUp.current = true;
|
|
281
|
+
setPlaying(now);
|
|
282
|
+
setPlayingId(id, now);
|
|
283
|
+
if (now) claimPlayback(id, () => player.pause());
|
|
274
284
|
const subs = [
|
|
275
285
|
player.addListener("playingChange", ((payload: { isPlaying: boolean }) => {
|
|
276
286
|
setPlaying(payload.isPlaying);
|
|
@@ -290,8 +300,24 @@ function ActiveVideo({
|
|
|
290
300
|
if (payload.status === "error") latestBroken.current();
|
|
291
301
|
}) as (payload: never) => void),
|
|
292
302
|
];
|
|
303
|
+
// Not on in the background: a video plays only while you can see it (full screen aside).
|
|
304
|
+
const appSub = AppState?.addEventListener?.("change", (next: string) => {
|
|
305
|
+
if (next === "background" && !fullScreenOpen.current) {
|
|
306
|
+
try {
|
|
307
|
+
player.pause();
|
|
308
|
+
} catch {
|
|
309
|
+
// A player already released has nothing to pause.
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
});
|
|
293
313
|
return () => {
|
|
294
314
|
subs.forEach((s) => s.remove());
|
|
315
|
+
appSub?.remove?.();
|
|
316
|
+
try {
|
|
317
|
+
player.pause();
|
|
318
|
+
} catch {
|
|
319
|
+
// Released with the view: nothing left to pause.
|
|
320
|
+
}
|
|
295
321
|
setPlayingId(id, false);
|
|
296
322
|
releasePlayback(id);
|
|
297
323
|
};
|
|
@@ -320,9 +346,23 @@ function ActiveVideo({
|
|
|
320
346
|
};
|
|
321
347
|
handles.current = { toggle, fullScreen, mute: toggleMute, rate: cycleRate };
|
|
322
348
|
const { VideoView } = video;
|
|
323
|
-
|
|
349
|
+
return (
|
|
324
350
|
<View testID={`${testID}-active`} style={style}>
|
|
325
|
-
<VideoView
|
|
351
|
+
<VideoView
|
|
352
|
+
ref={viewRef}
|
|
353
|
+
player={player}
|
|
354
|
+
nativeControls={false}
|
|
355
|
+
contentFit="cover"
|
|
356
|
+
allowsPictureInPicture={false}
|
|
357
|
+
onFullscreenEnter={() => {
|
|
358
|
+
fullScreenOpen.current = true;
|
|
359
|
+
}}
|
|
360
|
+
onFullscreenExit={() => {
|
|
361
|
+
fullScreenOpen.current = false;
|
|
362
|
+
}}
|
|
363
|
+
style={{ width: "100%", height: "100%" }}
|
|
364
|
+
testID={`${testID}-view`}
|
|
365
|
+
/>
|
|
326
366
|
<Pressable
|
|
327
367
|
testID={`${testID}-surface`}
|
|
328
368
|
accessibilityRole="button"
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
10
10
|
import { AccessibilityInfo, Linking, Modal, Pressable, View } from "react-native";
|
|
11
|
-
import {
|
|
11
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
12
12
|
import { NativeButton, NativeIcon, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
13
13
|
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
14
14
|
import { formatDuration } from "../core/media";
|
|
@@ -49,6 +49,9 @@ export function ChatVideoRecorder(props: ChatVideoRecorderProps) {
|
|
|
49
49
|
|
|
50
50
|
function RecorderBody({ camera, onClose, limitMs, onStart, onTick, onRecorded, now = Date.now, testID }: ChatVideoRecorderProps & { camera: ExpoCameraLike; testID: string }) {
|
|
51
51
|
const { copy, modules } = useChatNative();
|
|
52
|
+
// The app's insets, as padding: a SafeAreaView inside the full-screen Modal measured none on
|
|
53
|
+
// iOS 27, which put Close and the timer under the status bar (the photo viewer does the same).
|
|
54
|
+
const insets = useSafeAreaInsets();
|
|
52
55
|
const colors = useThemeRoles();
|
|
53
56
|
const [state, setState] = useState<VideoRecorderState>(() => initialVideoRecorder());
|
|
54
57
|
const stateRef = useRef(state);
|
|
@@ -171,7 +174,7 @@ function RecorderBody({ camera, onClose, limitMs, onStart, onTick, onRecorded, n
|
|
|
171
174
|
onMountError={() => dispatch({ type: "camera-failed" })}
|
|
172
175
|
/>
|
|
173
176
|
) : null}
|
|
174
|
-
<
|
|
177
|
+
<View style={{ flex: 1, justifyContent: "space-between", paddingTop: insets.top, paddingBottom: insets.bottom, paddingLeft: insets.left, paddingRight: insets.right }}>
|
|
175
178
|
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 8, paddingTop: 4 }}>
|
|
176
179
|
<Pressable
|
|
177
180
|
testID={`${testID}-close`}
|
|
@@ -265,7 +268,7 @@ function RecorderBody({ camera, onClose, limitMs, onStart, onTick, onRecorded, n
|
|
|
265
268
|
</NativeText>
|
|
266
269
|
) : null}
|
|
267
270
|
</View>
|
|
268
|
-
</
|
|
271
|
+
</View>
|
|
269
272
|
</View>
|
|
270
273
|
);
|
|
271
274
|
}
|
|
@@ -82,6 +82,24 @@ export function cycleVoiceNoteRate(id: string): boolean {
|
|
|
82
82
|
return Boolean(c);
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Pauses the voice note or video playing, if any; with `ids` (or a test of an id), only when it
|
|
87
|
+
* is one of them (a screen's own attachments). For a screen losing focus: a tab switch keeps it mounted, so its
|
|
88
|
+
* player would play on. Returns whether one was paused.
|
|
89
|
+
*/
|
|
90
|
+
export function pauseChatPlayback(ids?: Iterable<string> | ((id: string) => boolean)): boolean {
|
|
91
|
+
if (!active) return false;
|
|
92
|
+
if (typeof ids === "function" ? !ids(active.id) : ids && !new Set(ids).has(active.id)) return false;
|
|
93
|
+
const { id, pause } = active;
|
|
94
|
+
try {
|
|
95
|
+
pause();
|
|
96
|
+
} catch {
|
|
97
|
+
// A player already gone has nothing to pause.
|
|
98
|
+
}
|
|
99
|
+
releasePlayback(id);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
85
103
|
/** The playing voice note's id, for tests. */
|
|
86
104
|
export function activePlaybackId(): string | null {
|
|
87
105
|
return active?.id ?? null;
|
package/src/react/index.tsx
CHANGED
|
@@ -8,7 +8,16 @@
|
|
|
8
8
|
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
9
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
|
10
10
|
import type { ChatClient, ConversationChannel, MessagePage } from "../core/client";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
createOutbox,
|
|
13
|
+
type HostedUpload,
|
|
14
|
+
type LocalFile,
|
|
15
|
+
type Outbox,
|
|
16
|
+
type OutboxStore,
|
|
17
|
+
type QueuedMessage,
|
|
18
|
+
type StorageUpload,
|
|
19
|
+
} from "../core/outbox";
|
|
20
|
+
import { sendBytes } from "../core/transfer";
|
|
12
21
|
import { currentActivity, firstUnreadId, mergeThread, throttle, upsertMessages, type ThreadItem } from "../core/thread";
|
|
13
22
|
import { attachmentKindFor, randomId } from "../core/media";
|
|
14
23
|
import type {
|
|
@@ -76,20 +85,55 @@ export function belongsTo(key: readonly unknown[], m: ChatMessage): boolean {
|
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
/**
|
|
79
|
-
* Hosted video uploads (Mux direct uploads): your server makes an upload and returns its URL
|
|
80
|
-
*
|
|
81
|
-
*
|
|
88
|
+
* Hosted video uploads (Mux direct uploads): your server makes an upload and returns its URL
|
|
89
|
+
* (`createVideoUploadHandler` in /server). A video the composer marks `hosted` is sent through
|
|
90
|
+
* the outbox at once, in order, with `{ kind: "video", provider, providerId, status: "pending" }`,
|
|
91
|
+
* while its bytes are PUT to that URL (see `hostedUploadFrom`). Without it, videos go to storage
|
|
92
|
+
* as files.
|
|
82
93
|
*/
|
|
83
94
|
export interface ChatVideoUpload {
|
|
84
95
|
create(conversationId: string): Promise<{ url: string; providerId: string; provider: "mux" }>;
|
|
85
96
|
}
|
|
86
97
|
|
|
98
|
+
/**
|
|
99
|
+
* The outbox's `hostedUpload` from a `ChatVideoUpload`: mints the upload (or, resuming, reuses
|
|
100
|
+
* the one minted before), resolves at once with its id, and PUTs the whole file to its URL in
|
|
101
|
+
* the background (XMLHttpRequest where there is one, for progress). A native file URI is read
|
|
102
|
+
* with `readFile`. Native apps with chunked, resumable uploads pass their own `hostedUpload`.
|
|
103
|
+
*/
|
|
104
|
+
export function hostedUploadFrom(
|
|
105
|
+
videoUpload: ChatVideoUpload,
|
|
106
|
+
options: { readFile?: (uri: string) => Promise<Blob | ArrayBuffer>; fetch?: typeof fetch } = {},
|
|
107
|
+
): HostedUpload {
|
|
108
|
+
return async (conversationId, file, { signal, onProgress, resume }) => {
|
|
109
|
+
const minted = resume?.url
|
|
110
|
+
? { url: resume.url, providerId: resume.providerId, provider: resume.provider }
|
|
111
|
+
: await videoUpload.create(conversationId);
|
|
112
|
+
const done = (async () => {
|
|
113
|
+
const body =
|
|
114
|
+
typeof file.data === "string"
|
|
115
|
+
? await (options.readFile ?? (() => Promise.reject(new Error("This file can't be read on this device"))))(file.data)
|
|
116
|
+
: file.data;
|
|
117
|
+
await sendBytes(minted.url, body, { method: "PUT", headers: { "Content-Type": file.contentType }, signal, onProgress, fetch: options.fetch });
|
|
118
|
+
})();
|
|
119
|
+
return { provider: minted.provider, providerId: minted.providerId, status: "pending", url: minted.url, done };
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What a kind's `title`, `subtitle` and `action` are told besides the attachment. */
|
|
124
|
+
export interface ChatCardContext {
|
|
125
|
+
/** The card is on a message you sent (your own check-in reads "Sent", not "Answer"). */
|
|
126
|
+
mine: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
87
129
|
/** What an app attachment kind's card shows (a form, a check-in): plain data, drawn by each renderer. */
|
|
88
130
|
export interface ChatAttachmentKind {
|
|
89
|
-
title(attachment: ChatAttachment): string;
|
|
90
|
-
subtitle?(attachment: ChatAttachment): string | null | undefined;
|
|
131
|
+
title(attachment: ChatAttachment, ctx?: ChatCardContext): string;
|
|
132
|
+
subtitle?(attachment: ChatAttachment, ctx?: ChatCardContext): string | null | undefined;
|
|
91
133
|
/** The card's button: a link (`href`) or a handler (`onPress`). */
|
|
92
|
-
action?(attachment: ChatAttachment): { label: string; href?: string; onPress?: () => void } | null | undefined;
|
|
134
|
+
action?(attachment: ChatAttachment, ctx?: ChatCardContext): { label: string; href?: string; onPress?: () => void } | null | undefined;
|
|
135
|
+
/** What previews call it (the inbox's last message, "Check-in"); the title by default. */
|
|
136
|
+
label?: string;
|
|
93
137
|
}
|
|
94
138
|
|
|
95
139
|
interface ChatContextValue {
|
|
@@ -118,8 +162,17 @@ export interface ChatProviderProps {
|
|
|
118
162
|
outboxStore?: OutboxStore;
|
|
119
163
|
/** Reads a native file URI for upload. */
|
|
120
164
|
readFile?: (uri: string) => Promise<Blob | ArrayBuffer>;
|
|
121
|
-
/**
|
|
122
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Replaces the plain upload (resumable uploads on native). `signal` aborts when the message is
|
|
167
|
+
* discarded or the outbox cleared; `onProgress(sent, total)` fills the pending bubble's bar.
|
|
168
|
+
*/
|
|
169
|
+
upload?: StorageUpload;
|
|
170
|
+
/**
|
|
171
|
+
* Starts a hosted upload for files marked `hosted` (see HostedUpload): resolve with
|
|
172
|
+
* `{ provider, providerId, status: "pending", url?, done }` once the upload id exists. Default:
|
|
173
|
+
* `hostedUploadFrom(videoUpload)` when `videoUpload` is given.
|
|
174
|
+
*/
|
|
175
|
+
hostedUpload?: HostedUpload;
|
|
123
176
|
/** Makes client ids (default: a random UUID that works without crypto.randomUUID). */
|
|
124
177
|
newId?: () => string;
|
|
125
178
|
/** Keeps a byte file (a pasted image, a recording) across launches: write it, return a URI. */
|
|
@@ -148,6 +201,7 @@ export function ChatProvider({
|
|
|
148
201
|
outboxStore,
|
|
149
202
|
readFile,
|
|
150
203
|
upload,
|
|
204
|
+
hostedUpload,
|
|
151
205
|
newId,
|
|
152
206
|
persistFile,
|
|
153
207
|
releasePreview,
|
|
@@ -159,6 +213,10 @@ export function ChatProvider({
|
|
|
159
213
|
const queryClient = useQueryClient();
|
|
160
214
|
const [queue, setQueue] = useState<QueuedMessage[]>([]);
|
|
161
215
|
const listeners = useRef(new Set<(s: ChatSignal) => void>());
|
|
216
|
+
// The outbox lives as long as the client and user, so it reads the app's functions here at call
|
|
217
|
+
// time: an inline `videoUpload.create` closing over a refreshed token keeps working.
|
|
218
|
+
const live = useRef({ readFile, upload, hostedUpload, videoUpload, persistFile });
|
|
219
|
+
live.current = { readFile, upload, hostedUpload, videoUpload, persistFile };
|
|
162
220
|
|
|
163
221
|
const outbox = useMemo(
|
|
164
222
|
() =>
|
|
@@ -166,10 +224,19 @@ export function ChatProvider({
|
|
|
166
224
|
client,
|
|
167
225
|
userId,
|
|
168
226
|
store: outboxStore,
|
|
169
|
-
readFile,
|
|
170
|
-
upload,
|
|
227
|
+
readFile: readFile && ((uri) => (live.current.readFile ?? readFile)(uri)),
|
|
228
|
+
upload: upload && ((...args) => (live.current.upload ?? upload)(...args)),
|
|
229
|
+
hostedUpload:
|
|
230
|
+
hostedUpload || videoUpload
|
|
231
|
+
? (...args) => {
|
|
232
|
+
const now = live.current;
|
|
233
|
+
const video = now.videoUpload ?? videoUpload;
|
|
234
|
+
const send = now.hostedUpload ?? (video ? hostedUploadFrom(video, { readFile: now.readFile ?? readFile }) : hostedUpload!);
|
|
235
|
+
return send(...args);
|
|
236
|
+
}
|
|
237
|
+
: undefined,
|
|
171
238
|
newId,
|
|
172
|
-
persistFile,
|
|
239
|
+
persistFile: persistFile && ((file) => (live.current.persistFile ?? persistFile)(file)),
|
|
173
240
|
releasePreview,
|
|
174
241
|
onChange: setQueue,
|
|
175
242
|
onSent: (message) => {
|
|
@@ -396,6 +463,20 @@ export function useConversation(conversationId: string | null | undefined, opts:
|
|
|
396
463
|
if (shouldMark && latestSeq > 0) void markRead().catch(() => undefined);
|
|
397
464
|
}, [shouldMark, latestSeq, markRead]);
|
|
398
465
|
|
|
466
|
+
const refetchConversation = useCallback(
|
|
467
|
+
() =>
|
|
468
|
+
Promise.all([
|
|
469
|
+
// The conversation and what hangs off it (topics, availability), never its message lists:
|
|
470
|
+
// a refetch would replace loaded history and merged live messages with one fresh page.
|
|
471
|
+
queryClient.refetchQueries({ queryKey: chatKeys.conversation(id), predicate: (q) => q.queryKey[3] !== "messages" }),
|
|
472
|
+
queryClient.refetchQueries({ queryKey: chatKeys.members(id) }),
|
|
473
|
+
queryClient.refetchQueries({ queryKey: chatKeys.role(id) }),
|
|
474
|
+
queryClient.refetchQueries({ queryKey: chatKeys.pins(id) }),
|
|
475
|
+
queryClient.refetchQueries({ queryKey: ["genesis-chat", "kind"] }),
|
|
476
|
+
]).then(() => undefined),
|
|
477
|
+
[queryClient, id],
|
|
478
|
+
);
|
|
479
|
+
|
|
399
480
|
const loadEarlier = useCallback(async () => {
|
|
400
481
|
const first = list[0];
|
|
401
482
|
if (!first || loadingEarlier) return;
|
|
@@ -466,7 +547,10 @@ export function useConversation(conversationId: string | null | undefined, opts:
|
|
|
466
547
|
conversationLoading: conversation.isLoading,
|
|
467
548
|
/** Your role couldn't be loaded (offline): offer `refetchAccess`, don't say "can't post". */
|
|
468
549
|
accessError: access.error ?? null,
|
|
469
|
-
|
|
550
|
+
/** Retries everything this conversation loads (role, members, the conversation, topics, availability, pins), not only access. */
|
|
551
|
+
refetchAccess: refetchConversation,
|
|
552
|
+
/** Same as refetchAccess: every per-conversation lookup again, for a Retry after being offline. */
|
|
553
|
+
refetchConversation,
|
|
470
554
|
/** The kind's rules (max members, threads, who posts). */
|
|
471
555
|
kind: kindRules.data ?? null,
|
|
472
556
|
/** May you pin here: owners and admins, or anyone in a two-person conversation. */
|
|
@@ -613,11 +697,12 @@ export function useTopics(conversationId: string | null | undefined) {
|
|
|
613
697
|
|
|
614
698
|
/**
|
|
615
699
|
* Your scheduled messages (in one conversation, or all), soonest first, with `schedule` and
|
|
616
|
-
* `cancel
|
|
617
|
-
*
|
|
700
|
+
* `cancel` (which also removes one that failed). `schedule` uploads its files to storage first,
|
|
701
|
+
* the way `send` does (the provider's `upload` hook, or a native URI read through `readFile`),
|
|
702
|
+
* then schedules them with the message. A file marked `hosted` goes to storage too.
|
|
618
703
|
*/
|
|
619
704
|
export function useScheduled(conversationId?: string | null, opts: { enabled?: boolean } = {}) {
|
|
620
|
-
const { client,
|
|
705
|
+
const { client, outbox } = useChat();
|
|
621
706
|
const queryClient = useQueryClient();
|
|
622
707
|
const query = useQuery({
|
|
623
708
|
queryKey: chatKeys.scheduled(conversationId),
|
|
@@ -630,15 +715,14 @@ export function useScheduled(conversationId?: string | null, opts: { enabled?: b
|
|
|
630
715
|
async (input: Omit<ScheduleInput, "clientId"> & { clientId?: string }, files: LocalFile[] = []) => {
|
|
631
716
|
const attachments = [...(input.attachments ?? [])];
|
|
632
717
|
for (const f of files) {
|
|
633
|
-
|
|
634
|
-
const path = await client.upload(input.conversationId, userId, f.data, { name: f.name, contentType: f.contentType });
|
|
718
|
+
const path = await outbox.uploadFile(input.conversationId, f);
|
|
635
719
|
attachments.push({ ...f.meta, kind: f.meta?.kind ?? attachmentKindFor(f.contentType), path, name: f.meta?.name ?? f.name, mime: f.contentType });
|
|
636
720
|
}
|
|
637
721
|
const id = await client.schedule({ ...input, clientId: input.clientId ?? randomId(), attachments });
|
|
638
722
|
await refresh();
|
|
639
723
|
return id;
|
|
640
724
|
},
|
|
641
|
-
[client,
|
|
725
|
+
[client, outbox, refresh],
|
|
642
726
|
);
|
|
643
727
|
const cancel = useCallback(
|
|
644
728
|
async (id: string) => {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @feat:chat
|
|
3
|
+
* One-line previews of a message (the inbox, the long-press sheet) where an app attachment kind
|
|
4
|
+
* reads as its `label`, else its title ("Check-in"), not "Attachment". No React or DOM: web and
|
|
5
|
+
* native both draw from it.
|
|
6
|
+
*/
|
|
7
|
+
import { previewText } from "../core/thread";
|
|
8
|
+
import type { ChatAttachment, ChatMessage } from "../core/types";
|
|
9
|
+
import type { ChatAttachmentKind, ChatCardContext } from "./index";
|
|
10
|
+
|
|
11
|
+
/** Calls a kind's `title`, `subtitle` or `action` with the card context. */
|
|
12
|
+
export const callAttachmentKind = <K extends "title" | "subtitle" | "action">(kind: ChatAttachmentKind | undefined, key: K, a: ChatAttachment, ctx: ChatCardContext) =>
|
|
13
|
+
(kind?.[key] as ((this: ChatAttachmentKind, a: ChatAttachment, ctx: ChatCardContext) => unknown) | undefined)?.call(kind!, a, ctx) as
|
|
14
|
+
| ReturnType<NonNullable<ChatAttachmentKind[K]>>
|
|
15
|
+
| undefined;
|
|
16
|
+
|
|
17
|
+
/** A card's title (the kind's, else the file's name, else the kind); an app kind that throws reads as its name. */
|
|
18
|
+
export function cardTitle(kind: ChatAttachmentKind | undefined, a: ChatAttachment, ctx: ChatCardContext = { mine: false }): string {
|
|
19
|
+
try {
|
|
20
|
+
return callAttachmentKind(kind, "title", a, ctx) || a.name || a.kind;
|
|
21
|
+
} catch {
|
|
22
|
+
return a.name || a.kind;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** An attachment with only its kind, for a preview that knows no more (the inbox's last message). */
|
|
27
|
+
function bareAttachment(kind: string): ChatAttachment {
|
|
28
|
+
return { id: "", messageId: "", position: 0, kind, ref: {}, provider: "none", path: null, providerId: null, playbackId: null, url: null, status: "ready", name: null, mime: null, bytes: null, width: null, height: null, durationMs: null, waveform: null, posterPath: null, thumbhash: null, transcript: null, transcriptStatus: null };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A message's one-line preview: its text, else what it carries, where an app kind reads as its
|
|
33
|
+
* title ("Check-in") instead of "Attachment". `attachment` is the kind alone (the inbox) or the
|
|
34
|
+
* attachment itself.
|
|
35
|
+
*/
|
|
36
|
+
export function attachmentPreview(body: string, attachment: string | ChatAttachment | null | undefined, kinds: Record<string, ChatAttachmentKind> | undefined, deleted = false): string {
|
|
37
|
+
const kind = typeof attachment === "string" ? attachment : attachment?.kind;
|
|
38
|
+
const text = previewText(body, kind, deleted);
|
|
39
|
+
// Text that reads as something (not only markdown marks) is the preview, as previewText has it.
|
|
40
|
+
if (deleted || !kind || !kinds || !Object.prototype.hasOwnProperty.call(kinds, kind) || previewText(body, null)) return text;
|
|
41
|
+
const a = typeof attachment === "string" ? bareAttachment(attachment) : attachment!;
|
|
42
|
+
const k = kinds[kind];
|
|
43
|
+
const title = typeof k?.label === "string" && k.label.trim() ? k.label.trim() : cardTitle(k, a);
|
|
44
|
+
return title && title !== kind ? title : text;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The long-press sheet's caption for a message. */
|
|
48
|
+
export function messagePreview(message: ChatMessage, kinds: Record<string, ChatAttachmentKind> | undefined): string {
|
|
49
|
+
return attachmentPreview(message.body, message.attachments[0], kinds, Boolean(message.deletedAt));
|
|
50
|
+
}
|
|
51
|
+
|