@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.
Files changed (77) hide show
  1. package/README.md +82 -7
  2. package/dist/{chunk-5D5A7I4Q.js → chunk-A4YJX7KT.js} +130 -16
  3. package/dist/chunk-A4YJX7KT.js.map +1 -0
  4. package/dist/chunk-AZS3HNLM.js +30 -0
  5. package/dist/chunk-AZS3HNLM.js.map +1 -0
  6. package/dist/{chunk-7S6HYNWW.js → chunk-GBJ3YI4E.js} +72 -5
  7. package/dist/chunk-GBJ3YI4E.js.map +1 -0
  8. package/dist/{chunk-PZTOY2LY.js → chunk-JU2FAPBI.js} +46 -13
  9. package/dist/chunk-JU2FAPBI.js.map +1 -0
  10. package/dist/{client-DMa2j7kz.d.ts → client-DUIG1hum.d.ts} +36 -1
  11. package/dist/index.d.ts +4 -4
  12. package/dist/index.js +2 -2
  13. package/dist/native.d.ts +36 -7
  14. package/dist/native.js +239 -116
  15. package/dist/native.js.map +1 -1
  16. package/dist/{outbox-B0y3phE0.d.ts → outbox-B9NAWQ58.d.ts} +74 -6
  17. package/dist/push.d.ts +2 -2
  18. package/dist/react.d.ts +48 -21
  19. package/dist/react.js +3 -3
  20. package/dist/server.d.ts +43 -2
  21. package/dist/server.js +115 -15
  22. package/dist/server.js.map +1 -1
  23. package/dist/{thread-DWXHKPdw.d.ts → thread-oPra7pkW.d.ts} +1 -1
  24. package/dist/web.d.ts +31 -11
  25. package/dist/web.js +97 -48
  26. package/dist/web.js.map +1 -1
  27. package/migrations/0003_genesis_chat_search_threads.sql +85 -0
  28. package/migrations/tests/0002_genesis_chat_coaching.test.sql +7 -3
  29. package/migrations/tests/0003_genesis_chat_search_threads.test.sql +50 -0
  30. package/package.json +13 -9
  31. package/src/__tests__/coaching-core.test.ts +3 -1
  32. package/src/__tests__/coaching-react.test.tsx +73 -2
  33. package/src/__tests__/coaching-web.test.tsx +78 -6
  34. package/src/__tests__/fake-xhr.ts +54 -0
  35. package/src/__tests__/outbox-hosted.test.ts +269 -0
  36. package/src/__tests__/review-279.test.ts +41 -0
  37. package/src/__tests__/review-fixes-web.test.tsx +13 -10
  38. package/src/__tests__/server-fake-video.test.ts +81 -0
  39. package/src/__tests__/server-fakes.ts +7 -1
  40. package/src/__tests__/server-safety.test.ts +17 -4
  41. package/src/__tests__/web.test.tsx +16 -0
  42. package/src/core/client.ts +28 -2
  43. package/src/core/outbox.ts +222 -16
  44. package/src/core/transfer.ts +71 -0
  45. package/src/core/types.ts +17 -0
  46. package/src/index.ts +1 -0
  47. package/src/native/__tests__/emoji.test.tsx +16 -0
  48. package/src/native/__tests__/organise-screens.test.tsx +80 -0
  49. package/src/native/__tests__/video-media.test.tsx +81 -4
  50. package/src/native/attachment-cards.tsx +22 -6
  51. package/src/native/inbox-screen.tsx +12 -6
  52. package/src/native/index.ts +2 -2
  53. package/src/native/message-actions-sheet.tsx +10 -5
  54. package/src/native/message-bubble.tsx +3 -11
  55. package/src/native/modules.ts +2 -0
  56. package/src/native/schedule-sheet.tsx +23 -9
  57. package/src/native/search-screen.tsx +26 -5
  58. package/src/native/thread-screen.tsx +30 -3
  59. package/src/native/topics.tsx +70 -15
  60. package/src/native/video-player.tsx +46 -6
  61. package/src/native/video-recorder.tsx +6 -3
  62. package/src/native/voice-player.tsx +18 -0
  63. package/src/react/index.tsx +103 -19
  64. package/src/react/previews.ts +51 -0
  65. package/src/server/fake-video.ts +171 -0
  66. package/src/server/index.ts +1 -0
  67. package/src/web/attachments.tsx +6 -6
  68. package/src/web/bubble.tsx +22 -0
  69. package/src/web/inbox.tsx +7 -6
  70. package/src/web/memory-client.ts +11 -3
  71. package/src/web/panel.tsx +2 -2
  72. package/src/web/schedule.tsx +14 -2
  73. package/src/web/thread.tsx +36 -8
  74. package/src/web/video.tsx +28 -34
  75. package/dist/chunk-5D5A7I4Q.js.map +0 -1
  76. package/dist/chunk-7S6HYNWW.js.map +0 -1
  77. package/dist/chunk-PZTOY2LY.js.map +0 -1
@@ -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 horizontal showsHorizontalScrollIndicator={false} accessibilityLabel={copy.topics} contentContainerStyle={{ paddingVertical: 4, paddingRight: 8 }} style={{ flex: 1 }}>
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
- <NativeChip value={ALL} testID={`${testID}-all`}>
123
- {copy.topicAll}
124
- </NativeChip>
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
- claimPlayback(id, () => player.pause());
273
- setPlayingId(id, true);
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
- return (
349
+ return (
324
350
  <View testID={`${testID}-active`} style={style}>
325
- <VideoView ref={viewRef} player={player} nativeControls={false} contentFit="cover" allowsPictureInPicture={false} style={{ width: "100%", height: "100%" }} testID={`${testID}-view`} />
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 { SafeAreaView } from "react-native-safe-area-context";
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
- <SafeAreaView edges={["top", "bottom", "left", "right"]} style={{ flex: 1, justifyContent: "space-between" }}>
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
- </SafeAreaView>
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;
@@ -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 { createOutbox, type LocalFile, type Outbox, type OutboxStore, type QueuedMessage } from "../core/outbox";
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
- * the renderer PUTs the recording there and sends `{ kind: "video", provider: "mux", providerId,
81
- * status: "pending" }`. Without it, videos go to storage as files.
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
- /** Replaces the plain upload (resumable uploads on native). `signal` aborts when the message is discarded or the outbox cleared. */
122
- upload?: (conversationId: string, file: LocalFile, opts?: { signal?: AbortSignal }) => Promise<string>;
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
- refetchAccess: access.refetch,
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`. `schedule` uploads byte files first (`LocalFile.data` a Blob or ArrayBuffer; a
617
- * native URI goes through the provider's `readFile`), then schedules them with the message.
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, userId } = useChat();
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
- if (typeof f.data === "string") throw new Error("Scheduled files must be bytes (a Blob or ArrayBuffer)");
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, refresh, userId],
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
+