@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,184 @@
1
+ /**
2
+ * @feat:chat
3
+ * `@marktiderman/genesis-chat/native`: chat on genesis-ui-native, over the
4
+ * hooks in `/react`.
5
+ *
6
+ * Rung 1: `ChatInboxScreen` and `ChatThreadScreen` in two routes, under
7
+ * `ChatProvider` (from `/react`) and `ChatNativeProvider` (the native modules,
8
+ * copy and people).
9
+ * Rung 2: the parts (`ChatComposer`, `MessageBubble`, `MessageActionsSheet`,
10
+ * `VoiceNotePlayer`, `ChatPhotoViewer`, `ChatEmojiPicker`, `ChatStartSheet`) in your own screen.
11
+ * Rung 3: the logic (`composerReducer`, `recorderStep`, `buildThreadRows`,
12
+ * `messageActionsFor`, `useVoiceRecorder`, `useChatComposer`).
13
+ * Glue: `createTusUpload` (resumable uploads for `ChatProvider upload`) and
14
+ * the push block's `messages` category, actions and reply handler.
15
+ *
16
+ * Video messages: `ChatVideoRecorder` (expo-camera), `ChatVideoPlayer`
17
+ * (expo-video, HLS), uploads through `ChatProvider videoUpload` with
18
+ * `chunkedPut`; link previews (`ChatLinkPreviewCard`) and the app's own
19
+ * attachment cards (`ChatAttachmentCard`).
20
+ *
21
+ * Optional peers, passed in rather than imported: expo-audio,
22
+ * expo-image-picker, expo-haptics, expo-clipboard, expo-camera, expo-video,
23
+ * expo-web-browser, expo-sharing, expo-media-library, NetInfo, react-native-keyboard-controller; FlashList and expo-image through
24
+ * GenesisNativeApp's `modules`.
25
+ *
26
+ * @stability Beta
27
+ */
28
+ export { ChatInboxScreen, inboxRow, type ChatInboxScreenProps } from "./inbox-screen";
29
+ export { ChatThreadScreen, threadListMode, nearBottom, AT_BOTTOM_POINTS, type ChatThreadScreenProps } from "./thread-screen";
30
+ export { chatThreadStackOptions, useChatThreadStackOptions, type ChatThreadStackOptionsProps } from "./stack-options";
31
+ export { ChatStartSheet, ChatStartButton, type ChatStartPerson, type ChatStartSheetProps } from "./start-sheet";
32
+ export { ChatComposer, type ChatComposerProps } from "./composer";
33
+ export { MessageBubble, displayBody, formatClock, type MessageBubbleProps } from "./message-bubble";
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";
36
+ export { MessageAttachments, attachmentPaths, type BubbleAttachment } from "./media";
37
+ export {
38
+ ChatMediaViewer,
39
+ ChatPhotoViewer,
40
+ galleryFor,
41
+ shouldDismiss,
42
+ pinchScale,
43
+ panLimit,
44
+ isLocalUri,
45
+ DISMISS_DISTANCE,
46
+ DISMISS_VELOCITY,
47
+ MAX_ZOOM,
48
+ type ChatMediaViewerProps,
49
+ type ChatPhoto,
50
+ type ChatPhotoViewerProps,
51
+ } from "./photo-viewer";
52
+ export { ChatEmojiPicker, ChatEmojiPickerSheet, forgetRecentEmoji, type ChatEmojiPickerProps, type ChatEmojiPickerSheetProps } from "./emoji-picker";
53
+ export {
54
+ EMOJI_CATEGORIES,
55
+ emojiInCategory,
56
+ emojiEntry,
57
+ searchEmoji,
58
+ pushRecentEmoji,
59
+ recentEmojiKey,
60
+ loadRecentEmoji,
61
+ saveRecentEmoji,
62
+ insertAtSelection,
63
+ RECENT_EMOJI_MAX,
64
+ type EmojiCategoryId,
65
+ type EmojiEntry,
66
+ } from "./emoji";
67
+ export { ChatAccessError, type ChatAccessErrorProps } from "./access-error";
68
+ export { ChatAutoRetry, useChatAutoRetry, isOnline, type ChatAutoRetryOptions, type AppStateLike } from "./auto-retry";
69
+ export { ChatVideoRecorder, type ChatVideoRecorderProps } from "./video-recorder";
70
+ export { videoRecorderStep, initialVideoRecorder, recordingType, VIDEO_HOLD_MS, VIDEO_MIN_MS, type VideoRecorderState, type VideoRecorderEvent, type VideoRecorderEffect, type VideoRecorderPhase } from "./video-recorder-state";
71
+ export { ChatVideoPlayer, videoState, videoSource, muxStreamUrl, muxPosterUrl, toggleVideo, fullScreenVideo, isVideoPlaying, usePlayingVideo, toggleVideoMute, cycleVideoRate, type ChatVideoPlayerProps, type ChatVideoState } from "./video-player";
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
+ export { VideoUploadTray, type VideoUploadTrayProps } from "./video-upload-tray";
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";
76
+ export { ChatNativeProvider, useChatNative, initials, type ChatNativeProviderProps, type ChatNativeContextValue, type ChatPerson } from "./context";
77
+ export { chatNativeCopy, resolveChatCopy, fill, type ChatNativeCopy, type ChatNativeCopyOverrides } from "./copy";
78
+ export {
79
+ composerReducer,
80
+ emptyComposer,
81
+ canSend,
82
+ composerDisabledReason,
83
+ toSend,
84
+ replyTargetFor,
85
+ filesFromAssets,
86
+ fileFromDataUri,
87
+ base64ToBytes,
88
+ draftKey,
89
+ clearDrafts,
90
+ loadDraft,
91
+ createDraftSaver,
92
+ memoryDraftStorage,
93
+ type ComposerAction,
94
+ type ComposerAttachment,
95
+ type ComposerDraft,
96
+ type ComposerState,
97
+ type ReplyTarget,
98
+ } from "./composer-state";
99
+ export { useChatComposer, type ChatComposerHandle } from "./use-composer";
100
+ export {
101
+ recorderStep,
102
+ idleRecorder,
103
+ liveBars,
104
+ type RecorderConfig,
105
+ type RecorderEffect,
106
+ type RecorderEvent,
107
+ type RecorderPhase,
108
+ type RecorderState,
109
+ type RecorderNotice,
110
+ } from "./recorder-machine";
111
+ export { useVoiceRecorder, voiceNoteFile, type UseVoiceRecorderOptions } from "./use-voice-recorder";
112
+ export { invertedRows, buildThreadRows, rowKey, type ThreadRow, type BuildRowsOptions } from "./rows";
113
+ export { messageActionsFor, canReactTo, QUICK_REACTIONS, type MessageActionId, type MessageActionContext } from "./actions";
114
+ export { createTusUpload, base64Utf8, TUS_CHUNK_BYTES, type TusUploadOptions } from "./tus";
115
+ export {
116
+ CHAT_PUSH_CATEGORY,
117
+ CHAT_REPLY_ACTION,
118
+ CHAT_MARK_READ_ACTION,
119
+ chatPushCopy,
120
+ chatPath,
121
+ chatPushCategory,
122
+ chatNotificationActions,
123
+ registerChatNotificationActions,
124
+ conversationIdFromData,
125
+ replyClientId,
126
+ replyClientIdFor,
127
+ failedReply,
128
+ PUSH_REPLY_WAIT_MS,
129
+ handleChatPushResponse,
130
+ chatThreadMatch,
131
+ chatPushPayload,
132
+ type ChatPushCopy,
133
+ type ChatPushData,
134
+ type ChatPushPayload,
135
+ type ChatPushPayloadInput,
136
+ type ChatPushOutcome,
137
+ type ChatPushOutboxDeps,
138
+ type ChatPushResponseLike,
139
+ type NotificationCategoriesLike,
140
+ } from "./push";
141
+ export { haptic, type ChatNativeModules, type ExpoAudioLike, type ImagePickerLike, type HapticsLike, type ClipboardLike, type KeyboardAvoidingLike, type DraftStorageLike, type AudioPlayerLike, type AudioRecorderLike, type AudioStatusLike, type RecorderStateLike, type PickedAssetLike, type PickerResultLike, type HapticEvent, type ExpoCameraLike, type ExpoVideoLike, type WebBrowserLike, type ChunkFileLike, type VideoPlayerLike, type CameraViewHandleLike, type VideoViewHandleLike, type PermissionLike, type SharingLike, type MediaLibraryLike, type NetInfoLike } from "./modules";
142
+
143
+ // Organising: topics, scheduled send, availability, broadcast, search and moderation.
144
+ export { chatOrganiseCopy, type ChatOrganiseCopy } from "./organise-copy";
145
+ export { ChatIconButton, type ChatIconButtonProps } from "./icon-button";
146
+ export { ChatTopicBar, canManageTopics, topicClosedFor, topicMenuFor, checkTopicName, type ChatTopicBarProps, type TopicMenuAction } from "./topics";
147
+ export {
148
+ ChatScheduleSheet,
149
+ ChatScheduledList,
150
+ ChatScheduledBar,
151
+ useScheduleSend,
152
+ scheduleInputFrom,
153
+ defaultScheduleAt,
154
+ withDay,
155
+ withTime,
156
+ type ChatScheduleSheetProps,
157
+ type ChatScheduledListProps,
158
+ type ChatScheduledBarProps,
159
+ } from "./schedule-sheet";
160
+ export {
161
+ ChatAvailabilityScreen,
162
+ ChatAwayLine,
163
+ useAwayLine,
164
+ awayLineFor,
165
+ defaultWeek,
166
+ weekFromHours,
167
+ hoursFromWeek,
168
+ sameTimeDays,
169
+ formFrom,
170
+ availabilityInput,
171
+ timeZones,
172
+ deviceTimeZone,
173
+ clockToDate,
174
+ dateToClock,
175
+ type ChatAvailabilityScreenProps,
176
+ type ChatAwayLineProps,
177
+ type AvailabilityForm,
178
+ type DayHours,
179
+ type WeekHours,
180
+ } from "./availability";
181
+ export { ChatBroadcastSheet, broadcastOutcome, broadcastRows, type ChatBroadcastSheetProps } from "./broadcast-sheet";
182
+ export { ChatSearchScreen, SearchSnippet, snippetText, type ChatSearchScreenProps } from "./search-screen";
183
+ export { ModerationMarker, moderationNote, moderationText, type ModerationNote } from "./moderation";
184
+ export { useJumpToMessage, rowIndexOf, type JumpTarget, type UseJumpOptions } from "./use-jump";
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @feat:chat
3
+ * A link's preview under its message, from `useLinkPreviews`: the site, the title and the
4
+ * description, all plain text the server fetched (Open Graph, never HTML). Tap to open: in an
5
+ * in-app browser with `modules.webBrowser` (expo-web-browser), else the system's (`Linking`).
6
+ * Only https links. The image shows only when the app turns on `linkPreviewImages`: fetching
7
+ * a page's og:image from the phone tells that site the reader's address, so by default the
8
+ * card has none and nothing is fetched. The thread screen reads that switch from `ChatProvider
9
+ * linkPreviewImages` once the provider has it (the chat owner's change); until then it is off.
10
+ */
11
+ import { Linking, Pressable, View } from "react-native";
12
+ import { NativeImage, NativeText } from "@marktiderman/genesis-ui-native/components";
13
+ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
14
+ import { extractLinks, isHttpsUrl } from "../core/extras";
15
+ import type { ChatLinkPreview, ChatMessage } from "../core/types";
16
+ import { fill } from "./copy";
17
+ import { useChatNative } from "./context";
18
+ import { ON_TINT_FILL } from "./media-colors";
19
+ import type { WebBrowserLike } from "./modules";
20
+
21
+ /** The site a link is on, without `www.`. */
22
+ export function linkHost(url: string): string {
23
+ try {
24
+ return new URL(url).hostname.replace(/^www\./, "");
25
+ } catch {
26
+ return url;
27
+ }
28
+ }
29
+
30
+ /** The preview a bubble shows: the first one the server fetched for a link in its text, https only. */
31
+ export function previewToShow(previews: readonly ChatLinkPreview[] | undefined): ChatLinkPreview | null {
32
+ return previews?.find((p) => p.status === "ok" && isHttpsUrl(p.url)) ?? null;
33
+ }
34
+
35
+ /** The messages to ask previews for: shown (not deleted, not hidden by moderation), with a link. */
36
+ export function linkPreviewIds(messages: readonly ChatMessage[]): string[] {
37
+ return messages.filter((m) => !m.deletedAt && m.moderation !== "hidden" && extractLinks(m.body).length > 0).map((m) => m.id);
38
+ }
39
+
40
+ /** Opens a link from chat: https only, in the in-app browser when there is one. */
41
+ export async function openChatLink(url: string, webBrowser?: WebBrowserLike): Promise<boolean> {
42
+ if (!isHttpsUrl(url)) return false;
43
+ try {
44
+ if (webBrowser) await webBrowser.openBrowserAsync(url);
45
+ else await Linking.openURL(url);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
51
+
52
+ export interface ChatLinkPreviewCardProps {
53
+ preview: ChatLinkPreview;
54
+ /** On the tint bubble. */
55
+ mine?: boolean;
56
+ /** Show the og:image. Off by default: fetching it tells the site the reader's address. */
57
+ images?: boolean;
58
+ testID?: string;
59
+ }
60
+
61
+ export function ChatLinkPreviewCard({ preview, mine = false, images = false, testID = "chat-link-preview" }: ChatLinkPreviewCardProps) {
62
+ const { copy, modules } = useChatNative();
63
+ const colors = useThemeRoles();
64
+ if (preview.status !== "ok" || !isHttpsUrl(preview.url)) return null;
65
+ const host = linkHost(preview.url);
66
+ const site = preview.siteName?.trim() || host;
67
+ const title = preview.title?.trim() || host;
68
+ const image = images && isHttpsUrl(preview.imageUrl) ? preview.imageUrl : null;
69
+ const fg = mine ? "text-primary-foreground" : undefined;
70
+ return (
71
+ <Pressable
72
+ testID={testID}
73
+ accessibilityRole="link"
74
+ accessibilityLabel={fill(copy.linkPreview, { title, site })}
75
+ accessibilityHint={copy.openLink}
76
+ onPress={() => void openChatLink(preview.url, modules.webBrowser)}
77
+ style={{
78
+ marginTop: 2,
79
+ borderLeftWidth: 3,
80
+ borderLeftColor: mine ? colors.surface : colors.tint,
81
+ borderRadius: 10,
82
+ overflow: "hidden",
83
+ backgroundColor: mine ? ON_TINT_FILL : colors.groupedBackground,
84
+ }}
85
+ >
86
+ {image ? (
87
+ <NativeImage testID={`${testID}-image`} src={image} width={240} height={126} contentFit="cover" />
88
+ ) : null}
89
+ <View style={{ paddingHorizontal: 10, paddingVertical: 8, gap: 2 }}>
90
+ <NativeText preset="caption" numberOfLines={1} className={fg} style={mine ? { opacity: 0.8 } : { color: colors.secondaryLabel }}>
91
+ {site}
92
+ </NativeText>
93
+ <NativeText numberOfLines={2} className={fg} style={mine ? { fontWeight: "600", fontSize: 15 } : { fontWeight: "600", fontSize: 15, color: colors.label }}>
94
+ {title}
95
+ </NativeText>
96
+ {preview.description ? (
97
+ <NativeText preset="caption" numberOfLines={2} className={fg} style={mine ? { opacity: 0.85 } : { color: colors.secondaryLabel }}>
98
+ {preview.description}
99
+ </NativeText>
100
+ ) : null}
101
+ </View>
102
+ </Pressable>
103
+ );
104
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @feat:chat
3
+ * Colors over a camera feed or a video frame, the same in light and dark mode: the picture
4
+ * behind them is what it is, so the theme roles (which follow the scheme) don't apply. Genesis
5
+ * has no "on media" role yet; these are the one place chat names them.
6
+ */
7
+ export const ON_MEDIA = "#ffffff";
8
+ export const MEDIA_BACKDROP = "#000000";
9
+ export const MEDIA_FRAME = "#111111";
10
+ /** Behind controls and labels laid over a picture. */
11
+ export const MEDIA_SCRIM = "rgba(0,0,0,0.5)";
12
+ export const MEDIA_SCRIM_LIGHT = "rgba(0,0,0,0.35)";
13
+ /** A tint-bubble card's fill, over the tint. */
14
+ export const ON_TINT_FILL = "rgba(255,255,255,0.16)";
15
+ export const ON_TINT_FILL_STRONG = "rgba(255,255,255,0.2)";
@@ -0,0 +1,187 @@
1
+ /**
2
+ * @feat:chat
3
+ * Pictures and files in a message: photos as a grid of NativeImage (expo-image
4
+ * when the app gave it) over signed links from `useFileUrls`, a local preview
5
+ * while one still uploads, a tap opening the full-screen viewer (photo-viewer.tsx); voice notes
6
+ * as VoiceNotePlayer; other files as a row with their name.
7
+ */
8
+ import { Pressable, View, useWindowDimensions } from "react-native";
9
+ import { NativeIcon, NativeImage, NativeText } from "@marktiderman/genesis-ui-native/components";
10
+ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
11
+ import { formatDuration } from "../core/media";
12
+ import type { AttachmentInput, ChatAttachment } from "../core/types";
13
+ import { useChatNative } from "./context";
14
+ import { ChatVideoPlayer } from "./video-player";
15
+ import { VoiceNotePlayer } from "./voice-player";
16
+
17
+ /** An attachment as the bubble draws it: from the server, or still on the phone. */
18
+ export interface BubbleAttachment {
19
+ key: string;
20
+ kind: string;
21
+ /** What to show now: a signed URL, or the local preview. */
22
+ uri: string | null;
23
+ name: string | null;
24
+ width: number | null;
25
+ height: number | null;
26
+ durationMs: number | null;
27
+ waveform: number[] | null;
28
+ thumbhash: string | null;
29
+ /** A hosted video: who hosts it, its playback id once processed, and whether it is ready. */
30
+ provider?: string | null;
31
+ playbackId?: string | null;
32
+ status?: "pending" | "ready" | "failed" | null;
33
+ posterUri?: string | null;
34
+ /** Still on its way from this phone. */
35
+ uploading?: boolean;
36
+ }
37
+
38
+ export function fromServer(a: ChatAttachment, urls: Record<string, string>): BubbleAttachment {
39
+ return {
40
+ key: a.id,
41
+ kind: a.kind,
42
+ uri: (a.path ? urls[a.path] : null) ?? a.url ?? null,
43
+ name: a.name,
44
+ width: a.width,
45
+ height: a.height,
46
+ durationMs: a.durationMs,
47
+ waveform: a.waveform,
48
+ thumbhash: a.thumbhash,
49
+ provider: a.provider,
50
+ playbackId: a.playbackId,
51
+ status: a.status,
52
+ posterUri: a.posterPath ? (urls[a.posterPath] ?? null) : null,
53
+ };
54
+ }
55
+
56
+ export function fromPending(
57
+ clientId: string,
58
+ index: number,
59
+ file: { name: string; contentType: string; meta?: Partial<AttachmentInput> },
60
+ preview: string | undefined,
61
+ ): BubbleAttachment {
62
+ const kind = file.meta?.kind ?? (file.contentType.startsWith("image/") ? "image" : file.contentType.startsWith("audio/") ? "voice" : "file");
63
+ return {
64
+ key: `${clientId}:${index}`,
65
+ kind,
66
+ uri: preview ?? null,
67
+ name: file.name,
68
+ width: file.meta?.width ?? null,
69
+ height: file.meta?.height ?? null,
70
+ durationMs: file.meta?.durationMs ?? null,
71
+ waveform: file.meta?.waveform ?? null,
72
+ thumbhash: null,
73
+ provider: file.meta?.provider ?? null,
74
+ uploading: true,
75
+ };
76
+ }
77
+
78
+ /** An attachment a pending message already carries (a hosted video, uploaded before it was queued). */
79
+ export function fromPendingInput(clientId: string, index: number, a: AttachmentInput): BubbleAttachment {
80
+ return {
81
+ key: `${clientId}:input:${index}`,
82
+ kind: a.kind,
83
+ uri: a.url ?? null,
84
+ name: a.name ?? null,
85
+ width: a.width ?? null,
86
+ height: a.height ?? null,
87
+ durationMs: a.durationMs ?? null,
88
+ waveform: a.waveform ?? null,
89
+ thumbhash: a.thumbhash ?? null,
90
+ provider: a.provider ?? null,
91
+ status: a.status ?? null,
92
+ };
93
+ }
94
+
95
+ /** The paths a set of messages needs signed links for. */
96
+ export function attachmentPaths(attachments: readonly ChatAttachment[]): string[] {
97
+ const out: string[] = [];
98
+ for (const a of attachments) {
99
+ if (a.path && a.provider === "storage") out.push(a.path);
100
+ if (a.posterPath) out.push(a.posterPath);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ export interface AttachmentsProps {
106
+ attachments: BubbleAttachment[];
107
+ mine: boolean;
108
+ onOpenImage?: (uri: string, alt: string) => void;
109
+ testID: string;
110
+ }
111
+
112
+ export function MessageAttachments({ attachments, mine, onOpenImage, testID }: AttachmentsProps) {
113
+ const { copy, modules } = useChatNative();
114
+ const colors = useThemeRoles();
115
+ const { width: screen } = useWindowDimensions();
116
+ const images = attachments.filter((a) => a.kind === "image");
117
+ const others = attachments.filter((a) => a.kind !== "image");
118
+ const max = Math.min(260, Math.round(screen * 0.66));
119
+ return (
120
+ <View style={{ gap: 6 }}>
121
+ {images.length ? (
122
+ <View style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, maxWidth: max, justifyContent: mine ? "flex-end" : "flex-start" }}>
123
+ {images.map((a, i) => {
124
+ const single = images.length === 1;
125
+ const ratio = a.width && a.height ? a.width / a.height : 1;
126
+ const w = single ? max : Math.floor((max - 4) / 2);
127
+ const h = single ? Math.round(Math.max(120, Math.min(max * 1.3, w / ratio))) : w;
128
+ const alt = a.name ? `${copy.photo}, ${a.name}` : copy.photo;
129
+ return (
130
+ <Pressable
131
+ key={a.key}
132
+ testID={`${testID}-image-${i}`}
133
+ accessibilityRole="imagebutton"
134
+ accessibilityLabel={alt}
135
+ accessibilityHint={copy.openImage}
136
+ disabled={!a.uri || !onOpenImage}
137
+ onPress={() => a.uri && onOpenImage?.(a.uri, alt)}
138
+ style={{ borderRadius: 14, overflow: "hidden" }}
139
+ >
140
+ {a.uri ? (
141
+ <NativeImage src={a.uri} width={w} height={h} placeholder={a.thumbhash ? { thumbhash: a.thumbhash } : undefined} recyclingKey={a.key} />
142
+ ) : (
143
+ <View style={{ width: w, height: h, backgroundColor: colors.fill }} />
144
+ )}
145
+ </Pressable>
146
+ );
147
+ })}
148
+ </View>
149
+ ) : null}
150
+ {others.map((a, i) =>
151
+ a.kind === "voice" ? (
152
+ <VoiceNotePlayer key={a.key} id={a.key} uri={a.uri} durationMs={a.durationMs} waveform={a.waveform} mine={mine} testID={`${testID}-voice-${i}`} />
153
+ ) : a.kind === "video" && modules.video ? (
154
+ <ChatVideoPlayer
155
+ key={a.key}
156
+ id={a.key}
157
+ uri={a.uri}
158
+ playbackId={a.playbackId}
159
+ posterUri={a.posterUri}
160
+ provider={a.provider}
161
+ status={a.status}
162
+ durationMs={a.durationMs}
163
+ width={a.width}
164
+ height={a.height}
165
+ uploading={a.uploading}
166
+ testID={`${testID}-video-${i}`}
167
+ />
168
+ ) : (
169
+ <View
170
+ key={a.key}
171
+ testID={`${testID}-file-${i}`}
172
+ accessible
173
+ accessibilityLabel={`${a.kind === "video" ? copy.video : copy.file}, ${a.name ?? ""}${a.durationMs ? `, ${formatDuration(a.durationMs)}` : ""}`}
174
+ style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: 44 }}
175
+ >
176
+ <NativeIcon name={a.kind === "video" ? "play" : "file"} size={20} color={mine ? colors.surface : colors.secondaryLabel} />
177
+ <NativeText numberOfLines={1} className={mine ? "text-primary-foreground" : undefined} style={{ flexShrink: 1, fontSize: 15 }}>
178
+ {a.name ?? (a.kind === "video" ? copy.video : copy.file)}
179
+ </NativeText>
180
+ </View>
181
+ ),
182
+ )}
183
+ </View>
184
+ );
185
+ }
186
+
187
+ // ChatMediaViewer (one picture) is in photo-viewer.tsx, over the full-screen ChatPhotoViewer.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * @feat:chat
3
+ * The long-press sheet: a row of quick reactions and "+" for every emoji
4
+ * (the picker takes the sheet's place), then Reply, Copy, Pin or
5
+ * Unpin, and Delete (confirmed), offered per `messageActionsFor`. Each runs
6
+ * through `useMessageActions`; a refusal from the server is a toast.
7
+ */
8
+ import { useEffect, useState } from "react";
9
+ import { Alert, Pressable, View } from "react-native";
10
+ import { NativeIcon, NativeListItem, NativeSheet, NativeText, type NativeIconName } from "@marktiderman/genesis-ui-native/components";
11
+ import { useThemeRoles } from "@marktiderman/genesis-ui-native/hooks";
12
+ import { previewText, summarizeReactions } from "../core/thread";
13
+ import type { ChatMessage } from "../core/types";
14
+ import { QUICK_REACTIONS, canReactTo, messageActionsFor, type MessageActionContext, type MessageActionId } from "./actions";
15
+ import { fill } from "./copy";
16
+ import { useChatNative } from "./context";
17
+ import { ChatEmojiPicker } from "./emoji-picker";
18
+
19
+ export interface MessageActionsSheetProps {
20
+ message: ChatMessage | null;
21
+ context: MessageActionContext;
22
+ onClose: () => void;
23
+ onReact: (message: ChatMessage, emoji: string, on: boolean) => void;
24
+ onAction: (message: ChatMessage, action: MessageActionId) => void;
25
+ testID?: string;
26
+ }
27
+
28
+ const ICONS: Record<MessageActionId, NativeIconName> = { reply: "reply", copy: "copy", pin: "pin", unpin: "pin", delete: "trash" };
29
+
30
+ export function MessageActionsSheet({ message, context, onClose, onReact, onAction, testID = "chat-message-actions" }: MessageActionsSheetProps) {
31
+ const { copy } = useChatNative();
32
+ const colors = useThemeRoles();
33
+ const actions = message ? messageActionsFor(message, context) : [];
34
+ const mineReactions = new Set(message ? summarizeReactions(message, context.me).filter((r) => r.mine).map((r) => r.emoji) : []);
35
+ const labels: Record<MessageActionId, string> = { reply: copy.reply, copy: copy.copy, pin: copy.pin, unpin: copy.unpin, delete: copy.delete };
36
+ // "+" swaps the sheet's content for the full picker (iOS can't present a second sheet over this one).
37
+ const [picking, setPicking] = useState(false);
38
+ useEffect(() => {
39
+ if (!message) setPicking(false);
40
+ }, [message]);
41
+
42
+ const run = (action: MessageActionId) => {
43
+ if (!message) return;
44
+ if (action === "delete") {
45
+ Alert.alert(copy.deleteConfirm, undefined, [
46
+ { text: copy.delete, style: "destructive", onPress: () => onAction(message, "delete") },
47
+ { text: copy.cancel, style: "cancel" },
48
+ ]);
49
+ onClose();
50
+ return;
51
+ }
52
+ onClose();
53
+ onAction(message, action);
54
+ };
55
+
56
+ return (
57
+ <NativeSheet open={message !== null} onOpenChange={(open) => (open ? undefined : onClose())} testID={testID}>
58
+ {message && picking ? (
59
+ <View style={{ paddingHorizontal: 16 }}>
60
+ <ChatEmojiPicker
61
+ userId={context.me}
62
+ testID={`${testID}-picker`}
63
+ onSelect={(emoji) => {
64
+ onClose();
65
+ onReact(message, emoji, !mineReactions.has(emoji));
66
+ }}
67
+ />
68
+ </View>
69
+ ) : 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))}
73
+ </NativeText>
74
+ {canReactTo(message, context) ? (
75
+ <View accessibilityRole="toolbar" style={{ flexDirection: "row", justifyContent: "space-between" }}>
76
+ {QUICK_REACTIONS.map((emoji) => {
77
+ const on = mineReactions.has(emoji);
78
+ return (
79
+ <Pressable
80
+ key={emoji}
81
+ testID={`${testID}-react-${emoji}`}
82
+ accessibilityRole="button"
83
+ accessibilityState={{ selected: on }}
84
+ accessibilityLabel={fill(copy.react, { emoji })}
85
+ onPress={() => {
86
+ onClose();
87
+ onReact(message, emoji, !on);
88
+ }}
89
+ style={{ width: 48, height: 48, borderRadius: 24, alignItems: "center", justifyContent: "center", backgroundColor: on ? colors.fill : undefined }}
90
+ >
91
+ <NativeText style={{ fontSize: 26, lineHeight: 34 }}>{emoji}</NativeText>
92
+ </Pressable>
93
+ );
94
+ })}
95
+ <Pressable
96
+ testID={`${testID}-react-more`}
97
+ accessibilityRole="button"
98
+ accessibilityLabel={copy.moreReactions}
99
+ onPress={() => setPicking(true)}
100
+ style={{ width: 48, height: 48, borderRadius: 24, alignItems: "center", justifyContent: "center", backgroundColor: colors.fill }}
101
+ >
102
+ <NativeIcon name="plus" size={22} color={colors.secondaryLabel} />
103
+ </Pressable>
104
+ </View>
105
+ ) : null}
106
+ <View>
107
+ {actions.map((action) => (
108
+ <NativeListItem
109
+ key={action}
110
+ testID={`${testID}-${action}`}
111
+ title={labels[action]}
112
+ leading={<NativeIcon name={ICONS[action]} size={20} color={action === "delete" ? colors.destructive : colors.label} />}
113
+ onPress={() => run(action)}
114
+ />
115
+ ))}
116
+ </View>
117
+ </View>
118
+ ) : null}
119
+ </NativeSheet>
120
+ );
121
+ }