@marktiderman/genesis-chat 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +7 -3
- 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
|
@@ -12,8 +12,11 @@ import type { ChatNativeModules } from "../modules";
|
|
|
12
12
|
import { invertedRows, type ThreadRow } from "../rows";
|
|
13
13
|
import { ChatVideoPlayer, videoState } from "../video-player";
|
|
14
14
|
import { ChatVideoRecorder } from "../video-recorder";
|
|
15
|
-
import { claimPlayback, activePlaybackId, releasePlayback } from "../voice-player";
|
|
16
|
-
import {
|
|
15
|
+
import { claimPlayback, activePlaybackId, pauseChatPlayback, releasePlayback } from "../voice-player";
|
|
16
|
+
import { AppState } from "react-native";
|
|
17
|
+
import { inboxRow } from "../inbox-screen";
|
|
18
|
+
import { chatNativeCopy } from "../copy";
|
|
19
|
+
import { attachmentPreview, ChatAttachmentCard, isOpenableHref, messagePreview, splitCardAttachments, type ChatCardContext } from "../attachment-cards";
|
|
17
20
|
import { useChatComposer } from "../use-composer";
|
|
18
21
|
import { createVideoUploads } from "../video-upload";
|
|
19
22
|
import { VideoUploadTray } from "../video-upload-tray";
|
|
@@ -51,7 +54,10 @@ function fakeVideo() {
|
|
|
51
54
|
}),
|
|
52
55
|
};
|
|
53
56
|
const enterFullscreen = vi.fn(async () => undefined);
|
|
54
|
-
const
|
|
57
|
+
const view: { props: { onFullscreenEnter?: () => void; onFullscreenExit?: () => void } | null } = { props: null };
|
|
58
|
+
const VideoView = forwardRef<unknown, { testID?: string; onFullscreenEnter?: () => void; onFullscreenExit?: () => void }>(function VideoView(props, ref) {
|
|
59
|
+
const { testID } = props;
|
|
60
|
+
view.props = props;
|
|
55
61
|
useImperativeHandle(ref, () => ({ enterFullscreen }));
|
|
56
62
|
return <div data-testid={testID} />;
|
|
57
63
|
});
|
|
@@ -65,7 +71,7 @@ function fakeVideo() {
|
|
|
65
71
|
}),
|
|
66
72
|
VideoView,
|
|
67
73
|
};
|
|
68
|
-
return { video: video as unknown as ChatNativeModules["video"], player, enterFullscreen, sources };
|
|
74
|
+
return { video: video as unknown as ChatNativeModules["video"], player, enterFullscreen, sources, view };
|
|
69
75
|
}
|
|
70
76
|
|
|
71
77
|
function fakeCamera(granted = true) {
|
|
@@ -155,6 +161,44 @@ describe("ChatVideoPlayer", () => {
|
|
|
155
161
|
expect(screen.getByTestId("v-broken").getAttribute("aria-label")).toBe("This video can't play right now.");
|
|
156
162
|
});
|
|
157
163
|
|
|
164
|
+
it("pauses when the app goes to the background, and when it unmounts", () => {
|
|
165
|
+
const { video, player } = fakeVideo();
|
|
166
|
+
const view = render(withModules({ video }, <ChatVideoPlayer id="a5" uri="https://x/v.mp4" provider="storage" status="ready" durationMs={3000} testID="v" />));
|
|
167
|
+
fireEvent.click(screen.getByTestId("v-poster"));
|
|
168
|
+
expect(player.pause).not.toHaveBeenCalled();
|
|
169
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("inactive"));
|
|
170
|
+
expect(player.pause).not.toHaveBeenCalled(); // Control Center, a system alert: it plays on
|
|
171
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("background"));
|
|
172
|
+
expect(player.pause).toHaveBeenCalledTimes(1);
|
|
173
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("active"));
|
|
174
|
+
view.unmount();
|
|
175
|
+
expect(player.pause).toHaveBeenCalledTimes(2);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("plays on in full screen, which Android reports as the app going to the background", () => {
|
|
179
|
+
const { video, player, view } = fakeVideo();
|
|
180
|
+
render(withModules({ video }, <ChatVideoPlayer id="a7" uri="https://x/v.mp4" provider="storage" status="ready" durationMs={3000} testID="v" />));
|
|
181
|
+
fireEvent.click(screen.getByTestId("v-poster"));
|
|
182
|
+
act(() => view.props?.onFullscreenEnter?.());
|
|
183
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("background"));
|
|
184
|
+
expect(player.pause).not.toHaveBeenCalled();
|
|
185
|
+
act(() => view.props?.onFullscreenExit?.());
|
|
186
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("background"));
|
|
187
|
+
expect(player.pause).toHaveBeenCalledTimes(1);
|
|
188
|
+
act(() => (AppState as unknown as { __emit: (s: string) => void }).__emit("active"));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("is paused by pauseChatPlayback, which leaves another screen's alone", () => {
|
|
192
|
+
const { video, player } = fakeVideo();
|
|
193
|
+
render(withModules({ video }, <ChatVideoPlayer id="a6" uri="https://x/v.mp4" provider="storage" status="ready" durationMs={3000} testID="v" />));
|
|
194
|
+
fireEvent.click(screen.getByTestId("v-poster"));
|
|
195
|
+
act(() => void pauseChatPlayback(["other"]));
|
|
196
|
+
expect(player.pause).not.toHaveBeenCalled();
|
|
197
|
+
act(() => void pauseChatPlayback((id) => id === "a6"));
|
|
198
|
+
expect(player.pause).toHaveBeenCalledTimes(1);
|
|
199
|
+
expect(screen.getByTestId("v-play").getAttribute("aria-label")).toBe("Play video");
|
|
200
|
+
});
|
|
201
|
+
|
|
158
202
|
it("renders nothing without expo-video (the bubble keeps its row)", () => {
|
|
159
203
|
const { container } = render(<ChatVideoPlayer id="a" uri="https://x" durationMs={1} />);
|
|
160
204
|
expect(container.innerHTML).toBe("");
|
|
@@ -319,6 +363,39 @@ describe("the bubble", () => {
|
|
|
319
363
|
expect(onOpenAttachment).toHaveBeenCalledTimes(2);
|
|
320
364
|
});
|
|
321
365
|
|
|
366
|
+
it("tells a kind the card is the sender's own, so it can drop its button there", () => {
|
|
367
|
+
const checkIn = attachment({ id: "c1", kind: "check_in", provider: "none", providerId: null, status: "ready", ref: {} });
|
|
368
|
+
const action = vi.fn((_a: ChatAttachment, ctx?: ChatCardContext) => (ctx?.mine ? null : { label: "Answer" }));
|
|
369
|
+
const subtitle = vi.fn((_a: ChatAttachment, ctx?: ChatCardContext) => (ctx?.mine ? "Sent" : null));
|
|
370
|
+
const kinds = { check_in: { title: () => "Check-in", subtitle, action } };
|
|
371
|
+
const theirs = render(withModules({}, <MessageBubble row={row({ attachments: [checkIn] })} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} onOpenAttachment={vi.fn()} testID="b" />));
|
|
372
|
+
expect(screen.getByTestId("b-card-0-action").textContent).toBe("Answer");
|
|
373
|
+
theirs.unmount();
|
|
374
|
+
render(withModules({}, <MessageBubble row={row({ senderId: ME, attachments: [checkIn] })} me={ME} urls={{}} group={false} reducedMotion attachmentKinds={kinds} onOpenAttachment={vi.fn()} testID="b" />));
|
|
375
|
+
expect(screen.queryByTestId("b-card-0-action")).toBeNull();
|
|
376
|
+
expect(screen.getByTestId("b-card-0").textContent).toContain("Sent");
|
|
377
|
+
expect(screen.getByTestId("b-bubble").getAttribute("data-a11y-actions") ?? "").not.toContain("card-0");
|
|
378
|
+
expect(action).toHaveBeenLastCalledWith(checkIn, { mine: true });
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("previews an app kind by its title, not as Attachment (inbox and the long-press sheet)", () => {
|
|
382
|
+
const kinds = { check_in: { title: () => "Check-in" } };
|
|
383
|
+
expect(attachmentPreview("", "check_in", kinds)).toBe("Check-in");
|
|
384
|
+
expect(attachmentPreview("", "check_in", undefined)).toBe("Attachment");
|
|
385
|
+
expect(attachmentPreview("how did it go?", "check_in", kinds)).toBe("how did it go?");
|
|
386
|
+
expect(attachmentPreview("**", "check_in", kinds)).toBe("Check-in"); // only markdown marks: no text
|
|
387
|
+
expect(attachmentPreview("", "check_in", kinds, true)).toBe("Message deleted");
|
|
388
|
+
expect(attachmentPreview("", "check_in", { check_in: { title: (a) => `Check-in ${String(a.ref.week)}`, label: "Check-in" } })).toBe("Check-in");
|
|
389
|
+
expect(attachmentPreview("", "check_in", { check_in: { title: () => { throw new Error("x"); } } })).toBe("Attachment");
|
|
390
|
+
const m = row({ attachments: [attachment({ id: "c1", kind: "check_in", provider: "none", ref: {} })] }).item;
|
|
391
|
+
expect(messagePreview((m as { message: ChatMessage }).message, kinds)).toBe("Check-in");
|
|
392
|
+
const inbox = inboxRow(
|
|
393
|
+
{ conversationId: "c", kind: "direct", title: "Sam", otherUserId: null, unread: 0, mentioned: false, lastMessageAt: null, lastMessage: { id: "m", seq: 1, senderId: SAM, kind: "text", body: "", deleted: false, createdAt: "", attachment: "check_in" } } as never,
|
|
394
|
+
{ me: ME, copy: chatNativeCopy, person: () => undefined, kinds },
|
|
395
|
+
);
|
|
396
|
+
expect(inbox.preview).toBe("Check-in");
|
|
397
|
+
});
|
|
398
|
+
|
|
322
399
|
it("shows a hosted video still on its way as Processing, not an empty bubble", () => {
|
|
323
400
|
const pending = {
|
|
324
401
|
clientId: "c9", senderId: ME, createdAt: "2026-10-25T10:00:00Z", status: "sending" as const, files: [],
|
|
@@ -5,12 +5,21 @@
|
|
|
5
5
|
* button runs the kind's `onPress`; else the app's `onOpenAttachment` (open the form in the
|
|
6
6
|
* app's own screen); else its `href`, when it is https. The bubble offers the same button as
|
|
7
7
|
* a VoiceOver action (`cardAction`), since an accessible bubble hides what is inside it.
|
|
8
|
+
*
|
|
9
|
+
* The kind's `title`, `subtitle` and `action` get a second argument, `{ mine }` (`ChatCardContext`):
|
|
10
|
+
* true on the sender's own card, so a kind can drop its button there (you don't answer your own
|
|
11
|
+
* check-in). Previews (the inbox, the long-press sheet) name a card by its kind's `label`, else
|
|
12
|
+
* its title, not "Attachment".
|
|
8
13
|
*/
|
|
9
14
|
import { Linking, View } from "react-native";
|
|
10
15
|
import { NativeButton, NativeIcon, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
11
16
|
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
12
17
|
import type { ChatAttachment, ChatMessage } from "../core/types";
|
|
13
|
-
import type { ChatAttachmentKind } from "../react/index";
|
|
18
|
+
import type { ChatAttachmentKind, ChatCardContext } from "../react/index";
|
|
19
|
+
import { attachmentPreview, callAttachmentKind as call, cardTitle, messagePreview } from "../react/previews";
|
|
20
|
+
|
|
21
|
+
export type { ChatCardContext };
|
|
22
|
+
export { attachmentPreview, cardTitle, messagePreview };
|
|
14
23
|
import { fill } from "./copy";
|
|
15
24
|
import { useChatNative } from "./context";
|
|
16
25
|
import { ON_TINT_FILL, ON_TINT_FILL_STRONG } from "./media-colors";
|
|
@@ -42,10 +51,16 @@ export function isOpenableHref(href: string | null | undefined): href is string
|
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
/** What a card does: the kind's own handler, else the app's, else an https href; null without a button. */
|
|
45
|
-
export function cardAction(
|
|
54
|
+
export function cardAction(
|
|
55
|
+
kind: ChatAttachmentKind | undefined,
|
|
56
|
+
attachment: ChatAttachment,
|
|
57
|
+
message: ChatMessage,
|
|
58
|
+
onOpenAttachment?: OpenAttachment,
|
|
59
|
+
ctx: ChatCardContext = { mine: false },
|
|
60
|
+
): { label: string; run: () => void } | null {
|
|
46
61
|
let action: ReturnType<NonNullable<ChatAttachmentKind["action"]>> = null;
|
|
47
62
|
try {
|
|
48
|
-
action = kind
|
|
63
|
+
action = call(kind, "action", attachment, ctx);
|
|
49
64
|
} catch {
|
|
50
65
|
return null;
|
|
51
66
|
}
|
|
@@ -70,15 +85,16 @@ export function ChatAttachmentCard({ attachment, message, kind, mine = false, on
|
|
|
70
85
|
const colors = useThemeRoles();
|
|
71
86
|
let title = "";
|
|
72
87
|
let subtitle: string | null | undefined = null;
|
|
88
|
+
const ctx: ChatCardContext = { mine };
|
|
73
89
|
try {
|
|
74
|
-
title = kind
|
|
75
|
-
subtitle = kind
|
|
90
|
+
title = call(kind, "title", attachment, ctx) ?? "";
|
|
91
|
+
subtitle = call(kind, "subtitle", attachment, ctx);
|
|
76
92
|
} catch (e) {
|
|
77
93
|
// An app's kind that throws shows as a plain card, and says so in development.
|
|
78
94
|
if (typeof __DEV__ !== "undefined" && __DEV__) console.warn(`[genesis-chat] attachment kind "${attachment.kind}" threw:`, e);
|
|
79
95
|
}
|
|
80
96
|
title = title || attachment.name || attachment.kind;
|
|
81
|
-
const action = cardAction(kind, attachment, message, onOpenAttachment);
|
|
97
|
+
const action = cardAction(kind, attachment, message, onOpenAttachment, ctx);
|
|
82
98
|
const fg = mine ? "text-primary-foreground" : undefined;
|
|
83
99
|
return (
|
|
84
100
|
<View
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
import { useCallback, useMemo, useState } from "react";
|
|
10
10
|
import { NativeAvatar } from "@marktiderman/genesis-ui-native/components";
|
|
11
11
|
import { InboxPage, type InboxItem, type InboxPageProps } from "@marktiderman/genesis-ui-native/layouts";
|
|
12
|
-
import { previewText } from "../core/thread";
|
|
13
12
|
import type { ChatInboxItem } from "../core/types";
|
|
14
|
-
import { chatKeys, useChat, useInbox, usePeople } from "../react/index";
|
|
13
|
+
import { chatKeys, useChat, useInbox, usePeople, type ChatAttachmentKind } from "../react/index";
|
|
14
|
+
import { attachmentPreview } from "./attachment-cards";
|
|
15
15
|
import { useQueryClient } from "@tanstack/react-query";
|
|
16
16
|
import type { ChatNativeCopy } from "./copy";
|
|
17
17
|
import { initials, useChatNative, type ChatPerson } from "./context";
|
|
@@ -37,12 +37,18 @@ export interface ChatInboxScreenProps {
|
|
|
37
37
|
/** An inbox row from a conversation. Exported for apps that build their own list. */
|
|
38
38
|
export function inboxRow(
|
|
39
39
|
item: ChatInboxItem,
|
|
40
|
-
ctx: {
|
|
40
|
+
ctx: {
|
|
41
|
+
me: string;
|
|
42
|
+
copy: ChatNativeCopy;
|
|
43
|
+
person: (id: string | null) => ChatPerson | undefined;
|
|
44
|
+
/** The app's attachment kinds: a last message that is a card reads as its title ("Check-in"). */
|
|
45
|
+
kinds?: Record<string, ChatAttachmentKind>;
|
|
46
|
+
},
|
|
41
47
|
): Omit<InboxItem, "onPress" | "swipeActions"> {
|
|
42
48
|
const other = item.otherUserId ? ctx.person(item.otherUserId) : undefined;
|
|
43
49
|
const title = item.title ?? other?.name ?? ctx.copy.untitled;
|
|
44
50
|
const last = item.lastMessage;
|
|
45
|
-
const text = last ?
|
|
51
|
+
const text = last ? attachmentPreview(displayBody(last.body), last.attachment, ctx.kinds, last.deleted) : "";
|
|
46
52
|
const preview = last && text && last.senderId === ctx.me && last.kind !== "system" ? `${ctx.copy.youPrefix}${text}` : text;
|
|
47
53
|
return {
|
|
48
54
|
id: item.conversationId,
|
|
@@ -57,7 +63,7 @@ export function inboxRow(
|
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
export function ChatInboxScreen({ onOpen, onStart, kind, kinds, excludeKinds, archived, pageProps, testID = "chat-inbox" }: ChatInboxScreenProps) {
|
|
60
|
-
const { client, userId } = useChat();
|
|
66
|
+
const { client, userId, attachmentKinds } = useChat();
|
|
61
67
|
const queryClient = useQueryClient();
|
|
62
68
|
const { copy, person: known } = useChatNative();
|
|
63
69
|
const inbox = useInbox({ kind, kinds, excludeKinds, archived });
|
|
@@ -82,7 +88,7 @@ export function ChatInboxScreen({ onOpen, onStart, kind, kinds, excludeKinds, ar
|
|
|
82
88
|
}, [queryClient]);
|
|
83
89
|
|
|
84
90
|
const items: InboxItem[] = inbox.items.map((item) => ({
|
|
85
|
-
...inboxRow(item, { me: userId, copy, person }),
|
|
91
|
+
...inboxRow(item, { me: userId, copy, person, kinds: attachmentKinds }),
|
|
86
92
|
onPress: () => onOpen(item.conversationId),
|
|
87
93
|
swipeActions: [
|
|
88
94
|
...(item.unread > 0 ? [{ id: "read", label: copy.markRead, onPress: () => void client.markRead(item.conversationId).then(settle, settle) }] : []),
|
package/src/native/index.ts
CHANGED
|
@@ -32,7 +32,7 @@ export { ChatStartSheet, ChatStartButton, type ChatStartPerson, type ChatStartSh
|
|
|
32
32
|
export { ChatComposer, type ChatComposerProps } from "./composer";
|
|
33
33
|
export { MessageBubble, displayBody, formatClock, type MessageBubbleProps } from "./message-bubble";
|
|
34
34
|
export { MessageActionsSheet, type MessageActionsSheetProps } from "./message-actions-sheet";
|
|
35
|
-
export { VoiceNotePlayer, claimPlayback, releasePlayback, activePlaybackId, toggleVoiceNote, cycleVoiceNoteRate, usePlayingVoiceNote, scrubSeconds, waveformBars, barsThatFit, type VoiceNotePlayerProps } from "./voice-player";
|
|
35
|
+
export { VoiceNotePlayer, claimPlayback, releasePlayback, pauseChatPlayback, activePlaybackId, toggleVoiceNote, cycleVoiceNoteRate, usePlayingVoiceNote, scrubSeconds, waveformBars, barsThatFit, type VoiceNotePlayerProps } from "./voice-player";
|
|
36
36
|
export { MessageAttachments, attachmentPaths, type BubbleAttachment } from "./media";
|
|
37
37
|
export {
|
|
38
38
|
ChatMediaViewer,
|
|
@@ -72,7 +72,7 @@ export { ChatVideoPlayer, videoState, videoSource, muxStreamUrl, muxPosterUrl, t
|
|
|
72
72
|
export { chunkedPut, fileSource, nextOffset, createVideoUploads, videoUploads, type VideoUploadError, type MintedUpload, useVideoUploads, recordedVideoFile, VIDEO_CHUNK_BYTES, type ByteSource, type ChunkedPutOptions, type RecordedVideo, type VideoUploadTask, type VideoUploads, type StartVideoInput, type OutboxSend } from "./video-upload";
|
|
73
73
|
export { VideoUploadTray, type VideoUploadTrayProps } from "./video-upload-tray";
|
|
74
74
|
export { ChatLinkPreviewCard, previewToShow, linkPreviewIds, openChatLink, linkHost, type ChatLinkPreviewCardProps } from "./link-preview";
|
|
75
|
-
export { ChatAttachmentCard, splitCardAttachments, isOpenableHref, cardAction, type ChatAttachmentCardProps, type OpenAttachment } from "./attachment-cards";
|
|
75
|
+
export { ChatAttachmentCard, splitCardAttachments, isOpenableHref, cardAction, cardTitle, attachmentPreview, messagePreview, type ChatAttachmentCardProps, type ChatCardContext, type OpenAttachment } from "./attachment-cards";
|
|
76
76
|
export { ChatNativeProvider, useChatNative, initials, type ChatNativeProviderProps, type ChatNativeContextValue, type ChatPerson } from "./context";
|
|
77
77
|
export { chatNativeCopy, resolveChatCopy, fill, type ChatNativeCopy, type ChatNativeCopyOverrides } from "./copy";
|
|
78
78
|
export {
|
|
@@ -9,7 +9,9 @@ import { useEffect, useState } from "react";
|
|
|
9
9
|
import { Alert, Pressable, View } from "react-native";
|
|
10
10
|
import { NativeIcon, NativeListItem, NativeSheet, NativeText, type NativeIconName } from "@marktiderman/genesis-ui-native/components";
|
|
11
11
|
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
12
|
-
import {
|
|
12
|
+
import { summarizeReactions } from "../core/thread";
|
|
13
|
+
import type { ChatAttachmentKind } from "../react/index";
|
|
14
|
+
import { messagePreview } from "./attachment-cards";
|
|
13
15
|
import type { ChatMessage } from "../core/types";
|
|
14
16
|
import { QUICK_REACTIONS, canReactTo, messageActionsFor, type MessageActionContext, type MessageActionId } from "./actions";
|
|
15
17
|
import { fill } from "./copy";
|
|
@@ -22,12 +24,14 @@ export interface MessageActionsSheetProps {
|
|
|
22
24
|
onClose: () => void;
|
|
23
25
|
onReact: (message: ChatMessage, emoji: string, on: boolean) => void;
|
|
24
26
|
onAction: (message: ChatMessage, action: MessageActionId) => void;
|
|
27
|
+
/** The app's attachment kinds (`ChatProvider attachmentKinds`): the caption names a card by its title. */
|
|
28
|
+
attachmentKinds?: Record<string, ChatAttachmentKind>;
|
|
25
29
|
testID?: string;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
const ICONS: Record<MessageActionId, NativeIconName> = { reply: "reply", copy: "copy", pin: "pin", unpin: "pin", delete: "trash" };
|
|
29
33
|
|
|
30
|
-
export function MessageActionsSheet({ message, context, onClose, onReact, onAction, testID = "chat-message-actions" }: MessageActionsSheetProps) {
|
|
34
|
+
export function MessageActionsSheet({ message, context, onClose, onReact, onAction, attachmentKinds, testID = "chat-message-actions" }: MessageActionsSheetProps) {
|
|
31
35
|
const { copy } = useChatNative();
|
|
32
36
|
const colors = useThemeRoles();
|
|
33
37
|
const actions = message ? messageActionsFor(message, context) : [];
|
|
@@ -67,9 +71,10 @@ export function MessageActionsSheet({ message, context, onClose, onReact, onActi
|
|
|
67
71
|
/>
|
|
68
72
|
</View>
|
|
69
73
|
) : message ? (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
74
|
+
// Room at the top: the iOS page sheet has no grabber, and its rounded corner clipped the caption.
|
|
75
|
+
<View style={{ gap: 12, paddingTop: 20, paddingBottom: 8, paddingHorizontal: 16 }}>
|
|
76
|
+
<NativeText testID={`${testID}-caption`} numberOfLines={2} preset="body-sm" style={{ color: colors.secondaryLabel, paddingHorizontal: 4 }}>
|
|
77
|
+
{messagePreview(message, attachmentKinds)}
|
|
73
78
|
</NativeText>
|
|
74
79
|
{canReactTo(message, context) ? (
|
|
75
80
|
<View accessibilityRole="toolbar" style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
|
@@ -17,7 +17,7 @@ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
|
17
17
|
import { previewText, summarizeReactions } from "../core/thread";
|
|
18
18
|
import type { ChatLinkPreview, ChatMessage, PendingFile } from "../core/types";
|
|
19
19
|
import type { ChatAttachmentKind } from "../react/index";
|
|
20
|
-
import { cardAction, ChatAttachmentCard, splitCardAttachments, type OpenAttachment } from "./attachment-cards";
|
|
20
|
+
import { cardAction, cardTitle, ChatAttachmentCard, splitCardAttachments, type OpenAttachment } from "./attachment-cards";
|
|
21
21
|
import { ChatLinkPreviewCard, linkHost, openChatLink } from "./link-preview";
|
|
22
22
|
import { cycleVideoRate, fullScreenVideo, toggleVideo, toggleVideoMute, usePlayingVideo } from "./video-player";
|
|
23
23
|
import { fill } from "./copy";
|
|
@@ -113,7 +113,7 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
|
|
|
113
113
|
const photos = attachments.filter((a) => a.kind === "image" && a.uri && props.onOpenImage);
|
|
114
114
|
const videos = attachments.filter((a) => a.kind === "video" && modules.video && (a.uri || a.playbackId));
|
|
115
115
|
const playingVideo = usePlayingVideo(videos.map((a) => a.key));
|
|
116
|
-
const cardActions = message ? cards.map((a) => cardAction(kinds?.[a.kind], a, message, props.onOpenAttachment)) : [];
|
|
116
|
+
const cardActions = message ? cards.map((a) => cardAction(kinds?.[a.kind], a, message, props.onOpenAttachment, { mine })) : [];
|
|
117
117
|
const playingVoice = usePlayingVoiceNote(voices.map((a) => a.key));
|
|
118
118
|
|
|
119
119
|
// ---- Swipe to reply ----
|
|
@@ -166,7 +166,7 @@ function MessageBubbleImpl(props: MessageBubbleProps) {
|
|
|
166
166
|
senderName,
|
|
167
167
|
message?.deletedAt ? copy.deleted : displayBody(body),
|
|
168
168
|
attachments.map((a) => (a.kind === "image" ? copy.photo : a.kind === "voice" ? copy.voice : a.kind === "video" ? copy.video : copy.file)).join(", "),
|
|
169
|
-
cards.map((a) => cardTitle(kinds?.[a.kind], a)).join(", "),
|
|
169
|
+
cards.map((a) => cardTitle(kinds?.[a.kind], a, { mine })).join(", "),
|
|
170
170
|
preview ? fill(copy.linkPreview, { title: preview.title?.trim() || linkHost(preview.url), site: preview.siteName?.trim() || linkHost(preview.url) }) : null,
|
|
171
171
|
time,
|
|
172
172
|
message?.editedAt ? copy.edited : null,
|
|
@@ -362,13 +362,5 @@ function SmallAction({ label, onPress, color, testID }: { label: string; onPress
|
|
|
362
362
|
);
|
|
363
363
|
}
|
|
364
364
|
|
|
365
|
-
/** A card's title for the bubble's label (an app kind that throws reads as its name). */
|
|
366
|
-
function cardTitle(kind: ChatAttachmentKind | undefined, a: ChatMessage["attachments"][number]): string {
|
|
367
|
-
try {
|
|
368
|
-
return kind?.title(a) || a.name || a.kind;
|
|
369
|
-
} catch {
|
|
370
|
-
return a.name || a.kind;
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
365
|
|
|
374
366
|
export const MessageBubble = memo(MessageBubbleImpl);
|
package/src/native/modules.ts
CHANGED
|
@@ -201,6 +201,8 @@ export interface ExpoVideoLike {
|
|
|
201
201
|
contentFit?: "contain" | "cover" | "fill";
|
|
202
202
|
nativeControls?: boolean;
|
|
203
203
|
allowsPictureInPicture?: boolean;
|
|
204
|
+
onFullscreenEnter?: () => void;
|
|
205
|
+
onFullscreenExit?: () => void;
|
|
204
206
|
testID?: string;
|
|
205
207
|
}>;
|
|
206
208
|
}
|
|
@@ -24,9 +24,9 @@ import {
|
|
|
24
24
|
} from "@marktiderman/genesis-ui-native/components";
|
|
25
25
|
import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
|
|
26
26
|
import { isSchedulable, scheduleLabel, scheduleQuickPicks } from "../core/extras";
|
|
27
|
-
import { previewText } from "../core/thread";
|
|
28
27
|
import type { ScheduleInput, ScheduledMessage } from "../core/types";
|
|
29
|
-
import { useScheduled } from "../react/index";
|
|
28
|
+
import { useChat, useScheduled, type ChatAttachmentKind } from "../react/index";
|
|
29
|
+
import { attachmentPreview } from "./attachment-cards";
|
|
30
30
|
import { toSend, type ComposerState } from "./composer-state";
|
|
31
31
|
import { haptic } from "./modules";
|
|
32
32
|
import type { ChatComposerHandle } from "./use-composer";
|
|
@@ -148,11 +148,7 @@ export function ChatScheduleSheet({ open, onOpenChange, onSchedule, now, testID
|
|
|
148
148
|
};
|
|
149
149
|
|
|
150
150
|
return (
|
|
151
|
-
<NativeSheet
|
|
152
|
-
open={open}
|
|
153
|
-
onOpenChange={onOpenChange}
|
|
154
|
-
testID={testID}
|
|
155
|
-
>
|
|
151
|
+
<NativeSheet open={open} onOpenChange={onOpenChange} fitContent testID={testID}>
|
|
156
152
|
<View style={{ paddingVertical: 8 }}>
|
|
157
153
|
<View style={{ paddingHorizontal: 16, paddingBottom: 8, gap: 4 }}>
|
|
158
154
|
<NativeText preset="h4" accessibilityRole="header">
|
|
@@ -234,12 +230,30 @@ export function ChatScheduledList({ conversationId, conversationTitle, testID =
|
|
|
234
230
|
);
|
|
235
231
|
}
|
|
236
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Cancel's (or Remove's) label on a scheduled row: the message's text as well as its time, so
|
|
235
|
+
* each row's button says which message it takes back.
|
|
236
|
+
*/
|
|
237
|
+
export function scheduledActionLabel(
|
|
238
|
+
item: ScheduledMessage,
|
|
239
|
+
copy: Pick<ReturnType<typeof useChatNative>["copy"], "scheduledCancel" | "scheduledRemove" | "scheduledFor">,
|
|
240
|
+
kinds?: Record<string, ChatAttachmentKind>,
|
|
241
|
+
): string {
|
|
242
|
+
const text = attachmentPreview(displayBody(item.body), item.attachments[0]?.kind ?? null, kinds);
|
|
243
|
+
// Cut by character, not UTF-16 unit, so an emoji isn't split.
|
|
244
|
+
const chars = Array.from(text);
|
|
245
|
+
const short = chars.length > 60 ? `${chars.slice(0, 59).join("")}…` : text;
|
|
246
|
+
if (item.status === "failed") return [copy.scheduledRemove, short].filter(Boolean).join(", ");
|
|
247
|
+
return [copy.scheduledCancel, short, fill(copy.scheduledFor, { when: scheduleLabel(item.sendAt) })].filter(Boolean).join(", ");
|
|
248
|
+
}
|
|
249
|
+
|
|
237
250
|
function ScheduledRow({ item, where, onCancel, testID }: { item: ScheduledMessage; where: string | null; onCancel: () => void; testID: string }) {
|
|
238
251
|
const { copy } = useChatNative();
|
|
239
252
|
const colors = useThemeRoles();
|
|
253
|
+
const { attachmentKinds } = useChat();
|
|
240
254
|
const failed = item.status === "failed";
|
|
241
255
|
const when = failed ? copy.scheduledCouldntSend : scheduleLabel(item.sendAt);
|
|
242
|
-
const text =
|
|
256
|
+
const text = attachmentPreview(displayBody(item.body), item.attachments[0]?.kind ?? null, attachmentKinds);
|
|
243
257
|
return (
|
|
244
258
|
<View testID={testID} style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingLeft: 16, paddingRight: 4, paddingVertical: 8, minHeight: 56 }}>
|
|
245
259
|
<NativeIcon name={failed ? "warning" : "clock"} size={18} color={failed ? colors.destructive : colors.secondaryLabel} />
|
|
@@ -261,7 +275,7 @@ function ScheduledRow({ item, where, onCancel, testID }: { item: ScheduledMessag
|
|
|
261
275
|
<Pressable
|
|
262
276
|
testID={`${testID}-cancel`}
|
|
263
277
|
accessibilityRole="button"
|
|
264
|
-
accessibilityLabel={
|
|
278
|
+
accessibilityLabel={scheduledActionLabel(item, copy, attachmentKinds)}
|
|
265
279
|
onPress={onCancel}
|
|
266
280
|
style={{ minHeight: 44, minWidth: 44, paddingHorizontal: 12, alignItems: "center", justifyContent: "center" }}
|
|
267
281
|
>
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* (`ChatThreadScreen jumpTo`, which loads earlier pages until it is there). The same rules as
|
|
8
8
|
* the web's `ChatSearch`.
|
|
9
9
|
*/
|
|
10
|
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
10
|
+
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
|
11
11
|
import { AccessibilityInfo, FlatList, Pressable, View } from "react-native";
|
|
12
12
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
13
13
|
import { NativeBadge, NativeEmptyState, NativeIcon, NativeSearchBar, NativeSkeleton, NativeText } from "@marktiderman/genesis-ui-native/components";
|
|
@@ -123,12 +123,10 @@ export function ChatSearchScreen({ conversationId, onOpen, conversationTitle, de
|
|
|
123
123
|
const where = conversationId ? null : fill(copy.searchIn, { name: titleOf(h.conversationId) });
|
|
124
124
|
const when = formatInboxTime(new Date(h.createdAt), now());
|
|
125
125
|
return (
|
|
126
|
-
<
|
|
126
|
+
<HitRow
|
|
127
127
|
testID={`${testID}-hit-${h.messageId}`}
|
|
128
|
-
accessibilityRole="button"
|
|
129
128
|
accessibilityLabel={[who, where, when, h.inTranscript ? copy.transcript : null, snippetText(h.snippet)].filter(Boolean).join(", ")}
|
|
130
129
|
onPress={() => onOpen(h)}
|
|
131
|
-
style={({ pressed }: { pressed: boolean }) => ({ minHeight: 56, paddingHorizontal: 16, paddingVertical: 10, gap: 4, backgroundColor: pressed ? colors.fill : "transparent" })}
|
|
132
130
|
>
|
|
133
131
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
134
132
|
<NativeText preset="label" numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
@@ -158,7 +156,7 @@ export function ChatSearchScreen({ conversationId, onOpen, conversationTitle, de
|
|
|
158
156
|
<SearchSnippet snippet={h.snippet} />
|
|
159
157
|
</View>
|
|
160
158
|
</View>
|
|
161
|
-
</
|
|
159
|
+
</HitRow>
|
|
162
160
|
);
|
|
163
161
|
}}
|
|
164
162
|
/>
|
|
@@ -166,3 +164,26 @@ export function ChatSearchScreen({ conversationId, onOpen, conversationTitle, de
|
|
|
166
164
|
</SafeAreaView>
|
|
167
165
|
);
|
|
168
166
|
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* One result's row. A static style, with the pressed state kept here: with a `style` function
|
|
170
|
+
* the row's padding wasn't applied in the starter on iOS 27 (the name and time touched the
|
|
171
|
+
* screen's edges).
|
|
172
|
+
*/
|
|
173
|
+
function HitRow({ testID, accessibilityLabel, onPress, children }: { testID: string; accessibilityLabel: string; onPress: () => void; children: ReactNode }) {
|
|
174
|
+
const colors = useThemeRoles();
|
|
175
|
+
const [pressed, setPressed] = useState(false);
|
|
176
|
+
return (
|
|
177
|
+
<Pressable
|
|
178
|
+
testID={testID}
|
|
179
|
+
accessibilityRole="button"
|
|
180
|
+
accessibilityLabel={accessibilityLabel}
|
|
181
|
+
onPress={onPress}
|
|
182
|
+
onPressIn={() => setPressed(true)}
|
|
183
|
+
onPressOut={() => setPressed(false)}
|
|
184
|
+
style={{ minHeight: 56, paddingHorizontal: 16, paddingVertical: 10, gap: 4, backgroundColor: pressed ? colors.fill : "transparent" }}
|
|
185
|
+
>
|
|
186
|
+
{children}
|
|
187
|
+
</Pressable>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
@@ -52,6 +52,7 @@ import { ChatPhotoViewer, galleryFor, type ChatPhoto } from "./photo-viewer";
|
|
|
52
52
|
import { MessageActionsSheet } from "./message-actions-sheet";
|
|
53
53
|
import { displayBody, MessageBubble } from "./message-bubble";
|
|
54
54
|
import { haptic } from "./modules";
|
|
55
|
+
import { pauseChatPlayback } from "./voice-player";
|
|
55
56
|
import { buildThreadRows, type ThreadRow } from "./rows";
|
|
56
57
|
|
|
57
58
|
/** How long an upload minted for a thrown-away take is kept for the next one. */
|
|
@@ -79,6 +80,12 @@ export interface ChatThreadScreenProps {
|
|
|
79
80
|
availability?: boolean;
|
|
80
81
|
/** An app attachment card's button (a form, a check-in), when its kind gives no `onPress`. */
|
|
81
82
|
onOpenAttachment?: OpenAttachment;
|
|
83
|
+
/**
|
|
84
|
+
* Whether the screen is the one shown (the navigator's `useIsFocused()`). When it turns false a
|
|
85
|
+
* video or voice note of this thread that is playing pauses: switching tab keeps the screen
|
|
86
|
+
* mounted. Default true.
|
|
87
|
+
*/
|
|
88
|
+
focused?: boolean;
|
|
82
89
|
testID?: string;
|
|
83
90
|
}
|
|
84
91
|
|
|
@@ -133,7 +140,7 @@ export function nearBottom(m: { contentOffset: { y: number }; contentSize: { hei
|
|
|
133
140
|
}
|
|
134
141
|
|
|
135
142
|
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;
|
|
143
|
+
const { conversationId, threadRootId = null, keyboardVerticalOffset, onOpenAttachment, jumpTo, topic: topicProp, showTopics = false, scheduling = true, availability = true, focused = true, testID = "chat-thread" } = props;
|
|
137
144
|
const chat = useChat();
|
|
138
145
|
const { userId, videoUpload, attachmentKinds, outbox } = chat;
|
|
139
146
|
// Off unless the app turns it on: an og:image fetched from the phone tells its site who reads it.
|
|
@@ -209,6 +216,16 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
|
|
|
209
216
|
const rows = useMemo(() => buildThreadRows(conv.items, { me: userId, firstUnreadId: conv.firstUnreadId, group }), [conv.items, userId, conv.firstUnreadId, group]);
|
|
210
217
|
const paths = useMemo(() => conv.items.flatMap((i) => (i.type === "message" ? attachmentPaths(i.message.attachments) : [])), [conv.items]);
|
|
211
218
|
const urls = useFileUrls(paths);
|
|
219
|
+
// Out of sight (another tab, a screen pushed over it): what this thread is playing pauses.
|
|
220
|
+
// Players are keyed by attachment id, or `<clientId>:…` while the message is still sending.
|
|
221
|
+
const itemsNow = useRef(conv.items);
|
|
222
|
+
itemsNow.current = conv.items;
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
if (focused) return;
|
|
225
|
+
pauseChatPlayback((id) =>
|
|
226
|
+
itemsNow.current.some((i) => (i.type === "message" ? i.message.attachments.some((a) => a.id === id) : i.type === "pending" && id.startsWith(`${i.pending.clientId}:`))),
|
|
227
|
+
);
|
|
228
|
+
}, [focused]);
|
|
212
229
|
const byId = useMemo(() => new Map(conv.messages.map((m) => [m.id, m])), [conv.messages]);
|
|
213
230
|
const linkIds = useMemo(() => linkPreviewIds(conv.messages), [conv.messages]);
|
|
214
231
|
const previews = useLinkPreviews(linkIds);
|
|
@@ -321,7 +338,16 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
|
|
|
321
338
|
loadUntil,
|
|
322
339
|
scrollTo: (index, id) => {
|
|
323
340
|
jumpingTo.current = { id, tries: 0 };
|
|
324
|
-
|
|
341
|
+
const scroll = (at: number) => listRef.current?.scrollToIndex?.({ index: at, animated: !reducedMotion, viewPosition: 0.5 });
|
|
342
|
+
requestAnimationFrame(() => scroll(index));
|
|
343
|
+
// Once more after the first layout: with the message already loaded (the thread was opened
|
|
344
|
+
// before), the jump runs on the first frame, and FlashList's start-at-the-bottom placement,
|
|
345
|
+
// which comes after it, would win and leave the thread at the bottom.
|
|
346
|
+
if (retryTimer.current) clearTimeout(retryTimer.current);
|
|
347
|
+
retryTimer.current = setTimeout(() => {
|
|
348
|
+
const at = rowIndexOf(rowsNow.current, id);
|
|
349
|
+
if (at >= 0) scroll(at);
|
|
350
|
+
}, 250);
|
|
325
351
|
},
|
|
326
352
|
onStart: () => {
|
|
327
353
|
atBottom.current = false;
|
|
@@ -447,7 +473,7 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
|
|
|
447
473
|
<LoadingThread testID={`${testID}-loading`} />
|
|
448
474
|
) : empty ? (
|
|
449
475
|
<View style={{ flex: 1, justifyContent: "center" }}>
|
|
450
|
-
<NativeEmptyState variant="first-run" title={copy.threadEmptyTitle} body={copy.threadEmptyBody} testID={`${testID}-empty`} />
|
|
476
|
+
<NativeEmptyState variant="first-run" title={copy.threadEmptyTitle} body={currentTopic?.closedAt ? copy.topicClosedNotice : copy.threadEmptyBody} testID={`${testID}-empty`} />
|
|
451
477
|
</View>
|
|
452
478
|
) : (
|
|
453
479
|
<List
|
|
@@ -534,6 +560,7 @@ export function ChatThreadScreen(props: ChatThreadScreenProps) {
|
|
|
534
560
|
onClose={() => setActionsFor(null)}
|
|
535
561
|
onReact={react}
|
|
536
562
|
onAction={onAction}
|
|
563
|
+
attachmentKinds={attachmentKinds}
|
|
537
564
|
testID={`${testID}-actions`}
|
|
538
565
|
/>
|
|
539
566
|
<ChatPhotoViewer photos={viewer?.photos ?? null} initialIndex={viewer?.index} onClose={() => setViewer(null)} testID={`${testID}-viewer`} />
|