@marktiderman/genesis-chat 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +304 -13
  2. package/dist/{chunk-SAY24E6V.js → chunk-5D5A7I4Q.js} +166 -45
  3. package/dist/chunk-5D5A7I4Q.js.map +1 -0
  4. package/dist/{chunk-JQXZUEEL.js → chunk-7S6HYNWW.js} +382 -8
  5. package/dist/chunk-7S6HYNWW.js.map +1 -0
  6. package/dist/chunk-BJCE5O7V.js +70 -0
  7. package/dist/chunk-BJCE5O7V.js.map +1 -0
  8. package/dist/chunk-PZTOY2LY.js +576 -0
  9. package/dist/chunk-PZTOY2LY.js.map +1 -0
  10. package/dist/chunk-VEPZROQB.js +187 -0
  11. package/dist/chunk-VEPZROQB.js.map +1 -0
  12. package/dist/{client-BtdALFCx.d.ts → client-DMa2j7kz.d.ts} +152 -11
  13. package/dist/index.d.ts +78 -163
  14. package/dist/index.js +3 -2
  15. package/dist/native.d.ts +1910 -1
  16. package/dist/native.js +4900 -0
  17. package/dist/native.js.map +1 -1
  18. package/dist/outbox-B0y3phE0.d.ts +151 -0
  19. package/dist/push.d.ts +206 -0
  20. package/dist/push.js +3 -0
  21. package/dist/push.js.map +1 -0
  22. package/dist/react.d.ts +149 -7
  23. package/dist/react.js +3 -3
  24. package/dist/server.d.ts +510 -21
  25. package/dist/server.js +863 -21
  26. package/dist/server.js.map +1 -1
  27. package/dist/thread-DWXHKPdw.d.ts +57 -0
  28. package/dist/web.d.ts +313 -10
  29. package/dist/web.js +1731 -60
  30. package/dist/web.js.map +1 -1
  31. package/migrations/0002_genesis_chat_coaching.sql +934 -0
  32. package/migrations/tests/0002_genesis_chat_coaching.test.sql +265 -0
  33. package/package.json +74 -6
  34. package/src/__tests__/coaching-core.test.ts +332 -0
  35. package/src/__tests__/coaching-react.test.tsx +248 -0
  36. package/src/__tests__/coaching-web.test.tsx +449 -0
  37. package/src/__tests__/core.test.ts +91 -0
  38. package/src/__tests__/outbox.test.ts +122 -0
  39. package/src/__tests__/realtime.test.ts +70 -0
  40. package/src/__tests__/review-fixes-core.test.ts +193 -0
  41. package/src/__tests__/review-fixes-web.test.tsx +402 -0
  42. package/src/__tests__/server-effects.test.ts +158 -0
  43. package/src/__tests__/server-fakes.ts +90 -0
  44. package/src/__tests__/server-integrations.test.ts +247 -0
  45. package/src/__tests__/server-providers.test.ts +246 -0
  46. package/src/__tests__/server-runner.test.ts +277 -0
  47. package/src/__tests__/server-safety.test.ts +290 -0
  48. package/src/__tests__/web-markdown.test.tsx +88 -0
  49. package/src/__tests__/web.test.tsx +333 -0
  50. package/src/core/availability.ts +223 -0
  51. package/src/core/client.ts +757 -0
  52. package/src/core/extras.ts +106 -0
  53. package/src/core/media.ts +144 -0
  54. package/src/core/outbox.ts +429 -0
  55. package/src/core/thread.ts +171 -0
  56. package/src/core/types.ts +311 -0
  57. package/src/index.ts +13 -0
  58. package/src/native/__tests__/auto-retry.test.tsx +128 -0
  59. package/src/native/__tests__/composer-mic-lock.test.tsx +115 -0
  60. package/src/native/__tests__/composer-state.test.ts +188 -0
  61. package/src/native/__tests__/composer.test.tsx +133 -0
  62. package/src/native/__tests__/emoji.test.tsx +238 -0
  63. package/src/native/__tests__/inbox-screen.test.tsx +48 -0
  64. package/src/native/__tests__/message-bubble.test.tsx +127 -0
  65. package/src/native/__tests__/organise-logic.test.ts +164 -0
  66. package/src/native/__tests__/organise-screens.test.tsx +344 -0
  67. package/src/native/__tests__/photo-viewer.test.tsx +226 -0
  68. package/src/native/__tests__/push-payload.test.ts +66 -0
  69. package/src/native/__tests__/push.test.ts +209 -0
  70. package/src/native/__tests__/recorder-machine.test.ts +96 -0
  71. package/src/native/__tests__/rows-actions.test.ts +76 -0
  72. package/src/native/__tests__/thread-layout.test.ts +41 -0
  73. package/src/native/__tests__/thread-screen.test.tsx +116 -0
  74. package/src/native/__tests__/tus.test.ts +154 -0
  75. package/src/native/__tests__/use-voice-recorder.test.tsx +316 -0
  76. package/src/native/__tests__/video-media.test.tsx +421 -0
  77. package/src/native/__tests__/video-recorder-state.test.ts +87 -0
  78. package/src/native/__tests__/video-upload.test.ts +227 -0
  79. package/src/native/access-error.tsx +31 -0
  80. package/src/native/actions.ts +42 -0
  81. package/src/native/attachment-cards.tsx +116 -0
  82. package/src/native/auto-retry.ts +69 -0
  83. package/src/native/availability.tsx +376 -0
  84. package/src/native/broadcast-sheet.tsx +193 -0
  85. package/src/native/composer-state.ts +302 -0
  86. package/src/native/composer.tsx +453 -0
  87. package/src/native/context.tsx +70 -0
  88. package/src/native/copy.ts +296 -0
  89. package/src/native/emoji-data.ts +21 -0
  90. package/src/native/emoji-picker.tsx +229 -0
  91. package/src/native/emoji.ts +118 -0
  92. package/src/native/icon-button.tsx +40 -0
  93. package/src/native/inbox-screen.tsx +116 -0
  94. package/src/native/index.ts +184 -0
  95. package/src/native/link-preview.tsx +104 -0
  96. package/src/native/media-colors.ts +15 -0
  97. package/src/native/media.tsx +187 -0
  98. package/src/native/message-actions-sheet.tsx +121 -0
  99. package/src/native/message-bubble.tsx +374 -0
  100. package/src/native/moderation.tsx +42 -0
  101. package/src/native/modules.ts +275 -0
  102. package/src/native/organise-copy.ts +195 -0
  103. package/src/native/photo-viewer.tsx +531 -0
  104. package/src/native/push.ts +375 -0
  105. package/src/native/recorder-machine.ts +176 -0
  106. package/src/native/rows.ts +81 -0
  107. package/src/native/schedule-sheet.tsx +314 -0
  108. package/src/native/search-screen.tsx +168 -0
  109. package/src/native/stack-options.ts +36 -0
  110. package/src/native/start-sheet.tsx +87 -0
  111. package/src/native/thread-screen.tsx +620 -0
  112. package/src/native/topics.tsx +179 -0
  113. package/src/native/tus.ts +282 -0
  114. package/src/native/use-composer.ts +71 -0
  115. package/src/native/use-jump.ts +91 -0
  116. package/src/native/use-voice-recorder.ts +234 -0
  117. package/src/native/video-player.tsx +370 -0
  118. package/src/native/video-recorder-state.ts +140 -0
  119. package/src/native/video-recorder.tsx +271 -0
  120. package/src/native/video-upload-tray.tsx +77 -0
  121. package/src/native/video-upload.ts +432 -0
  122. package/src/native/voice-player.tsx +283 -0
  123. package/src/react/index.tsx +810 -0
  124. package/src/server/assistant.ts +235 -0
  125. package/src/server/crypto.ts +86 -0
  126. package/src/server/expo.ts +119 -0
  127. package/src/server/handler.ts +32 -0
  128. package/src/server/index.ts +57 -0
  129. package/src/server/moderation.ts +155 -0
  130. package/src/server/mux.ts +290 -0
  131. package/src/server/notifications.ts +130 -0
  132. package/src/server/runner.ts +468 -0
  133. package/src/server/transcribers.ts +111 -0
  134. package/src/server/types.ts +171 -0
  135. package/src/server/unfurl.ts +480 -0
  136. package/src/server/upload.ts +158 -0
  137. package/src/web/attachments.tsx +379 -0
  138. package/src/web/availability.tsx +299 -0
  139. package/src/web/broadcast.tsx +166 -0
  140. package/src/web/bubble.tsx +563 -0
  141. package/src/web/cards.tsx +136 -0
  142. package/src/web/composer.tsx +571 -0
  143. package/src/web/emoji-picker.tsx +84 -0
  144. package/src/web/error-boundary.tsx +33 -0
  145. package/src/web/format.ts +55 -0
  146. package/src/web/inbox.tsx +195 -0
  147. package/src/web/index.ts +75 -0
  148. package/src/web/markdown.tsx +245 -0
  149. package/src/web/memory-client.ts +706 -0
  150. package/src/web/panel.tsx +135 -0
  151. package/src/web/schedule.tsx +192 -0
  152. package/src/web/search.tsx +129 -0
  153. package/src/web/thread-context.ts +64 -0
  154. package/src/web/thread.tsx +802 -0
  155. package/src/web/topics.tsx +160 -0
  156. package/src/web/use-voice-recorder.ts +264 -0
  157. package/src/web/video.tsx +522 -0
  158. package/src/web/voice-note.tsx +197 -0
  159. package/dist/chunk-27UTR5FW.js +0 -322
  160. package/dist/chunk-27UTR5FW.js.map +0 -1
  161. package/dist/chunk-JQXZUEEL.js.map +0 -1
  162. package/dist/chunk-SAY24E6V.js.map +0 -1
