@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
@@ -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 { ChatAttachmentCard, isOpenableHref, splitCardAttachments } from "../attachment-cards";
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 VideoView = forwardRef<unknown, { testID?: string }>(function VideoView({ testID }, ref) {
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(kind: ChatAttachmentKind | undefined, attachment: ChatAttachment, message: ChatMessage, onOpenAttachment?: OpenAttachment): { label: string; run: () => void } | null {
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?.action?.(attachment);
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.title(attachment);
75
- subtitle = kind.subtitle?.(attachment);
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: { me: string; copy: ChatNativeCopy; person: (id: string | null) => ChatPerson | undefined },
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 ? previewText(displayBody(last.body), last.attachment, last.deleted) : "";
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) }] : []),
@@ -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 { previewText, summarizeReactions } from "../core/thread";
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
- <View style={{ gap: 12, paddingBottom: 8, paddingHorizontal: 16 }}>
71
- <NativeText numberOfLines={2} preset="body-sm" style={{ color: colors.secondaryLabel, paddingHorizontal: 4 }}>
72
- {previewText(message.body, message.attachments[0]?.kind, Boolean(message.deletedAt))}
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);
@@ -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 = previewText(displayBody(item.body), item.attachments[0]?.kind ?? null);
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={failed ? `${copy.scheduledRemove}, ${text}` : `${copy.scheduledCancel}, ${fill(copy.scheduledFor, { when })}`}
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
- <Pressable
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
- </Pressable>
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
- requestAnimationFrame(() => listRef.current?.scrollToIndex?.({ index, animated: !reducedMotion, viewPosition: 0.5 }));
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`} />