@@ -0,0 +1,620 @@
1
+ /**
2
+ * @feat:chat
3
+ * One conversation: a list oldest first, top to bottom, the order it reads in
4
+ * with VoiceOver (FlashList's bottom-anchored chat mode when the app gave it,
5
+ * else FlatList), that opens at the newest message, follows new ones while
6
+ * you are at the bottom, goes to the bottom when you send, and keeps its
7
+ * place while earlier ones load at the top; day separators, the "New messages" line,
8
+ * grouped bubbles with names and avatars in groups, who is typing or
9
+ * recording, retry and delete on a send that failed, the long-press sheet and
10
+ * swipe to reply, a message's photos in the full-screen viewer, and the composer above
11
+ * the keyboard (or, when your role couldn't be read, Retry). Render it as the screen's only content, under the
12
+ * navigator's header with `useChatThreadStackOptions` (an opaque bar).
13
+ *
14
+ * Not inverted: FlashList 2 keeps the first visible row in place when rows
15
+ * change and only follows new rows at the bottom (`autoscrollToBottomThreshold`;
16
+ * it declares `autoscrollToTopThreshold` but never reads it), so in an inverted list a new message
17
+ * at index 0 was pushed under the composer. Inverted, VoiceOver also read
18
+ * each day's heading after its messages.
19
+ *
20
+ * Organising: a topic bar when the conversation has topics (the list shows the topic chosen; a
21
+ * closed one takes no posts from members), the away line under the header, scheduled send
22
+ * (touch and hold Send) with the thread's scheduled messages over the composer, `jumpTo` a
23
+ * message (a search result), and moderation's markers.
24
+ */
25
+ import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from "react";
26
+ import { AccessibilityInfo, FlatList, KeyboardAvoidingView, Platform, Pressable, View, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
27
+ import { SafeAreaView } from "react-native-safe-area-context";
28
+ import { NativeEmptyState, NativeSkeleton, NativeText, useGenesisModules, useToast } from "@marktiderman/genesis-ui-native/components";
29
+ import { useReducedMotion, useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
30
+ import { activityLine, previewText } from "../core/thread";
31
+ import type { ChatMessage, PendingFile } from "../core/types";
32
+ import { useChat, useConversation, useFileUrls, useLinkPreviews, useMessageActions, usePeople, usePins, useTopics } from "../react/index";
33
+ import { recordingLimitMs } from "../core/media";
34
+ import type { OpenAttachment } from "./attachment-cards";
35
+ import { linkPreviewIds, previewToShow } from "./link-preview";
36
+ import { ChatVideoRecorder } from "./video-recorder";
37
+ import { VideoUploadTray } from "./video-upload-tray";
38
+ import { videoUploads, type MintedUpload, type RecordedVideo } from "./video-upload";
39
+ import { ChatAwayLine, useAwayLine } from "./availability";
40
+ import { fill } from "./copy";
41
+ import { ChatScheduledBar, ChatScheduleSheet, useScheduleSend } from "./schedule-sheet";
42
+ import { canManageTopics, ChatTopicBar, topicClosedFor } from "./topics";
43
+ import { rowIndexOf, useJumpToMessage, type JumpTarget } from "./use-jump";
44
+ import type { MessageActionId } from "./actions";
45
+ import { ChatComposer } from "./composer";
46
+ import { composerDisabledReason, filesFromAssets, replyTargetFor } from "./composer-state";
47
+ import { ChatPeopleScope, useChatNative } from "./context";
48
+ import { useChatComposer } from "./use-composer";
49
+ import { ChatAccessError } from "./access-error";
50
+ import { attachmentPaths } from "./media";
51
+ import { ChatPhotoViewer, galleryFor, type ChatPhoto } from "./photo-viewer";
52
+ import { MessageActionsSheet } from "./message-actions-sheet";
53
+ import { displayBody, MessageBubble } from "./message-bubble";
54
+ import { haptic } from "./modules";
55
+ import { buildThreadRows, type ThreadRow } from "./rows";
56
+
57
+ /** How long an upload minted for a thrown-away take is kept for the next one. */
58
+ const PREPARED_UPLOAD_MS = 20 * 60_000;
59
+
60
+ export interface ChatThreadScreenProps {
61
+ conversationId: string;
62
+ /** One thread's replies instead of the main timeline. */
63
+ threadRootId?: string | null;
64
+ /**
65
+ * Points from the top of the screen to the top of this view, for keeping the composer above the
66
+ * keyboard. Measured from the screen's own position when left out (under an opaque header it is
67
+ * the header's bottom).
68
+ */
69
+ keyboardVerticalOffset?: number;
70
+ /** Open at this message (a search result's `messageId` and `seq`): earlier pages load until it is held. */
71
+ jumpTo?: JumpTarget | null;
72
+ /** Only this topic's messages, with no topic bar. */
73
+ topic?: string;
74
+ /** Show the topic bar before the first topic exists, so an owner or admin can add one. */
75
+ showTopics?: boolean;
76
+ /** Scheduled send (touch and hold Send) and the scheduled list. Default true. */
77
+ scheduling?: boolean;
78
+ /** The away line under the header. Default true. */
79
+ availability?: boolean;
80
+ /** An app attachment card's button (a form, a check-in), when its kind gives no `onPress`. */
81
+ onOpenAttachment?: OpenAttachment;
82
+ testID?: string;
83
+ }
84
+
85
+ /** The list props chat passes; FlashList v2 and FlatList both take them. */
86
+ interface ChatListProps {
87
+ ref?: unknown;
88
+ data: readonly ThreadRow[];
89
+ renderItem: (info: { item: ThreadRow; index: number }) => React.ReactElement | null;
90
+ keyExtractor: (row: ThreadRow) => string;
91
+ getItemType?: (row: ThreadRow) => string;
92
+ inverted?: boolean;
93
+ onStartReached?: () => void;
94
+ onStartReachedThreshold?: number;
95
+ ListHeaderComponent?: React.ReactElement | null;
96
+ ListFooterComponent?: React.ReactElement | null;
97
+ keyboardShouldPersistTaps?: "handled";
98
+ keyboardDismissMode?: "interactive" | "on-drag";
99
+ contentContainerStyle?: Record<string, unknown>;
100
+ maintainVisibleContentPosition?: Record<string, unknown>;
101
+ onScroll?: (e: NativeSyntheticEvent<NativeScrollEvent>) => void;
102
+ scrollEventThrottle?: number;
103
+ onContentSizeChange?: (width: number, height: number) => void;
104
+ onLayout?: (e: LayoutChangeEvent) => void;
105
+ extraData?: unknown;
106
+ onScrollToIndexFailed?: (info: { index: number; averageItemLength: number }) => void;
107
+ testID?: string;
108
+ }
109
+
110
+ /** Points from the bottom that still count as "at the bottom" (FlatList, which follows by hand). */
111
+ export const AT_BOTTOM_POINTS = 120;
112
+
113
+ /**
114
+ * How the thread's list is set up, oldest first and not inverted. FlashList:
115
+ * its chat mode (opens at the bottom, follows new rows while within a fifth of
116
+ * a screen of it, keeps the first visible row in place when earlier ones load
117
+ * at the top). FlatList: keeps its place when earlier rows load at the top;
118
+ * the screen follows new rows itself (`followsByHand`).
119
+ */
120
+ export function threadListMode(flash: boolean, reducedMotion: boolean): { followsByHand: boolean; maintainVisibleContentPosition: Record<string, unknown> } {
121
+ if (flash) {
122
+ return {
123
+ followsByHand: false,
124
+ maintainVisibleContentPosition: { startRenderingFromBottom: true, autoscrollToBottomThreshold: 0.2, animateAutoScrollToBottom: !reducedMotion },
125
+ };
126
+ }
127
+ return { followsByHand: true, maintainVisibleContentPosition: { minIndexForVisible: 0 } };
128
+ }
129
+
130
+ /** Whether a scroll position is at (or near) the bottom of its content. */
131
+ export function nearBottom(m: { contentOffset: { y: number }; contentSize: { height: number }; layoutMeasurement: { height: number } }, slack = AT_BOTTOM_POINTS): boolean {
132
+ return m.contentOffset.y + m.layoutMeasurement.height >= m.contentSize.height - slack;
133
+ }
134
+
135
+ export function ChatThreadScreen(props: ChatThreadScreenProps) {
136
+ const { conversationId, threadRootId = null, keyboardVerticalOffset, onOpenAttachment, jumpTo, topic: topicProp, showTopics = false, scheduling = true, availability = true, testID = "chat-thread" } = props;
137
+ const chat = useChat();
138
+ const { userId, videoUpload, attachmentKinds, outbox } = chat;
139
+ // Off unless the app turns it on: an og:image fetched from the phone tells its site who reads it.
140
+ const linkPreviewImages = (chat as { linkPreviewImages?: boolean }).linkPreviewImages === true;
141
+ const topicsState = useTopics(threadRootId ? null : conversationId);
142
+ const [chosenTopic, setChosenTopic] = useState<string | null>(null);
143
+ // Another conversation in this screen starts in All.
144
+ const [topicFor, setTopicFor] = useState(conversationId);
145
+ if (topicFor !== conversationId) {
146
+ setTopicFor(conversationId);
147
+ setChosenTopic(null);
148
+ }
149
+ // A message to go to is looked for in All, not in the topic shown.
150
+ const [jumpedFor, setJumpedFor] = useState(jumpTo);
151
+ if (jumpTo !== jumpedFor) {
152
+ setJumpedFor(jumpTo);
153
+ if (jumpTo) setChosenTopic(null);
154
+ }
155
+ const topic = topicProp ?? chosenTopic ?? undefined;
156
+ const conv = useConversation(conversationId, { threadRootId: threadRootId ?? undefined, topic });
157
+ const pins = usePins(conversationId);
158
+ const messageActions = useMessageActions();
159
+ const { modules, copy, person } = useChatNative();
160
+ const toast = useToast();
161
+ const colors = useThemeRoles();
162
+ const reducedMotion = useReducedMotion();
163
+ const genesis = useGenesisModules();
164
+ const List = (genesis.list ?? FlatList) as unknown as ComponentType<ChatListProps>;
165
+ const mode = threadListMode(Boolean(genesis.list), reducedMotion);
166
+ const listRef = useRef<{
167
+ scrollToEnd?: (o?: { animated?: boolean }) => void;
168
+ scrollToIndex?: (o: { index: number; animated?: boolean; viewPosition?: number }) => void;
169
+ scrollToOffset?: (o: { offset: number; animated?: boolean }) => void;
170
+ } | null>(null);
171
+ // FlatList follows by hand: at the bottom (or not yet scrolled), new rows and a shorter list keep it there.
172
+ const atBottom = useRef(true);
173
+ // FlatList opens at the top and is moved to the bottom a frame later: until it has been, the top
174
+ // isn't "reached" (it would load an earlier page on every open).
175
+ const placed = useRef(!mode.followsByHand);
176
+ const toBottom = useCallback(
177
+ (animated: boolean) => {
178
+ const go = (a: boolean) => listRef.current?.scrollToEnd?.({ animated: a && !reducedMotion });
179
+ requestAnimationFrame(() => {
180
+ go(animated);
181
+ placed.current = true;
182
+ });
183
+ // Once more after the new row has its size (FlashList measures it after the first pass).
184
+ if (animated) setTimeout(() => go(true), 180);
185
+ },
186
+ [reducedMotion],
187
+ );
188
+ const composer = useChatComposer(conversationId, threadRootId, userId);
189
+ const { setReply } = composer;
190
+ const [actionsFor, setActionsFor] = useState<ChatMessage | null>(null);
191
+ const [viewer, setViewer] = useState<{ photos: ChatPhoto[]; index: number } | null>(null);
192
+ const [retryingAccess, setRetryingAccess] = useState(false);
193
+ // Where this screen starts on the window: the keyboard offset when the app gives none.
194
+ const rootRef = useRef<{ measureInWindow?: (callback: (x: number, y: number) => void) => void } | null>(null);
195
+ const [measuredTop, setMeasuredTop] = useState(0);
196
+ const measureTop = useCallback(() => {
197
+ rootRef.current?.measureInWindow?.((_x: number, y: number) => {
198
+ if (Number.isFinite(y)) setMeasuredTop(Math.max(0, Math.round(y)));
199
+ });
200
+ }, []);
201
+
202
+ // Names and avatars show for more than two people: the kind's rule once it is read, else the member count.
203
+ const group = conv.kind ? conv.kind.maxMembers !== 2 : conv.members.length > 2;
204
+ const peopleIds = useMemo(
205
+ () => [...conv.members.map((m) => m.userId), ...conv.messages.map((m) => m.senderId)],
206
+ [conv.members, conv.messages],
207
+ );
208
+
209
+ const rows = useMemo(() => buildThreadRows(conv.items, { me: userId, firstUnreadId: conv.firstUnreadId, group }), [conv.items, userId, conv.firstUnreadId, group]);
210
+ const paths = useMemo(() => conv.items.flatMap((i) => (i.type === "message" ? attachmentPaths(i.message.attachments) : [])), [conv.items]);
211
+ const urls = useFileUrls(paths);
212
+ const byId = useMemo(() => new Map(conv.messages.map((m) => [m.id, m])), [conv.messages]);
213
+ const linkIds = useMemo(() => linkPreviewIds(conv.messages), [conv.messages]);
214
+ const previews = useLinkPreviews(linkIds);
215
+ const people = usePeople(peopleIds);
216
+ const nameOf = useCallback(
217
+ (id: string | null) => (id === userId ? copy.you : ((person(id) ?? (id ? people[id] : undefined))?.name ?? copy.someone)),
218
+ [userId, copy, person, people],
219
+ );
220
+
221
+ const fail = useCallback(() => {
222
+ haptic(modules.haptics, "error");
223
+ toast.show({ title: copy.actionFailed, variant: "destructive" });
224
+ }, [modules.haptics, toast, copy.actionFailed]);
225
+
226
+ const reply = useCallback(
227
+ (m: ChatMessage) => {
228
+ setReply(replyTargetFor(m, nameOf(m.senderId), previewText(displayBody(m.body), m.attachments[0]?.kind, Boolean(m.deletedAt))));
229
+ },
230
+ [setReply, nameOf],
231
+ );
232
+
233
+ const onAction = useCallback(
234
+ (m: ChatMessage, action: MessageActionId) => {
235
+ switch (action) {
236
+ case "reply":
237
+ return reply(m);
238
+ case "copy":
239
+ void modules.clipboard
240
+ ?.setStringAsync(displayBody(m.body))
241
+ .then(() => toast.show({ title: copy.copied, variant: "success", duration: 1500 }))
242
+ .catch(fail);
243
+ return;
244
+ case "pin":
245
+ case "unpin":
246
+ void messageActions.pin(m, action === "pin").catch(fail);
247
+ return;
248
+ case "delete":
249
+ void messageActions.remove(m).catch(fail);
250
+ return;
251
+ }
252
+ },
253
+ [reply, modules.clipboard, toast, copy.copied, fail, messageActions],
254
+ );
255
+
256
+ const react = useCallback(
257
+ (m: ChatMessage, emoji: string, on: boolean) => {
258
+ haptic(modules.haptics, "select");
259
+ void messageActions.react(m, emoji, on).catch(fail);
260
+ },
261
+ [modules.haptics, fail, messageActions],
262
+ );
263
+
264
+ // Stable handlers, so MessageBubble's memo holds while someone types or a row elsewhere changes.
265
+ const { retry, discard, canPost: allowed } = conv;
266
+ const onLongPress = useCallback(
267
+ (message: ChatMessage) => {
268
+ haptic(modules.haptics, "select");
269
+ setActionsFor(message);
270
+ },
271
+ [modules.haptics],
272
+ );
273
+ const onSwipeReply = useCallback(() => haptic(modules.haptics, "select"), [modules.haptics]);
274
+ const onRetry = useCallback((clientId: string) => void retry(clientId), [retry]);
275
+ const onDiscard = useCallback((clientId: string) => void discard(clientId), [discard]);
276
+ // Read when a photo is tapped, so the handler stays stable for MessageBubble's memo.
277
+ const forGallery = useRef({ messages: conv.messages, urls });
278
+ forGallery.current = { messages: conv.messages, urls };
279
+ const onOpenImage = useCallback(
280
+ (uri: string, alt: string) => {
281
+ const { messages, urls: signed } = forGallery.current;
282
+ setViewer(galleryFor(messages, signed, uri, alt, (a) => (a.name ? `${copy.photo}, ${a.name}` : copy.photo)));
283
+ },
284
+ [copy.photo],
285
+ );
286
+ // Files that didn't survive a relaunch: photos and videos can be picked again; anything else can only be deleted.
287
+ const { reattach } = conv;
288
+ const picker = modules.imagePicker;
289
+ const onReattach = useCallback(
290
+ async (clientId: string, files: readonly PendingFile[]) => {
291
+ if (!picker) return;
292
+ try {
293
+ // Only the slots that were lost: reattach fills those and keeps the rest.
294
+ const lostCount = Math.max(1, files.filter((f) => f.lost).length);
295
+ const result = await picker.launchImageLibraryAsync({ mediaTypes: ["images", "videos"], allowsMultipleSelection: lostCount > 1, selectionLimit: lostCount, quality: 0.8 });
296
+ if (!result || result.canceled || !result.assets?.length) return;
297
+ const { attachments, notice } = filesFromAssets(result.assets, copy);
298
+ if (notice) toast.show({ title: notice, variant: "destructive" });
299
+ if (attachments.length) await reattach(clientId, attachments.map((a) => a.file));
300
+ } catch {
301
+ fail();
302
+ }
303
+ },
304
+ [picker, copy, toast, reattach, fail],
305
+ );
306
+ const observer = conv.role === "observer";
307
+ const { loadUntil } = conv;
308
+ // The message being gone to, for FlatList's retry once it has measured that far (by id: rows may have moved).
309
+ const jumpingTo = useRef<{ id: string; tries: number } | null>(null);
310
+ const retryTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
311
+ useEffect(() => () => {
312
+ if (retryTimer.current) clearTimeout(retryTimer.current);
313
+ }, []);
314
+ const rowsNow = useRef(rows);
315
+ rowsNow.current = rows;
316
+ const { highlightedId, jumping } = useJumpToMessage({
317
+ target: jumpTo,
318
+ rows,
319
+ loading: conv.isLoading,
320
+ has: useCallback((id: string) => byId.has(id), [byId]),
321
+ loadUntil,
322
+ scrollTo: (index, id) => {
323
+ jumpingTo.current = { id, tries: 0 };
324
+ requestAnimationFrame(() => listRef.current?.scrollToIndex?.({ index, animated: !reducedMotion, viewPosition: 0.5 }));
325
+ },
326
+ onStart: () => {
327
+ atBottom.current = false;
328
+ placed.current = true;
329
+ },
330
+ onLanded: (id) => {
331
+ const m = byId.get(id);
332
+ if (m) AccessibilityInfo.announceForAccessibility?.(fill(copy.jumpedTo, { name: nameOf(m.senderId), text: previewText(displayBody(m.body), m.attachments[0]?.kind, Boolean(m.deletedAt)) }));
333
+ },
334
+ onGone: () => {
335
+ toast.show({ title: copy.messageGone });
336
+ // Back to where the thread opens: the newest messages.
337
+ atBottom.current = true;
338
+ toBottom(false);
339
+ },
340
+ onError: fail,
341
+ });
342
+ const reattachable = useCallback((files: readonly PendingFile[]) => files.every((f) => !f.lost || f.kind === "image" || f.kind === "video"), []);
343
+
344
+ const renderItem = useCallback(
345
+ ({ item: row }: { item: ThreadRow }) => {
346
+ if (row.type === "day") return <DaySeparator label={row.label} testID={`${testID}-day-${row.key}`} />;
347
+ if (row.type === "unread") return <UnreadDivider label={copy.newMessages} testID={`${testID}-unread`} />;
348
+ const m = row.item.type === "message" ? row.item.message : null;
349
+ const replyTo = m?.replyToId ?? (row.item.type === "pending" ? row.item.pending.input.replyToId : null);
350
+ return (
351
+ <MessageBubble
352
+ row={row}
353
+ me={userId}
354
+ urls={urls}
355
+ repliedTo={replyTo ? byId.get(replyTo) : null}
356
+ group={group}
357
+ reducedMotion={reducedMotion}
358
+ onLongPress={onLongPress}
359
+ onReply={allowed ? reply : undefined}
360
+ onSwipeReply={onSwipeReply}
361
+ onRetry={onRetry}
362
+ onDiscard={onDiscard}
363
+ onReattach={picker && row.item.type === "pending" && reattachable(row.item.pending.files ?? []) ? onReattach : undefined}
364
+ onToggleReaction={allowed ? react : undefined}
365
+ onOpenImage={onOpenImage}
366
+ observer={observer}
367
+ highlighted={Boolean(m && m.id === highlightedId)}
368
+ linkPreview={m ? previewToShow(previews[m.id]) : null}
369
+ linkPreviewImages={linkPreviewImages}
370
+ attachmentKinds={attachmentKinds}
371
+ onOpenAttachment={onOpenAttachment}
372
+ testID={`${testID}-message-${m?.id ?? row.key}`}
373
+ />
374
+ );
375
+ },
376
+ [testID, copy.newMessages, userId, urls, byId, group, reducedMotion, onLongPress, allowed, reply, onSwipeReply, onRetry, onDiscard, react, onOpenImage, picker, reattachable, onReattach, previews, linkPreviewImages, attachmentKinds, onOpenAttachment, observer, highlightedId],
377
+ );
378
+
379
+ // ---- Video messages ----
380
+ const [videoOpen, setVideoOpen] = useState(false);
381
+ // Minted when a take starts, so its bytes go the moment it stops. Kept for the next take when this
382
+ // one is thrown away, but only for this conversation and only while it is fresh (Mux's direct
383
+ // uploads expire; the uploader mints another if one has).
384
+ const preparedVideo = useRef<{ conversationId: string; at: number; upload: Promise<MintedUpload> } | null>(null);
385
+ const takePrepared = useCallback(() => {
386
+ const p = preparedVideo.current;
387
+ preparedVideo.current = null;
388
+ return p && p.conversationId === conversationId && Date.now() - p.at < PREPARED_UPLOAD_MS ? p.upload : null;
389
+ }, [conversationId]);
390
+ const onVideoStart = useCallback(() => {
391
+ if (!videoUpload) return;
392
+ const before = preparedVideo.current;
393
+ const kept = takePrepared();
394
+ const upload = kept ?? videoUpload.create(conversationId);
395
+ upload.catch(() => {
396
+ if (preparedVideo.current?.upload === upload) preparedVideo.current = null;
397
+ });
398
+ preparedVideo.current = { conversationId, at: kept && before ? before.at : Date.now(), upload };
399
+ }, [videoUpload, conversationId, takePrepared]);
400
+ const replyNow = composer.state.replyTo?.messageId ?? null;
401
+ const onVideoRecorded = useCallback(
402
+ (video: RecordedVideo) => {
403
+ videoUploads.start({ conversationId, userId, video, extra: { replyToId: replyNow, threadRootId }, send: outbox.send, videoUpload, prepared: takePrepared(), openFile: modules.openFile });
404
+ if (replyNow) setReply(null);
405
+ atBottom.current = true;
406
+ toBottom(true);
407
+ },
408
+ [conversationId, userId, replyNow, threadRootId, outbox, videoUpload, takePrepared, modules.openFile, setReply, toBottom],
409
+ );
410
+
411
+ const activity = activityLine(conv.activity, (id) => nameOf(id));
412
+ // Unknown while access loads: the composer waits quietly instead of saying you can't post. A
413
+ // read that failed (and no role known from before) offers Retry in the composer's place.
414
+ const topics = topicsState.topics;
415
+ const currentTopic = topic ? (topics.find((t) => t.topic === topic) ?? null) : null;
416
+ const topicClosed = topicClosedFor(currentTopic, conv.role);
417
+ const showTopicBar = !threadRootId && topicProp === undefined && (topics.length > 0 || (showTopics && canManageTopics(conv.role)));
418
+ const canPost = topicClosed ? false : conv.accessLoading ? null : conv.canPost;
419
+ const disabledReason = topicClosed ? copy.topicClosedNotice : composerDisabledReason(canPost, conv.conversation, conv.role, copy);
420
+ const awayLine = useAwayLine(availability ? conversationId : null, conv.members, userId, nameOf);
421
+ // Scheduled send: not in a reply thread (a scheduled message has no thread).
422
+ const canSchedule = scheduling && !threadRootId && canPost === true;
423
+ const schedule = useScheduleSend({ conversationId, topic: topic ?? null, composer, enabled: scheduling && !threadRootId });
424
+ const accessFailed = Boolean(conv.accessError) && conv.role === null && !conv.accessLoading;
425
+ const { refetchAccess } = conv;
426
+ const retryAccess = useCallback(() => {
427
+ setRetryingAccess(true);
428
+ void Promise.resolve(refetchAccess()).finally(() => setRetryingAccess(false));
429
+ }, [refetchAccess]);
430
+ const KeyboardAvoiding = (modules.keyboardAvoidingView ?? KeyboardAvoidingView) as ComponentType<Record<string, unknown>>;
431
+ const offset = keyboardVerticalOffset ?? measuredTop;
432
+ const keyboardProps = modules.keyboardAvoidingView
433
+ ? { behavior: "translate-with-padding", keyboardVerticalOffset: offset }
434
+ : { behavior: Platform.OS === "ios" ? "padding" : undefined, keyboardVerticalOffset: offset };
435
+
436
+ const empty = !conv.isLoading && rows.length === 0;
437
+ return (
438
+ <ChatPeopleScope userIds={peopleIds}>
439
+ <SafeAreaView ref={rootRef as never} onLayout={measureTop} testID={testID} edges={["left", "right", "bottom"]} style={{ flex: 1, backgroundColor: colors.groupedBackground }}>
440
+ <KeyboardAvoiding style={{ flex: 1 }} {...keyboardProps}>
441
+ {showTopicBar ? (
442
+ <ChatTopicBar topics={topics} value={topic ?? null} onValueChange={setChosenTopic} canManage={canManageTopics(conv.role)} onSetTopic={topicsState.setTopic} testID={`${testID}-topics`} />
443
+ ) : null}
444
+ <ChatAwayLine text={awayLine} testID={`${testID}-away`} />
445
+ <View style={{ flex: 1 }}>
446
+ {conv.isLoading ? (
447
+ <LoadingThread testID={`${testID}-loading`} />
448
+ ) : empty ? (
449
+ <View style={{ flex: 1, justifyContent: "center" }}>
450
+ <NativeEmptyState variant="first-run" title={copy.threadEmptyTitle} body={copy.threadEmptyBody} testID={`${testID}-empty`} />
451
+ </View>
452
+ ) : (
453
+ <List
454
+ ref={listRef}
455
+ testID={`${testID}-list`}
456
+ data={rows}
457
+ renderItem={renderItem}
458
+ keyExtractor={(row) => row.key}
459
+ getItemType={(row) => (row.type === "message" ? (row.mine ? "mine" : "theirs") : row.type)}
460
+ onStartReached={() => {
461
+ // Not while going to a message: its pages are loading, and new rows would move it.
462
+ if (placed.current && !jumping && conv.hasEarlier && !conv.loadingEarlier) void conv.loadEarlier();
463
+ }}
464
+ onStartReachedThreshold={0.5}
465
+ keyboardShouldPersistTaps="handled"
466
+ keyboardDismissMode="interactive"
467
+ contentContainerStyle={{ paddingVertical: 8 }}
468
+ maintainVisibleContentPosition={mode.maintainVisibleContentPosition}
469
+ extraData={highlightedId}
470
+ onScrollToIndexFailed={({ index, averageItemLength }) => {
471
+ // FlatList hasn't measured that far up yet: get near it, then try again (three times at most).
472
+ listRef.current?.scrollToOffset?.({ offset: Math.max(0, index * averageItemLength - 200), animated: false });
473
+ const target = jumpingTo.current;
474
+ if (!target || target.tries >= 3) return;
475
+ target.tries += 1;
476
+ if (retryTimer.current) clearTimeout(retryTimer.current);
477
+ retryTimer.current = setTimeout(() => {
478
+ const at = rowIndexOf(rowsNow.current, target.id);
479
+ if (at >= 0) listRef.current?.scrollToIndex?.({ index: at, animated: !reducedMotion, viewPosition: 0.5 });
480
+ }, 250);
481
+ }}
482
+ {...(mode.followsByHand
483
+ ? {
484
+ scrollEventThrottle: 32,
485
+ onScroll: (e: NativeSyntheticEvent<NativeScrollEvent>) => {
486
+ atBottom.current = nearBottom(e.nativeEvent);
487
+ },
488
+ onContentSizeChange: () => {
489
+ if (atBottom.current) toBottom(false);
490
+ },
491
+ onLayout: () => {
492
+ if (atBottom.current) toBottom(false);
493
+ },
494
+ }
495
+ : {})}
496
+ ListHeaderComponent={
497
+ conv.hasEarlier ? (
498
+ <EarlierAnchor loading={conv.loadingEarlier} label={copy.loadEarlier} onPress={() => void conv.loadEarlier()} testID={`${testID}-earlier`} />
499
+ ) : null
500
+ }
501
+ ListFooterComponent={activity ? <ActivityLine text={activity} testID={`${testID}-activity`} /> : null}
502
+ />
503
+ )}
504
+ </View>
505
+ {scheduling && !threadRootId ? <ChatScheduledBar conversationId={conversationId} testID={`${testID}-scheduled`} /> : null}
506
+ <VideoUploadTray conversationId={conversationId} userId={userId} testID={`${testID}-video-uploads`} />
507
+ {accessFailed ? (
508
+ <ChatAccessError onRetry={retryAccess} retrying={retryingAccess} testID={`${testID}-access-error`} />
509
+ ) : (
510
+ <ChatComposer
511
+ composer={composer}
512
+ userId={userId}
513
+ placeholder={currentTopic ? fill(copy.topicPlaceholder, { name: currentTopic.title }) : undefined}
514
+ onSchedule={canSchedule ? schedule.request : undefined}
515
+ canPost={canPost}
516
+ disabledReason={disabledReason}
517
+ role={conv.role}
518
+ onSend={({ input, files }) => {
519
+ void conv.send(input, files).catch(fail);
520
+ // What you send is what you look at next, wherever the list was.
521
+ atBottom.current = true;
522
+ toBottom(true);
523
+ }}
524
+ signalTyping={conv.signalTyping}
525
+ signalRecording={conv.signalRecording}
526
+ onRecordVideo={modules.camera ? () => setVideoOpen(true) : undefined}
527
+ testID={`${testID}-composer`}
528
+ />
529
+ )}
530
+ </KeyboardAvoiding>
531
+ <MessageActionsSheet
532
+ message={actionsFor}
533
+ context={{ me: userId, role: conv.role, canPost: conv.canPost, pinned: actionsFor ? pins.includes(actionsFor.id) : false, canPin: conv.canPin, canCopy: Boolean(modules.clipboard) }}
534
+ onClose={() => setActionsFor(null)}
535
+ onReact={react}
536
+ onAction={onAction}
537
+ testID={`${testID}-actions`}
538
+ />
539
+ <ChatPhotoViewer photos={viewer?.photos ?? null} initialIndex={viewer?.index} onClose={() => setViewer(null)} testID={`${testID}-viewer`} />
540
+ {canSchedule ? <ChatScheduleSheet open={schedule.open} onOpenChange={schedule.setOpen} onSchedule={(at) => void schedule.scheduleAt(at)} testID={`${testID}-schedule`} /> : null}
541
+ <ChatVideoRecorder
542
+ open={videoOpen}
543
+ onClose={() => setVideoOpen(false)}
544
+ limitMs={recordingLimitMs(conv.role)}
545
+ onStart={onVideoStart}
546
+ onTick={conv.signalRecordingVideo}
547
+ onRecorded={onVideoRecorded}
548
+ testID={`${testID}-video-recorder`}
549
+ />
550
+ </SafeAreaView>
551
+ </ChatPeopleScope>
552
+ );
553
+ }
554
+
555
+ function DaySeparator({ label, testID }: { label: string; testID: string }) {
556
+ const colors = useThemeRoles();
557
+ return (
558
+ <View testID={testID} accessibilityRole="header" style={{ alignItems: "center", paddingVertical: 10 }}>
559
+ <NativeText preset="caption" style={{ color: colors.secondaryLabel, fontWeight: "600" }}>
560
+ {label}
561
+ </NativeText>
562
+ </View>
563
+ );
564
+ }
565
+
566
+ function UnreadDivider({ label, testID }: { label: string; testID: string }) {
567
+ const colors = useThemeRoles();
568
+ return (
569
+ <View testID={testID} accessible accessibilityLabel={label} style={{ flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 16, paddingVertical: 8 }}>
570
+ <View style={{ flex: 1, height: 1, backgroundColor: colors.tint }} />
571
+ <NativeText preset="caption" style={{ color: colors.tint, fontWeight: "600" }}>
572
+ {label}
573
+ </NativeText>
574
+ <View style={{ flex: 1, height: 1, backgroundColor: colors.tint }} />
575
+ </View>
576
+ );
577
+ }
578
+
579
+ function ActivityLine({ text, testID }: { text: string; testID: string }) {
580
+ const colors = useThemeRoles();
581
+ return (
582
+ <View testID={testID} accessibilityLiveRegion="polite" style={{ paddingHorizontal: 20, paddingVertical: 6 }}>
583
+ <NativeText preset="caption" style={{ color: colors.secondaryLabel, fontStyle: "italic" }}>
584
+ {text}
585
+ </NativeText>
586
+ </View>
587
+ );
588
+ }
589
+
590
+ function EarlierAnchor({ loading, label, onPress, testID }: { loading: boolean; label: string; onPress: () => void; testID: string }) {
591
+ const colors = useThemeRoles();
592
+ return (
593
+ <View style={{ alignItems: "center", paddingVertical: 12 }}>
594
+ {loading ? (
595
+ <View testID={`${testID}-loading`} style={{ gap: 8, alignItems: "center" }}>
596
+ <NativeSkeleton width={180} height={14} />
597
+ <NativeSkeleton width={120} height={14} />
598
+ </View>
599
+ ) : (
600
+ <Pressable testID={testID} accessibilityRole="button" accessibilityLabel={label} onPress={onPress} style={{ minHeight: 44, justifyContent: "center", paddingHorizontal: 16 }}>
601
+ <NativeText preset="label" style={{ color: colors.tint }}>
602
+ {label}
603
+ </NativeText>
604
+ </Pressable>
605
+ )}
606
+ </View>
607
+ );
608
+ }
609
+
610
+ function LoadingThread({ testID }: { testID: string }) {
611
+ return (
612
+ <View testID={testID} style={{ flex: 1, justifyContent: "flex-end", padding: 16, gap: 10 }}>
613
+ <NativeSkeleton width={200} height={36} className="rounded-2xl" />
614
+ <View style={{ alignItems: "flex-end" }}>
615
+ <NativeSkeleton width={160} height={36} className="rounded-2xl" />
616
+ </View>
617
+ <NativeSkeleton width={240} height={36} className="rounded-2xl" />
618
+ </View>
619
+ );
620
+ }