@robono/messaging-react-native 0.2.0 → 0.3.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.
Files changed (85) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +84 -7
  3. package/SBOM.spdx.json +1290 -234
  4. package/dist/account.d.ts +15 -0
  5. package/dist/account.js +27 -0
  6. package/dist/call-message.js +7 -1
  7. package/dist/calling.js +5 -9
  8. package/dist/conversation-details.d.ts +24 -0
  9. package/dist/conversation-details.js +83 -0
  10. package/dist/emoji-text.d.ts +2 -0
  11. package/dist/emoji-text.js +8 -0
  12. package/dist/expo-history.d.ts +3 -0
  13. package/dist/expo-history.js +59 -0
  14. package/dist/expo-media.d.ts +1 -0
  15. package/dist/expo-media.js +46 -43
  16. package/dist/expo-messenger.d.ts +35 -0
  17. package/dist/expo-messenger.js +39 -0
  18. package/dist/expo-notifications.d.ts +31 -0
  19. package/dist/expo-notifications.js +92 -0
  20. package/dist/expo-outbox.js +2 -1
  21. package/dist/expo-privacy.d.ts +4 -0
  22. package/dist/expo-privacy.js +7 -0
  23. package/dist/expo-screen.d.ts +42 -0
  24. package/dist/expo-screen.js +557 -0
  25. package/dist/expo.d.ts +2 -42
  26. package/dist/expo.js +2 -549
  27. package/dist/extras-lifecycle.d.ts +2 -0
  28. package/dist/extras-lifecycle.js +27 -0
  29. package/dist/extras.d.ts +49 -0
  30. package/dist/extras.js +49 -0
  31. package/dist/forward.js +4 -3
  32. package/dist/group-editor.d.ts +18 -0
  33. package/dist/group-editor.js +98 -0
  34. package/dist/header-calls.d.ts +18 -0
  35. package/dist/header-calls.js +45 -0
  36. package/dist/icons.d.ts +2 -0
  37. package/dist/icons.js +2 -0
  38. package/dist/inbox-search.d.ts +11 -0
  39. package/dist/inbox-search.js +42 -0
  40. package/dist/inbox.d.ts +5 -2
  41. package/dist/inbox.js +19 -15
  42. package/dist/index.d.ts +33 -3
  43. package/dist/index.js +359 -141
  44. package/dist/media-controller.d.ts +3 -0
  45. package/dist/media-controller.js +39 -0
  46. package/dist/media-ui.d.ts +14 -3
  47. package/dist/media-ui.js +90 -28
  48. package/dist/message-menu.d.ts +28 -0
  49. package/dist/message-menu.js +24 -0
  50. package/dist/message-order.d.ts +3 -0
  51. package/dist/message-order.js +10 -0
  52. package/dist/messenger-video-view.d.ts +8 -0
  53. package/dist/messenger-video-view.js +46 -0
  54. package/dist/native-outbox.d.ts +8 -2
  55. package/dist/native-outbox.js +3 -1
  56. package/dist/photo-gesture.d.ts +20 -0
  57. package/dist/photo-gesture.js +52 -0
  58. package/dist/polls.d.ts +2 -1
  59. package/dist/polls.js +19 -17
  60. package/dist/popup.d.ts +43 -1
  61. package/dist/popup.js +72 -6
  62. package/dist/quick-send.d.ts +15 -0
  63. package/dist/quick-send.js +26 -0
  64. package/dist/safety.d.ts +9 -1
  65. package/dist/safety.js +21 -6
  66. package/dist/saved.d.ts +1 -1
  67. package/dist/saved.js +9 -4
  68. package/dist/search.d.ts +2 -2
  69. package/dist/search.js +32 -14
  70. package/dist/social.d.ts +9 -3
  71. package/dist/social.js +11 -15
  72. package/dist/translation-settings.d.ts +12 -0
  73. package/dist/translation-settings.js +16 -0
  74. package/dist/use-quote-gesture.d.ts +5 -1
  75. package/dist/use-quote-gesture.js +35 -17
  76. package/dist/visual/WeatherArtwork.d.ts +9 -0
  77. package/dist/visual/WeatherArtwork.js +43 -0
  78. package/dist/visual/weather-artwork.d.ts +17 -0
  79. package/dist/visual/weather-artwork.js +86 -0
  80. package/dist/visual/weather-conditions.d.ts +5 -0
  81. package/dist/visual/weather-conditions.js +2 -0
  82. package/dist/voice-composer.js +2 -2
  83. package/dist/voice-waveform.d.ts +2 -0
  84. package/dist/voice-waveform.js +21 -0
  85. package/package.json +24 -4
package/dist/index.js CHANGED
@@ -1,6 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { orderChatMessages } from './message-order.js';
3
- import { MessagingPopup } from './popup.js';
2
+ import { MessagingGroupEditor } from './group-editor.js';
3
+ import { ConversationDetails } from './conversation-details.js';
4
+ import { MessagingAccountScreen } from './account.js';
5
+ import { MessagingHeaderCalls } from './header-calls.js';
6
+ import { retainExtrasLifecycle } from './extras-lifecycle.js';
7
+ import { isBigEmojiText } from './emoji-text.js';
8
+ import { MessageMenu, MessageMenuRow, messageMenuPosition } from './message-menu.js';
9
+ import { TranslationSettings } from './translation-settings.js';
10
+ import { QuickSendButton } from './quick-send.js';
11
+ import { MessageExtra, MessagingPrivacyLayer, privacyReadingStripHeight, openChatAddress, ExtraPopup } from './extras.js';
12
+ export { MessagingPrivacyLayer } from './extras.js';
13
+ import { orderChatMessages, continuesAttachmentSend } from './message-order.js';
14
+ import { MessagingPopup, MessagingPopupHost, MessagingPopupHeaderBoundary, messagingPopupColors } from './popup.js';
4
15
  import { messageLinks } from './message-links.js';
5
16
  import { MessagingForward } from './forward.js';
6
17
  import { SavedMessagesPopup } from './saved.js';
@@ -13,14 +24,16 @@ import { PollMessage, PollComposer } from "./polls.js";
13
24
  export { PollMessage, PollComposer } from "./polls.js";
14
25
  import { createContext, useContext, useEffect, useRef, useState, useSyncExternalStore, } from "react";
15
26
  import { Animated, ActivityIndicator, AppState, BackHandler, FlatList, Image, KeyboardAvoidingView, Keyboard, Linking, Modal, Platform, Pressable, ScrollView, Text, TextInput, View, useWindowDimensions, } from "react-native";
16
- import { useQuoteGesture } from "./use-quote-gesture.js";
17
- import { MessageIcon, duration, fileSize, chatSurface } from "./icons.js";
27
+ import { useQuoteGesture, messageInfoRevealWidth, messageReplyRevealWidth } from "./use-quote-gesture.js";
28
+ import { MessageIcon, duration, fileSize, chatSurface, composerSurface } from "./icons.js";
18
29
  import { VoiceMessageComposer } from "./voice-composer.js";
19
30
  export { VoiceMessageComposer, VoiceDraftPlayback } from "./voice-composer.js";
20
31
  import { VoicePlayback } from "./media-ui.js";
21
32
  export { VoicePlayback, MediaViewer, MediaPlaybackControls, ZoomablePhoto } from "./media-ui.js";
22
- import { defaultMessagingSettings, defaultAttachmentLimits, messageReactions, savedMessageKey, } from "@robono/messaging";
33
+ import { findChatAddress, socialShare, needsMessageTranslation, supportedLanguageLabel, defaultMessagingSettings, defaultAttachmentLimits, messageReactions, savedMessageKey, } from "@robono/messaging";
23
34
  const Context = createContext(null);
35
+ const noExtras = () => null;
36
+ const noSubscribe = () => () => { };
24
37
  export function useMessagingUI() {
25
38
  const context = useContext(Context);
26
39
  if (!context)
@@ -29,6 +42,22 @@ export function useMessagingUI() {
29
42
  }
30
43
  export function MessagingProvider({ children, ...props }) {
31
44
  const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
45
+ const extrasState = useSyncExternalStore(props.extras?.subscribe ?? noSubscribe, props.extras?.getSnapshot ?? noExtras, props.extras?.getSnapshot ?? noExtras);
46
+ const messageIds = state.messages.filter(m => !m.deleted).map(m => m.id).join(',');
47
+ useEffect(() => { if (props.active === false || state.offline || state.settings?.enabled === false)
48
+ props.extras?.pause();
49
+ else if (state.settings?.enabled)
50
+ void props.extras?.open(state.conversationId, messageIds ? messageIds.split(',') : []); }, [props.extras, props.active, state.settings?.enabled, state.conversationId, messageIds, state.offline]);
51
+ useEffect(() => {
52
+ const extras = props.extras;
53
+ if (!extras)
54
+ return;
55
+ let active = props.active !== false && !state.offline;
56
+ const refresh = () => { if (active && (AppState.currentState === null || AppState.currentState === 'active'))
57
+ void extras.refresh(); };
58
+ const timer = setInterval(refresh, 5000), release = retainExtrasLifecycle(extras);
59
+ return () => { active = false; clearInterval(timer); release(); };
60
+ }, [props.extras, props.active, state.offline]);
32
61
  useEffect(() => {
33
62
  const update = (value) => props.client.setPresenceActive(props.active !== false && (value === null || value === 'active'));
34
63
  update(AppState.currentState);
@@ -75,6 +104,8 @@ export function MessagingProvider({ children, ...props }) {
75
104
  setSelection([]);
76
105
  } }; document.addEventListener('keydown', escape); return () => document.removeEventListener('keydown', escape); }, [selection.length]);
77
106
  const [editing, setEditing] = useState(null);
107
+ const [discussion, setDiscussion] = useState(null);
108
+ useEffect(() => setDiscussion(null), [state.conversationId, state.discussionAvailable, state.offline]);
78
109
  const [reply, setReply] = useState(null);
79
110
  useEffect(() => { setEditing(null); }, [state.conversationId, state.settings?.enabled, state.messageActionsAvailable, state.conversation?.canSend]);
80
111
  useEffect(() => {
@@ -95,9 +126,11 @@ export function MessagingProvider({ children, ...props }) {
95
126
  setDeleting([m]); }, [props.requestedMessageAction]);
96
127
  const value = {
97
128
  ...props,
129
+ openDiscussion: (threadRoot) => setDiscussion({ threadRoot }),
98
130
  state,
131
+ extrasState: extrasState?.conversationId === state.conversationId && state.settings?.enabled && !state.offline ? extrasState : null,
99
132
  reply: reply?.conversationId === state.conversationId
100
- ? state.messages.find(m => m.id === reply.id && !m.deleted) ?? null
133
+ ? (state.messages.find(m => m.id === reply.id && !m.deleted) ? { ...state.messages.find(m => m.id === reply.id), ...(reply.threadRoot ? { threadRoot: reply.threadRoot } : {}) } : null)
101
134
  : null,
102
135
  setReply,
103
136
  editing: state.messageActionsAvailable && editing?.conversationId === state.conversationId && state.settings?.enabled && state.conversation?.canSend !== false && state.messages.some(m => m.id === editing.id && !m.deleted) ? editing : null,
@@ -105,14 +138,82 @@ export function MessagingProvider({ children, ...props }) {
105
138
  design: props.appearance ?? state.settings?.appearance ??
106
139
  defaultMessagingSettings().appearance,
107
140
  };
108
- return _jsxs(Context.Provider, { value: value, children: [children, forwarding.length > 0 && _jsx(MessagingForward, { messages: forwarding, client: props.client, appearance: value.design, participants: props.participants, userId: props.userId, onClose: () => setForwarding([]) }), _jsx(MessageDeletion, { messages: deleting, onClose: () => setDeleting([]) }), savedOpen && _jsx(SavedMessagesPopup, { records: saved, appearance: value.design, onClose: () => setSavedOpen(false), onOpen: async (record) => { setSavedOpen(false); try {
109
- if (!props.openSavedAttachment)
110
- throw Error('Saved attachments cannot be opened here.');
111
- await props.openSavedAttachment(record);
141
+ return _jsx(Context.Provider, { value: value, children: _jsxs(MessagingPopupHost, { children: [children, discussion && _jsx(GroupDiscussion, { threadRoot: discussion.threadRoot, onClose: () => setDiscussion(null) }), forwarding.length > 0 && _jsx(MessagingForward, { messages: forwarding, client: props.client, appearance: value.design, participants: props.participants, userId: props.userId, onClose: () => setForwarding([]) }), _jsx(MessageDeletion, { messages: deleting, onClose: () => setDeleting([]) }), savedOpen && _jsx(SavedMessagesPopup, { records: saved, appearance: value.design, onClose: () => setSavedOpen(false), onOpen: async (record) => { setSavedOpen(false); try {
142
+ if (!props.openSavedAttachment)
143
+ throw Error('Saved attachments cannot be opened here.');
144
+ await props.openSavedAttachment(record);
145
+ }
146
+ catch (e) {
147
+ setSavedError(e instanceof Error ? e.message : 'This saved attachment could not be opened.');
148
+ } }, onRemove: async (key) => { await props.client.removeSavedMessage(key); setSaved(await props.client.savedMessages()); } }), !!savedError && _jsxs(MessagingPopup, { compact: true, contentWidth: 640, title: "Saved", foreground: value.design.colors.foreground, background: value.design.colors.background, onClose: () => setSavedError(''), children: [_jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground }, children: savedError }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => setSavedError(''), style: { minHeight: 44, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: value.design.colors.accent }, children: "OK" }) })] })] }) });
149
+ }
150
+ function GroupDiscussion({ threadRoot, onClose }) {
151
+ const context = useMessagingUI(), { client, state, design } = context;
152
+ const [messages, setMessages] = useState([]), [root, setRoot] = useState(), [next, setNext] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState(''), [visible, setVisible] = useState(true);
153
+ const loaded = useRef([]), loading = useRef(false), refreshPage = useRef(() => { });
154
+ const version = useRef(0), request = useRef(undefined), pending = useRef(null);
155
+ const dismiss = () => { const action = pending.current; pending.current = null; onClose(); action?.(); };
156
+ useEffect(() => { if (!visible && Platform.OS !== 'ios')
157
+ dismiss(); }, [visible]);
158
+ async function load(more = false, quiet = false) {
159
+ if (quiet && loading.current)
160
+ return;
161
+ loading.current = true;
162
+ request.current?.abort();
163
+ const control = new AbortController();
164
+ request.current = control;
165
+ const generation = ++version.current;
166
+ if (!quiet)
167
+ setBusy(true);
168
+ setError('');
169
+ try {
170
+ const page = await client.discussion({ ...threadRoot ? { threadRoot } : { pinnedOnly: true }, ...(more && next ? { before: next } : {}) }, control.signal);
171
+ if (generation !== version.current)
172
+ return;
173
+ const previous = loaded.current;
174
+ let rows = page.messages;
175
+ if (quiet) {
176
+ const newest = new Set(rows.map(m => m.id)), older = previous.filter(m => !newest.has(m.id)).map(m => m.id);
177
+ for (let i = 0; i < older.length; i += 100) {
178
+ const checked = await client.discussion({ ...threadRoot ? { threadRoot } : { pinnedOnly: true }, messageIds: older.slice(i, i + 100) }, control.signal);
179
+ if (generation !== version.current)
180
+ return;
181
+ rows = [...rows, ...checked.messages];
112
182
  }
113
- catch (e) {
114
- setSavedError(e instanceof Error ? e.message : 'This saved attachment could not be opened.');
115
- } }, onRemove: async (key) => { await props.client.removeSavedMessage(key); setSaved(await props.client.savedMessages()); } }), !!savedError && _jsxs(MessagingPopup, { background: value.design.colors.background, onClose: () => setSavedError(''), children: [_jsx(Text, { accessibilityRole: "alert", style: { color: value.design.colors.foreground }, children: savedError }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => setSavedError(''), style: { minHeight: 44, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: value.design.colors.accent }, children: "OK" }) })] })] });
183
+ }
184
+ if (generation !== version.current)
185
+ return;
186
+ loaded.current = [...new Map([...(more ? previous : []), ...rows].map(m => [m.id, m])).values()].sort((a, b) => BigInt(a.id) < BigInt(b.id) ? -1 : 1);
187
+ setMessages(loaded.current);
188
+ setRoot(page.threadMessage);
189
+ if (!quiet || previous.length <= page.messages.length)
190
+ setNext(page.hasMore ? page.nextCursor : null);
191
+ }
192
+ catch (e) {
193
+ if (generation === version.current && !control.signal.aborted) {
194
+ setMessages([]);
195
+ setRoot(undefined);
196
+ setError(e instanceof Error ? e.message : 'These messages could not load.');
197
+ }
198
+ }
199
+ finally {
200
+ if (generation === version.current) {
201
+ loading.current = false;
202
+ setBusy(false);
203
+ }
204
+ }
205
+ }
206
+ refreshPage.current = () => { void load(false, true); };
207
+ const changes = state.messages.map(m => [m.id, m.revision, m.deleted, m.replyCount, m.pinned].join(':')).join(',');
208
+ useEffect(() => { const timer = setTimeout(() => refreshPage.current(), 200); return () => clearTimeout(timer); }, [changes]);
209
+ useEffect(() => { const timer = setInterval(() => { if (AppState.currentState === null || AppState.currentState === 'active')
210
+ refreshPage.current(); }, 10000); return () => clearInterval(timer); }, []);
211
+ useEffect(() => { void load(); return () => { ++version.current; request.current?.abort(); }; }, [client, state.conversationId, threadRoot]);
212
+ const open = (message, reply = false) => { pending.current = () => { void client.focusMessage(message.id).then(() => { if (reply && client.getSnapshot().conversationId === message.conversationId)
213
+ context.setReply({ ...message, threadRoot: message.id }); }).catch(() => { }); }; setVisible(false); };
214
+ const rows = root ? [root, ...messages.filter(m => m.id !== root.id)] : messages;
215
+ return _jsxs(MessagingPopup, { visible: visible, onDismiss: dismiss, title: threadRoot ? 'Replies' : 'Pinned messages', onClose: () => setVisible(false), background: messagingPopupColors.background, children: [_jsx(Context.Provider, { value: { ...context, discussionPreview: true, discussionRoot: threadRoot, state: { ...state, messages: rows }, setReply: message => { if (message)
216
+ open(root ?? message, true); }, openAttachment: context.openAttachment ? async (message) => { pending.current = () => { void context.openAttachment(message).catch(() => { }); }; setVisible(false); } : undefined }, children: _jsx(FlatList, { data: rows, keyExtractor: m => m.id, style: { flex: 1 }, contentContainerStyle: { paddingVertical: 12, gap: 10 }, ListHeaderComponent: next ? _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => void load(true), style: { minHeight: 44, justifyContent: 'center', alignItems: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: "Earlier replies" }) }) : null, renderItem: ({ item, index }) => _jsxs(View, { children: [root && index === 0 && _jsx(Text, { style: { color: messagingPopupColors.secondary, marginBottom: 8 }, children: "Original message" }), _jsx(MessagingBubble, { message: item }), !threadRoot && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Go to pinned message", onPress: () => open(item), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: "Go to message" }) })] }), ListEmptyComponent: !busy && !error ? _jsx(Text, { style: { color: messagingPopupColors.secondary, textAlign: 'center', padding: 20 }, children: "No pinned messages." }) : null }) }), busy && _jsx(ActivityIndicator, { color: design.colors.accent }), !!error && _jsxs(View, { children: [_jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void load(), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: "Try again" }) })] }), root && state.conversation?.canSend !== false && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Reply in conversation", onPress: () => open(root, true), style: { minHeight: 48, alignItems: 'center', justifyContent: 'center', borderTopWidth: 1, borderTopColor: '#ffffff20' }, children: _jsx(Text, { style: { color: design.colors.accent, fontSize: 16, fontWeight: '600' }, children: "Reply" }) })] });
116
217
  }
117
218
  export function messagingHeaderColors(design, group) {
118
219
  const background = group ? '#1F5F7A' : design.colors.accent;
@@ -127,33 +228,43 @@ function MessagingActivity({ ink }) {
127
228
  const typing = state.settings?.features.typing ? state.presence?.peers.filter(peer => peer.typingUntil > now) ?? [] : [];
128
229
  const online = state.settings?.features.online && state.presence?.peers.some(peer => peer.onlineUntil > now);
129
230
  const group = state.conversation?.kind === 'group';
130
- const text = typing.length ? (group ? (typing.length > 2 ? 'Several people are typing…' : typing.map(peer => participants?.[peer.userId]?.name ?? 'Someone').join(' and ') + ' ' + (typing.length > 1 ? 'are' : 'is') + ' typing…') : 'Typing…') : !group && online ? 'Online now' : group ? `${state.conversation?.memberCount ?? state.conversation?.memberIds?.length ?? 0} members` : design.name;
131
- return _jsx(Text, { numberOfLines: 1, style: { color: ink, opacity: .8, fontSize: 12 }, children: text });
231
+ const text = typing.length ? (group ? (typing.length > 2 ? 'Several people are typing…' : typing.map(peer => participants?.[peer.userId]?.name ?? 'Someone').join(' and ') + ' ' + (typing.length > 1 ? 'are' : 'is') + ' typing…') : 'Typing…') : !group && online ? 'Online now' : group ? `${state.conversation?.memberCount ?? state.conversation?.memberIds?.length ?? 0} members` : '';
232
+ return text ? _jsx(Text, { numberOfLines: 1, style: { color: ink, opacity: .8, fontSize: 14 }, children: text }) : null;
132
233
  }
133
234
  export function MessagingHeader({ children } = {}) {
134
- const { contact, design, state, client, userId, participants, onBack, onContactOptions, selection, setSelection, setSavedOpen } = useMessagingUI();
135
- const [safetyOpen, setSafetyOpen] = useState(false), [searchOpen, setSearchOpen] = useState(false);
235
+ const { contact, design, state, client, openDiscussion, userId, participants, onBack, onContactOptions, selection, setSelection, language, components, callActions, attachmentPreview, openAttachment } = useMessagingUI();
236
+ const [safetyOpen, setSafetyOpen] = useState(false), [searchOpen, setSearchOpen] = useState(false), [detailsOpen, setDetailsOpen] = useState(false), [groupOpen, setGroupOpen] = useState(false);
237
+ useEffect(() => { setDetailsOpen(false); setGroupOpen(false); }, [state.conversationId]);
238
+ const menuButton = useRef(null), viewport = useWindowDimensions(), [menuAnchor, setMenuAnchor] = useState({ x: 12, y: 12 });
239
+ const openContactMenu = () => menuButton.current?.measureInWindow((x, y, width, height) => { setMenuAnchor({ x: Math.max(12, Math.min(x + width - 240, viewport.width - 252)), y: y + height + 4 }); setSafetyOpen(true); });
240
+ const translationAvailable = !!state.settings?.enabled && !!state.settings.features.translation && client.hasLanguageTools();
136
241
  const c = design.colors, header = messagingHeaderColors(design, state.conversation?.kind === 'group');
137
- return (_jsxs(View, { style: {
138
- flexDirection: "row",
139
- alignItems: "center",
140
- paddingHorizontal: 8,
141
- paddingVertical: 6,
142
- minHeight: 58,
143
- backgroundColor: header.background,
144
- gap: 8,
145
- borderBottomWidth: 1,
146
- borderBottomColor: c.foreground + "12",
147
- }, children: [(onBack || selection.length > 0) && (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: selection.length ? 'Clear message selection' : 'Back', onPress: () => selection.length ? setSelection([]) : onBack?.(), style: { minWidth: 44, minHeight: 44, justifyContent: "center" }, children: _jsx(MessageIcon, { name: "back", color: header.foreground, size: 25 }) })), design.showAvatars && (contact.avatarUrl
148
- ? (_jsx(Image, { source: { uri: contact.avatarUrl }, style: { width: 36, height: 36, borderRadius: 18 } }))
149
- : (_jsx(View, { style: {
150
- width: 36,
151
- height: 36,
152
- borderRadius: 18,
153
- backgroundColor: c.incoming,
154
- alignItems: "center",
155
- justifyContent: "center",
156
- }, children: _jsx(Text, { style: { color: c.incomingText }, children: contact.name.slice(0, 1) }) }))), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: header.foreground, fontSize: 17, fontWeight: "600" }, children: state.conversation?.kind === 'group' ? state.conversation.name : contact.name }), _jsx(MessagingActivity, { ink: header.foreground })] }), design.logoUrl && (_jsx(Image, { source: { uri: design.logoUrl }, accessibilityLabel: design.name, style: { width: 32, height: 32 } })), children, client.hasSavedMessages() && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Saved messages", onPress: () => setSavedOpen(true), style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "bookmark", color: header.foreground, size: 22 }) }), (onContactOptions || state.conversation?.safety || state.searchAvailable) && (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Contact options", onPress: onContactOptions ?? (() => setSafetyOpen(true)), style: { padding: 12 }, children: _jsx(MessageIcon, { name: "more", color: header.foreground, size: 22 }) })), searchOpen && state.searchAvailable && state.settings?.enabled && _jsx(MessagingSearch, { client: client, appearance: design, onClose: () => setSearchOpen(false), resolveName: id => id === userId ? 'You' : participants?.[id]?.name ?? (state.conversation?.kind === 'direct' ? contact.name : 'Chat member') }, state.conversationId), safetyOpen && _jsx(MessagingSafety, { client: client, state: state, appearance: design, name: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Conversation') : contact.name, onSearch: state.searchAvailable && state.settings?.enabled ? () => { setSafetyOpen(false); setSearchOpen(true); } : undefined, onClose: () => setSafetyOpen(false) })] }));
242
+ return (_jsxs(_Fragment, { children: [_jsxs(View, { testID: "messaging-chat-header", style: {
243
+ flexDirection: "row",
244
+ alignItems: "center",
245
+ paddingHorizontal: 12,
246
+ paddingVertical: 4,
247
+ backgroundColor: header.background,
248
+ gap: 4,
249
+ }, children: [(onBack || selection.length > 0) && (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: selection.length ? 'Clear message selection' : 'Back', onPress: () => selection.length ? setSelection([]) : onBack?.(), style: { width: 44, flexShrink: 0, alignSelf: "stretch", marginStart: -12, marginEnd: -4, marginVertical: -4, alignItems: "center", justifyContent: "center" }, children: _jsx(MessageIcon, { name: "back", color: header.foreground, size: 32 }) })), _jsxs(View, { testID: "messaging-chat-identity", style: { flex: 1, minWidth: 0, flexDirection: "row", alignItems: "center", gap: 8 }, children: [design.showAvatars && (contact.avatarUrl
250
+ ? (_jsx(Image, { source: { uri: contact.avatarUrl }, style: { width: 38, height: 38, borderRadius: 19, borderWidth: 1, borderColor: "#ffffff" } }))
251
+ : (_jsx(View, { style: {
252
+ width: 38,
253
+ height: 38,
254
+ borderRadius: 19,
255
+ borderWidth: 1,
256
+ borderColor: "#ffffff",
257
+ backgroundColor: c.incoming,
258
+ alignItems: "center",
259
+ justifyContent: "center",
260
+ }, children: _jsx(Text, { style: { color: c.incomingText }, children: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Group').slice(0, 1) : contact.name.slice(0, 1) }) }))), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: header.foreground, fontSize: 18, fontWeight: "800" }, children: state.conversation?.kind === 'group' ? state.conversation.name : contact.name }), _jsx(MessagingActivity, { ink: header.foreground })] })] }), design.logoUrl && (_jsx(Image, { source: { uri: design.logoUrl }, accessibilityLabel: design.name, style: { width: 32, height: 32 } })), children, state.conversation?.canSend !== false && _jsx(MessagingHeaderCalls, { actions: callActions, name: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Group') : contact.name, group: state.conversation?.kind === 'group', color: header.foreground }, state.conversationId), _jsx(PrivacyControl, { color: header.foreground }), (onContactOptions || state.conversation?.safety || state.searchAvailable || translationAvailable) && (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Contact options", ref: menuButton, accessibilityState: { expanded: safetyOpen }, onPress: onContactOptions ?? openContactMenu, style: { width: 42, height: 42, alignItems: "center", justifyContent: "center" }, children: _jsx(MessageIcon, { name: "more", color: header.foreground, size: 25 }) })), safetyOpen && _jsx(MessagingSafety, { onDetails: state.settings?.enabled && (state.sharedMediaAvailable || state.conversation?.kind === 'group') ? () => { setSafetyOpen(false); setDetailsOpen(true); } : undefined, onPins: state.discussionAvailable ? () => { setSafetyOpen(false); openDiscussion(); } : undefined, menuAnchor: menuAnchor, client: client, state: state, appearance: design, name: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Conversation') : contact.name, onSearch: state.searchAvailable && state.settings?.enabled ? () => { setSafetyOpen(false); setSearchOpen(true); } : undefined, onClose: () => setSafetyOpen(false), children: translationAvailable && _jsx(TranslationSettings, { client: client, appearance: design, preference: state.translationPreference, language: language, sourceLanguage: contact.language, voiceAvailable: !!components?.TranslationVoice, onSaved: () => setSafetyOpen(false) }, state.conversationId) })] }), groupOpen && _jsx(MessagingGroupEditor, { client: client, userId: userId, contacts: participants ?? {}, conversation: state.conversation, groups: state.settings?.groups, accent: c.accent, onClose: () => setGroupOpen(false), onSaved: () => setGroupOpen(false) }), detailsOpen && _jsx(ConversationDetails, { onEditGroup: () => setGroupOpen(true), onLeft: onBack, client: client, state: state, userId: userId, contact: contact, participants: participants, accent: c.accent, onClose: () => setDetailsOpen(false), attachmentPreview: attachmentPreview, openAttachment: openAttachment }), searchOpen && state.searchAvailable && state.settings?.enabled && _jsx(MessagingSearch, { client: client, appearance: design, onClose: () => setSearchOpen(false), resolveName: id => id === userId ? 'You' : participants?.[id]?.name ?? (state.conversation?.kind === 'direct' ? contact.name : 'Chat member') }, state.conversationId)] }));
261
+ }
262
+ function PrivacyControl({ color }) {
263
+ const { extras, extrasState, design } = useMessagingUI();
264
+ const [error, setError] = useState(''), [busy, setBusy] = useState(false);
265
+ if (!extras || !extrasState || !extrasState.available.privacyLens && !extrasState.privacyLens)
266
+ return null;
267
+ return _jsxs(_Fragment, { children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: extrasState.privacyLens ? 'Turn off Privacy Lens' : 'Turn on Privacy Lens', disabled: busy, onPress: () => { setBusy(true); void extras.setPrivacy(!extrasState.privacyLens).catch(() => setError('Privacy Lens could not change. Try again.')).finally(() => setBusy(false)); }, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: extrasState.privacyLens ? "eye-off" : "eye", color: color, size: 23 }) }), !!error && _jsx(ExtraPopup, { title: "Privacy Lens", background: design.colors.background, foreground: design.colors.foreground, onClose: () => setError(''), children: _jsx(Text, { style: { color: design.colors.foreground }, children: error }) })] });
157
268
  }
158
269
  const mediaNames = {
159
270
  voice: "Voice message",
@@ -179,8 +290,17 @@ function QuotedPreview({ message, ink }) {
179
290
  return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 7, minWidth: 0 }, children: [media && (thumbnail ? _jsx(Image, { accessibilityLabel: "Quoted attachment thumbnail", source: { uri: thumbnail }, onError: () => setThumbnail(undefined), resizeMode: "cover", style: { width: 28, height: 28, borderRadius: 3 } }) : _jsx(MessageIcon, { name: message.kind === 'voice' ? 'mic' : message.kind, color: design.colors.accent, size: 24 })), _jsxs(Text, { numberOfLines: 2, style: { flexShrink: 1, color: ink, fontSize: 12, lineHeight: 15, opacity: .8 }, children: [name && _jsxs(Text, { style: { fontWeight: '600' }, children: [name, ": "] }), message?.deleted ? 'Message deleted' : quoteSummary(message), message?.kind === 'voice' && message.media?.durationMs ? ` · ${duration(message.media.durationMs)}` : ''] })] });
180
291
  }
181
292
  /** Thumbnails stay local to the view and are discarded on conversation/message changes. */
293
+ const heardVoiceMessages = new WeakMap();
182
294
  function MediaContent({ message, ink, footer, onOpen, onLongPress }) {
183
- const { attachmentPreview, components, design } = useMessagingUI();
295
+ const { attachmentPreview, components, design, userId, client } = useMessagingUI();
296
+ const [, updateHeard] = useState(0), voiceKey = message.conversationId + ':' + message.id + ':' + message.media?.assetId;
297
+ const heard = heardVoiceMessages.get(client)?.has(voiceKey) ?? false;
298
+ const onPlayed = () => { let rows = heardVoiceMessages.get(client); if (!rows) {
299
+ rows = new Set();
300
+ heardVoiceMessages.set(client, rows);
301
+ } if (rows.has(voiceKey))
302
+ return; rows.add(voiceKey); if (rows.size > 2048)
303
+ rows.delete(rows.values().next().value); updateHeard(n => n + 1); };
184
304
  const [preview, setPreview] = useState({});
185
305
  useEffect(() => {
186
306
  let current = true;
@@ -192,25 +312,27 @@ function MediaContent({ message, ink, footer, onOpen, onLongPress }) {
192
312
  }, [message.id, message.conversationId, message.media?.assetId, attachmentPreview]);
193
313
  if (message.kind === 'voice') {
194
314
  const Voice = components?.VoiceMessage;
195
- return Voice ? _jsx(Voice, { message: message, ink: ink, accent: design.colors.accent, waveform: preview.waveform ?? message.media?.waveform, showWaveform: design.showWaveform !== false, footer: footer, onOpen: onOpen, onLongPress: onLongPress }) : _jsx(VoicePlayback, { ink: ink, accent: design.colors.accent, waveform: preview.waveform ?? message.media?.waveform, showWaveform: design.showWaveform !== false, footer: footer, durationMs: message.media?.durationMs, onToggle: onOpen, onLongPress: onLongPress });
315
+ return Voice ? _jsx(Voice, { mine: message.senderId === userId, heard: heard, onPlayed: onPlayed, background: message.senderId === userId ? design.colors.outgoing : undefined, message: message, ink: ink, accent: design.colors.accent, waveform: preview.waveform ?? message.media?.waveform, showWaveform: design.showWaveform !== false, footer: footer, onOpen: onOpen, onLongPress: onLongPress }) : _jsx(VoicePlayback, { mine: message.senderId === userId, heard: heard, onPlayed: onPlayed, background: message.senderId === userId ? design.colors.outgoing : undefined, ink: ink, accent: design.colors.accent, waveform: preview.waveform ?? message.media?.waveform, showWaveform: design.showWaveform !== false, durationMs: message.media?.durationMs, footer: footer, onToggle: onOpen, onLongPress: onLongPress });
196
316
  }
197
317
  if (message.kind === 'image' || message.kind === 'video')
198
- return _jsx(View, { style: { width: 260, maxWidth: '100%', gap: 0 }, children: _jsxs(View, { style: { height: 186, borderRadius: 6, overflow: 'hidden', alignItems: 'center', justifyContent: 'center', backgroundColor: '#00000012' }, children: [preview.uri ? _jsx(Image, { source: { uri: preview.uri }, accessibilityLabel: message.media?.fileName ?? mediaNames[message.kind], resizeMode: "cover", onError: () => setPreview(value => ({ ...value, uri: undefined })), style: { width: '100%', height: '100%' } }) : _jsx(MessageIcon, { name: message.kind, color: ink, size: 36 }), message.kind === 'video' && _jsxs(_Fragment, { children: [_jsx(View, { style: { position: 'absolute', borderRadius: 24, padding: 10, backgroundColor: '#00000099' }, children: _jsx(MessageIcon, { name: "play", color: "#ffffff" }) }), _jsx(Text, { style: { position: 'absolute', left: 8, bottom: 8, color: '#ffffff', backgroundColor: '#000000aa', borderRadius: 4, paddingHorizontal: 5, fontSize: 12 }, children: duration(message.media?.durationMs) })] }), _jsx(View, { style: { position: 'absolute', right: 6, bottom: 6, paddingHorizontal: 6, paddingVertical: 3, borderRadius: 9, backgroundColor: '#00000070' }, children: footer })] }) });
199
- return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 12, width: 246, minHeight: 62, padding: 10, borderRadius: 6, backgroundColor: ink + '09' }, children: [_jsx(MessageIcon, { name: "document", color: design.colors.accent, size: 30 }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { numberOfLines: 2, style: { color: ink, fontSize: 14, fontWeight: '600' }, children: message.media?.fileName ?? 'File' }), _jsxs(Text, { style: { color: ink, fontSize: 12, opacity: 0.75 }, children: [message.media?.fileName?.split('.').pop()?.toUpperCase(), " \u00B7 ", fileSize(message.media?.byteSize)] })] })] });
318
+ return _jsx(View, { style: { width: 220, maxWidth: '100%', gap: 0 }, children: _jsxs(View, { style: { height: 155, borderRadius: 10, overflow: 'hidden', alignItems: 'center', justifyContent: 'center', backgroundColor: '#00000012' }, children: [preview.uri ? _jsx(Image, { source: { uri: preview.uri }, accessibilityLabel: message.media?.fileName ?? mediaNames[message.kind], resizeMode: "cover", onError: () => setPreview(value => ({ ...value, uri: undefined })), style: { width: '100%', height: '100%' } }) : _jsx(MessageIcon, { name: message.kind, color: ink, size: 36 }), message.kind === 'video' && _jsxs(_Fragment, { children: [_jsx(View, { style: { position: 'absolute', borderRadius: 24, padding: 10, backgroundColor: '#00000099' }, children: _jsx(MessageIcon, { name: "play", color: "#ffffff" }) }), _jsx(Text, { style: { position: 'absolute', left: 8, bottom: 8, color: '#ffffff', backgroundColor: '#000000aa', borderRadius: 4, paddingHorizontal: 5, fontSize: 12 }, children: duration(message.media?.durationMs) })] })] }) });
319
+ return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 12, width: 220, minHeight: 56, padding: 8, borderRadius: 6, backgroundColor: ink + '09' }, children: [_jsx(MessageIcon, { name: "document", color: design.colors.accent, size: 30 }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { numberOfLines: 2, style: { color: ink, fontSize: 14, fontWeight: '600' }, children: message.media?.fileName ?? 'File' }), _jsxs(Text, { style: { color: ink, fontSize: 12, opacity: 0.75 }, children: [message.media?.fileName?.split('.').pop()?.toUpperCase(), " \u00B7 ", fileSize(message.media?.byteSize)] })] })] });
200
320
  }
201
321
  function messageTimeLabel(message) { return (message.editedAt && !message.deleted ? 'edited · ' : '') + new Date(message.createdAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); }
202
322
  function MessageMetadata({ message, mine, ink, pendingStatus }) {
203
- const { state, design } = useMessagingUI();
204
- const read = !!message.readAt && state.settings?.features.readReceipts;
205
- return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-end' }, children: [_jsx(Text, { style: { color: ink, fontSize: 11, lineHeight: 14, opacity: 0.64, fontVariant: ['tabular-nums'] }, children: messageTimeLabel(message) }), mine && _jsx(View, { accessibilityLabel: pendingStatus === 'sending' ? 'Sending message' : pendingStatus === 'failed' ? 'Message not sent' : read ? 'Read' : 'Sent', children: _jsx(MessageIcon, { name: pendingStatus === 'sending' ? 'clock' : pendingStatus === 'failed' ? 'warning' : read ? 'read' : 'check', color: pendingStatus === 'failed' ? '#c94040' : read ? design.colors.accent : ink, size: 15 }) })] });
323
+ return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-end' }, children: [_jsx(Text, { style: { color: ink, fontSize: 11, lineHeight: 14, opacity: 0.64, fontVariant: ['tabular-nums'] }, children: messageTimeLabel(message) }), mine && pendingStatus && _jsx(View, { accessibilityLabel: pendingStatus === 'sending' ? 'Sending message' : 'Message not sent', children: _jsx(MessageIcon, { name: pendingStatus === 'sending' ? 'clock' : 'warning', color: pendingStatus === 'failed' ? '#c94040' : ink, size: 15 }) })] });
206
324
  }
207
- export function MessagingBubble({ message, pendingStatus }) {
208
- const { userId, design, state, client, participants, setReply, setEditing, setForwarding, setDeleting, setSelection, saved, saving, toggleSaved, onMessageOptions, openAttachment, language, contact, copyText, components } = useMessagingUI();
325
+ export function MessagingBubble({ message, pendingStatus, previousMessage, nextMessage }) {
326
+ const { userId, design, state, client, participants, openDiscussion, discussionPreview, discussionRoot, setReply, setEditing, setForwarding, setDeleting, selection, setSelection, saved, saving, toggleSaved, onMessageOptions, openAttachment, language, contact, copyText, components, extras, extrasState, onCallHistory } = useMessagingUI();
327
+ const extra = !pendingStatus && !message.deleted ? extrasState?.messages[message.id] : undefined;
328
+ const extraRef = useRef(null), socialRef = useRef(null);
209
329
  const socialOrigin = useMessagingUI().socialOrigin ?? 'https://robono.com';
330
+ const socialReady = !extra?.address && !pendingStatus && !message.deleted && message.kind === 'text' && state.settings?.enabled && state.socialPreviewAvailable && components?.SocialPlayer && messageLinks(message.text ?? '').some(link => !!link.url && !!socialShare(link.url, socialOrigin));
210
331
  const contextContactName = contact.name;
211
- const mine = message.senderId === userId, c = design.colors, ink = mine ? c.outgoingText : c.incomingText;
332
+ const mine = message.senderId === userId, c = design.colors, ink = message.kind === 'poll' ? c.foreground : mine ? c.outgoingText : c.incomingText;
212
333
  const visual = message.kind === 'image' || message.kind === 'video', voice = message.kind === 'voice';
213
- const previous = state.messages[state.messages.findIndex(m => m.id === message.id) - 1];
334
+ const previous = previousMessage ?? state.messages[state.messages.findIndex(m => m.id === message.id) - 1];
335
+ const joinsBefore = continuesAttachmentSend(previousMessage, message), joinsAfter = continuesAttachmentSend(message, nextMessage);
214
336
  const grouped = previous?.senderId === message.senderId && new Date(previous.createdAt).toDateString() === new Date(message.createdAt).toDateString();
215
337
  const [error, setError] = useState(''), [busy, setBusy] = useState(false), [actions, setActions] = useState(false), [reportOpen, setReportOpen] = useState(false);
216
338
  const afterMenu = useRef(null);
@@ -218,31 +340,37 @@ export function MessagingBubble({ message, pendingStatus }) {
218
340
  const leaveMenu = (next) => { afterMenu.current = next; setActions(false); if (Platform.OS !== 'ios')
219
341
  menuDismissed(); };
220
342
  useEffect(() => () => { afterMenu.current = null; }, [client, message.id]);
221
- const canChange = mine && !pendingStatus && !message.deleted && !!state.messageActionsAvailable && !!state.settings?.enabled && state.conversation?.canSend !== false && !message.readAt;
343
+ const canChange = !extra?.weather && !extra?.call && mine && !pendingStatus && !message.deleted && !!state.messageActionsAvailable && !!state.settings?.enabled && state.conversation?.canSend !== false && !message.readAt;
222
344
  const anchor = useRef(null), viewport = useWindowDimensions();
223
345
  const [menuPosition, setMenuPosition] = useState({ x: 12, y: 12, above: false, available: 400 }), [hovered, setHovered] = useState(false);
224
346
  const [footerWidth, setFooterWidth] = useState(0);
347
+ const [addressBubbleHeight, setAddressBubbleHeight] = useState(44);
225
348
  const openMenu = () => anchor.current?.measureInWindow((x, y, width, height) => {
226
- const center = Math.max(16, Math.min(y + height / 2, viewport.height - 16)), above = center > viewport.height * .52;
227
- setMenuPosition({ x: Math.max(8, Math.min(x, viewport.width - 312)), y: center, above, available: Math.max(60, (above ? center : viewport.height - center) - 16) });
349
+ setMenuPosition(messageMenuPosition(x, y, width, height, viewport.width, viewport.height, mine));
228
350
  setActions(true);
229
351
  });
230
- const [transformed, setTransformed] = useState(null), [transforming, setTransforming] = useState(false), [translationChoices, setTranslationChoices] = useState(false);
352
+ const [transformed, setTransformed] = useState(null), [transforming, setTransforming] = useState(false), [showOriginal, setShowOriginal] = useState(false), [translationAutoPlay, setTranslationAutoPlay] = useState(false);
231
353
  const TranslationVoice = components?.TranslationVoice;
232
354
  const canTranscript = !pendingStatus && voice && !!state.settings?.features.transcription && client.hasLanguageTools() && !message.deleted;
233
- const canTranslate = !pendingStatus && ['text', 'voice'].includes(message.kind) && !!state.settings?.features.translation && client.hasLanguageTools() && !message.deleted;
355
+ const translation = state.translationPreference;
356
+ const translationLanguage = translation?.language ?? language;
357
+ const sourceLanguage = participants?.[message.senderId]?.language ?? (state.conversation?.kind === 'direct' ? contact.language : undefined) ?? translation?.sourceLanguage;
358
+ const canTranslate = !mine && !extra?.weather && !extra?.call && !pendingStatus && ['text', 'voice'].includes(message.kind) && !!state.settings?.enabled && !!state.settings.features.translation && client.hasLanguageTools() && !message.deleted && translation?.mode !== undefined && translation.mode !== 'off' && needsMessageTranslation(sourceLanguage, translationLanguage) && (translation.mode !== 'voice' || !!TranslationVoice);
234
359
  const transformGeneration = useRef(0);
235
- useEffect(() => { transformGeneration.current++; setTransformed(null); setTransforming(false); setTranslationChoices(false); return () => { transformGeneration.current++; }; }, [message.id, message.deleted, message.text, message.media?.assetId, language, state.settings?.enabled, state.settings?.features.transcription, state.settings?.features.translation]);
360
+ useEffect(() => { transformGeneration.current++; setTransformed(null); setTransforming(false); setShowOriginal(false); setTranslationAutoPlay(false); return () => { transformGeneration.current++; }; }, [message.id, message.deleted, message.text, message.media?.assetId, translationLanguage, translation?.mode, sourceLanguage, state.settings?.enabled, state.settings?.features.transcription, state.settings?.features.translation]);
236
361
  useEffect(() => { if (!transformed?.audio)
237
362
  return; const timer = setTimeout(() => setTransformed(null), Math.max(0, Date.parse(transformed.audio.expiresAt) - Date.now())); return () => clearTimeout(timer); }, [transformed]);
238
- async function transform(kind, output = 'text') { setTranslationChoices(false); if (transformed?.kind === kind && !!transformed.audio === (output === 'voice')) {
239
- setTransformed(null);
363
+ async function transform(kind, output = 'text') { setShowOriginal(false); setTranslationAutoPlay(false); if (transformed?.kind === kind && !!transformed.audio === (output === 'voice')) {
364
+ setShowOriginal(false);
240
365
  return;
241
366
  } if (transforming)
242
367
  return; const generation = ++transformGeneration.current; setTransforming(true); setActions(false); setError(''); try {
243
- const result = await client.transform(message.id, kind, language ?? Intl.DateTimeFormat().resolvedOptions().locale, output);
244
- if (generation === transformGeneration.current)
368
+ const result = await client.transform(message.id, kind, translationLanguage ?? Intl.DateTimeFormat().resolvedOptions().locale, output);
369
+ if (generation === transformGeneration.current) {
245
370
  setTransformed(result);
371
+ setShowOriginal(false);
372
+ setTranslationAutoPlay(output === 'voice');
373
+ }
246
374
  }
247
375
  catch {
248
376
  if (generation === transformGeneration.current)
@@ -254,7 +382,7 @@ export function MessagingBubble({ message, pendingStatus }) {
254
382
  } }
255
383
  const canInteract = !pendingStatus && !!state.settings?.enabled && state.conversation?.canSend !== false && !message.deleted;
256
384
  const quoting = !!state.settings?.features.replies && canInteract;
257
- const quoteGesture = useQuoteGesture(quoting, mine, () => setReply(message));
385
+ const quoteGesture = useQuoteGesture(!pendingStatus && !selection.length, mine, quoting ? () => setReply(message) : undefined, extra ? () => extraRef.current?.open() : undefined);
258
386
  async function open() { if (pendingStatus || quoteGesture.suppressPress())
259
387
  return; if (!openAttachment) {
260
388
  setError('This attachment cannot be opened.');
@@ -288,13 +416,27 @@ export function MessagingBubble({ message, pendingStatus }) {
288
416
  setBusy(false);
289
417
  }
290
418
  }
291
- const renderText = (text) => messageLinks(text).map((part, index) => part.url ? _jsx(Text, { accessibilityRole: "link", onPress: event => { event.stopPropagation(); void Linking.openURL(part.url).catch(() => setError('This link could not be opened.')); }, style: { textDecorationLine: 'underline' }, children: part.text }, index) : part.text);
419
+ const renderLinks = (text) => messageLinks(text).map((part, index) => part.url ? _jsx(Text, { accessibilityRole: "link", onLongPress: menu, onPress: event => { event.stopPropagation(); void Linking.openURL(part.url).catch(() => setError('This link could not be opened.')); }, style: { textDecorationLine: 'underline' }, children: part.text }, index) : part.text);
420
+ const renderText = (text) => { const address = findChatAddress(text); return address ? _jsxs(_Fragment, { children: [renderLinks(text.slice(0, address.start)), _jsx(Text, { accessibilityRole: "link", onLongPress: menu, onPress: event => { event.stopPropagation(); void openChatAddress(address.text).catch(() => setError('Maps could not be opened.')); }, style: { color: c.accent, textDecorationLine: 'underline' }, children: address.text }), renderLinks(text.slice(address.end))] }) : renderLinks(text); };
421
+ const extraCard = extra && extras ? _jsx(MessageExtra, { ref: extraRef, addressBubbleHeight: addressBubbleHeight, extra: extra, messageId: message.id, userId: userId, extras: extras, ink: ink, accent: c.accent, background: c.background, foreground: c.foreground, Map: components?.TrackingMap, onLongPress: menu, onCall: onCallHistory, footer: _jsx(MessageMetadata, { message: message, mine: mine, ink: ink }), renderAddress: (action, people) => _jsxs(_Fragment, { children: [_jsxs(Pressable, { onLayout: event => { const h = event.nativeEvent.layout.height; setAddressBubbleHeight(old => Math.abs(old - h) < .5 ? old : h); }, testID: "address-message-bubble", accessibilityRole: "button", accessibilityLabel: "Show live tracking map", hitSlop: { top: 6, bottom: 6, left: 4, right: 4 }, onLongPress: menu, onPress: event => { event.stopPropagation(); if (!quoteGesture.suppressPress())
422
+ extraRef.current?.open(); }, style: { flexShrink: 1, minWidth: 0, minHeight: 44, justifyContent: 'center', position: 'relative', backgroundColor: mine ? c.outgoing : c.incoming, borderRadius: 8, paddingHorizontal: 6, paddingVertical: 3 }, children: [bubbleBody, people] }), action] }) }) : null;
423
+ const socialCard = socialReady && components?.SocialPlayer ? _jsx(SocialMessagePreview, { ref: socialRef, onLongPress: menu, message: message, client: client, Player: components.SocialPlayer, origin: socialOrigin, accent: c.accent, foreground: c.foreground, background: c.background, renderMessage: action => _jsxs(_Fragment, { children: [_jsx(View, { testID: "social-message-bubble", style: { flexShrink: 1, minWidth: 0, minHeight: 44, justifyContent: 'center', position: 'relative', backgroundColor: mine ? c.outgoing : c.incoming, borderRadius: 8, paddingHorizontal: 6, paddingVertical: 3 }, children: bubbleBody }), action] }) }) : null;
292
424
  const canCopy = !!message.text && (!!copyText || Platform.OS === 'web');
293
- const reactionPicker = canInteract && state.settings?.features.reactions && _jsx(View, { accessibilityLabel: "Choose a reaction", style: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 2 }, children: messageReactions.map(emoji => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: `React ${emoji}`, disabled: busy, onPress: () => void react(emoji), style: { minWidth: 42, minHeight: 42, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontSize: 28 }, children: emoji }) }, emoji)) });
425
+ const reactionPicker = !mine && canInteract && state.settings?.features.reactions && _jsx(View, { testID: "message-reaction-picker", accessibilityLabel: "Choose a reaction", style: { height: 50, borderRadius: 25, backgroundColor: '#171717', borderWidth: .5, borderColor: '#ffffff24', justifyContent: 'center', boxShadow: '0 6px 14px #0006' }, children: _jsx(ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, keyboardShouldPersistTaps: "handled", contentContainerStyle: { alignItems: 'center', paddingHorizontal: 8, gap: 4 }, children: messageReactions.map(emoji => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: `React ${emoji}`, accessibilityState: { selected: !!message.reactions?.some(r => r.emoji === emoji && r.userIds.includes(userId)) }, disabled: busy, onPress: () => void react(emoji), style: { width: 40, height: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', backgroundColor: message.reactions?.some(r => r.emoji === emoji && r.userIds.includes(userId)) ? '#f0843433' : 'transparent' }, children: _jsx(Text, { style: { fontSize: 23, lineHeight: 28 }, children: emoji }) }, emoji)) }) });
294
426
  const footer = _jsx(MessageMetadata, { message: message, mine: mine, ink: visual ? '#ffffff' : ink, pendingStatus: pendingStatus });
295
- const content = message.kind === 'poll' ? _jsx(PollMessage, { message: message, userId: userId, ink: ink, accent: c.accent, background: c.background, foreground: c.foreground, resolveVoterName: id => id === userId ? 'You' : participants?.[id]?.name ?? (state.conversation?.kind === 'direct' ? contextContactName : 'Chat member'), enabled: canInteract && !!state.settings?.features.poll, onVote: options => client.vote(message.id, options), onClose: () => client.closePoll(message.id) }) : message.kind === 'text' ? _jsxs(View, { testID: "message-original-text", style: { position: 'relative', paddingBottom: 2 }, children: [_jsxs(Text, { selectable: true, style: { color: ink, fontSize: 16, lineHeight: 22 }, children: [renderText(message.text ?? ''), _jsx(View, { pointerEvents: "none", accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : {}), style: { width: (footerWidth || (messageTimeLabel(message).length * 6.5 + (mine ? 19 : 0)) * (viewport.fontScale || 1)) + 6, height: 1 } })] }), _jsx(View, { pointerEvents: "none", testID: "message-timestamp", onLayout: e => { const width = e.nativeEvent.layout.width; setFooterWidth(old => Math.abs(old - width) < .5 ? old : width); }, style: { position: 'absolute', right: 0, bottom: 0 }, children: footer })] }) : _jsx(MediaContent, { message: message, ink: ink, accent: c.accent, footer: footer, onOpen: () => void open(), onLongPress: menu });
296
- return _jsxs(View, { ref: anchor, ...(Platform.OS === 'web' ? { onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onContextMenu: (event) => { event.preventDefault(); menu(); } } : {}), style: { alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '88%', marginTop: grouped ? 2 : 7, marginBottom: 0 }, children: [quoting && _jsx(Animated.View, { pointerEvents: "none", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : { accessible: false, accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' }), testID: `quote-reveal-${mine ? 'outgoing' : 'incoming'}`, style: { ...quoteGesture.revealWebStyle, position: 'absolute', top: '50%', marginTop: -14, ...(mine ? { right: 12 } : { left: 12 }), width: 28, height: 28, alignItems: 'center', justifyContent: 'center', opacity: quoteGesture.revealOpacity }, children: _jsx(MessageIcon, { name: "quote", color: c.secondary, size: 23 }) }), _jsxs(Animated.View, { ref: quoteGesture.webRef, ...quoteGesture.handlers, accessibilityActions: quoting ? [{ name: "quote", label: "Quote message" }] : undefined, onAccessibilityAction: event => { if (quoting && event.nativeEvent.actionName === "quote")
297
- setReply(message); }, testID: `message-${message.kind}-${mine ? 'outgoing' : 'incoming'}`, style: { ...quoteGesture.webStyle, transform: [{ translateX: quoteGesture.offset }], ...(Platform.OS === "web" && quoting ? { touchAction: "pan-y", userSelect: "none" } : {}), backgroundColor: mine ? c.outgoing : c.incoming, borderRadius: 8, borderTopRightRadius: mine && !grouped ? 0 : 8, borderTopLeftRadius: !mine && !grouped ? 0 : 8, padding: visual ? 3 : 6, boxShadow: '0 1px 1px #00000010' }, children: [!grouped && _jsx(View, { pointerEvents: "none", style: { position: 'absolute', top: 0, ...(mine ? { right: -6, borderRightWidth: 6 } : { left: -6, borderLeftWidth: 6 }), borderRightColor: 'transparent', borderLeftColor: 'transparent', borderTopWidth: 7, borderTopColor: mine ? c.outgoing : c.incoming } }), message.replyTo && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Go to quoted message", onPress: () => { void client.focusMessage(message.replyTo).catch(e => setError(e instanceof Error ? e.message : 'This message is unavailable.')); }, style: { borderLeftWidth: 3, borderLeftColor: c.accent, paddingLeft: 9, paddingVertical: 5, marginBottom: 7 }, children: _jsx(QuotedPreview, { message: state.messages.find(m => m.id === message.replyTo) ?? state.quotedMessages?.find(m => m.id === message.replyTo), ink: ink }) }), !mine && !grouped && state.conversation?.kind === 'group' && _jsx(Text, { style: { color: ink, fontSize: 12, fontWeight: '600', marginBottom: 5 }, children: message.deleted ? 'Former member' : participants?.[message.senderId]?.name ?? 'Member' }), voice || message.kind === 'poll' ? content : _jsx(Pressable, { accessibilityRole: message.kind === 'text' ? undefined : 'button', accessibilityLabel: message.kind === 'text' ? undefined : `${mediaNames[message.kind]}: ${message.media?.fileName ?? 'Open attachment'}`, disabled: busy, onLongPress: menu, onPress: message.kind === 'text' ? undefined : () => void open(), children: content }), !pendingStatus && state.settings?.enabled && state.socialPreviewAvailable && components?.SocialPlayer && _jsx(SocialMessagePreview, { message: message, client: client, Player: components.SocialPlayer, origin: socialOrigin, accent: c.accent, foreground: c.foreground, background: c.background }), message.kind !== 'text' && !!message.text && _jsx(Text, { selectable: true, style: { color: ink, fontSize: 16, lineHeight: 22, marginTop: 4 }, children: renderText(message.text) }), message.kind !== 'text' && !voice && !visual && _jsx(View, { style: { marginTop: 4, alignSelf: 'flex-end' }, children: footer }), transformed && !transformed.audio && _jsx(Text, { testID: "message-language-result", style: { color: ink, fontSize: 13, lineHeight: 18, opacity: .8, marginTop: 4 }, children: transformed.text }), transformed?.audio && TranslationVoice && _jsxs(View, { testID: "message-translated-voice", style: { marginTop: 4 }, children: [_jsx(TranslationVoice, { transform: transformed, ink: ink, accent: c.accent, showWaveform: design.showWaveform !== false }), _jsx(Text, { style: { color: ink, opacity: .6, fontSize: 10 }, children: "Translated voice" })] }), translationChoices && _jsxs(MessagingPopup, { background: chatSurface(c.background), onClose: () => setTranslationChoices(false), children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 }, children: [_jsx(Text, { accessibilityRole: "header", style: { flex: 1, color: c.foreground, fontSize: 16, fontWeight: '600' }, children: "Translate" }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close translation options", onPress: () => setTranslationChoices(false), style: { minHeight: 44, minWidth: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", color: c.foreground }) })] }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void transform('translation', 'text'), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 16 }, children: "Translate to text" }) }), TranslationVoice && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void transform('translation', 'voice'), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 16 }, children: "Translate to voice" }) })] }), (canTranscript || canTranslate) && _jsxs(View, { style: { flexDirection: 'row', gap: 12, marginTop: 2 }, children: [canTranscript && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Read voice message", accessibilityState: { expanded: transformed?.kind === 'transcript' }, disabled: transforming, onPress: () => void transform('transcript'), style: { minHeight: 28, justifyContent: 'center' }, children: _jsx(Text, { style: { color: ink, fontSize: 11, opacity: .7 }, children: "Transcript" }) }), canTranslate && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Translate message", accessibilityState: { expanded: transformed?.kind === 'translation' }, disabled: transforming, onPress: () => setTranslationChoices(true), style: { minHeight: 28, justifyContent: 'center' }, children: _jsx(Text, { style: { color: ink, fontSize: 11, opacity: .7 }, children: "Translate" }) })] }), transforming && _jsx(Text, { accessibilityLiveRegion: "polite", style: { color: ink, fontSize: 11 }, children: "Converting\u2026" }), busy && !voice && _jsx(Text, { style: { color: ink, fontSize: 12, marginTop: 4 }, children: "Opening\u2026" }), !!error && _jsxs(MessagingPopup, { background: chatSurface(c.background), onClose: () => setError(''), children: [_jsx(Text, { accessibilityRole: "header", style: { color: c.foreground, fontSize: 17, fontWeight: '600' }, children: "Message" }), _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, fontSize: 15, lineHeight: 21, marginTop: 8 }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close message alert", onPress: () => setError(''), style: { alignSelf: 'flex-end', minHeight: 44, justifyContent: 'center', paddingHorizontal: 8, marginTop: 8 }, children: _jsx(Text, { style: { color: c.accent }, children: "OK" }) })] }), pendingStatus === 'failed' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Retry sending message", onPress: () => void client.retry(message.clientMessageId).catch(() => { }), style: { minHeight: 32, justifyContent: 'center', alignSelf: 'flex-end' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 12 }, children: design.labels.retry }) }), Platform.OS === 'web' && canInteract && state.settings?.features.reactions && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "React to message", onFocus: () => setHovered(true), onBlur: () => setHovered(false), onPress: openMenu, style: { opacity: hovered || actions ? 1 : 0, position: 'absolute', top: 6, ...(mine ? { left: -34 } : { right: -34 }), width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "reaction", color: c.secondary, size: 20 }) }), !!message.reactions?.length && _jsx(View, { style: { flexDirection: 'row', gap: 4, marginTop: 5, flexWrap: 'wrap' }, children: message.reactions.map(r => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `${r.emoji}, ${r.userIds.length} reaction${r.userIds.length === 1 ? '' : 's'}`, accessibilityState: { selected: r.userIds.includes(userId) }, disabled: busy || !canInteract || !state.settings?.features.reactions, onPress: () => void react(r.emoji), style: { flexDirection: 'row', gap: 4, minWidth: 40, minHeight: 32, paddingHorizontal: 7, alignItems: 'center', justifyContent: 'center', borderRadius: 16, backgroundColor: r.userIds.includes(userId) ? c.accent + '24' : c.background, borderWidth: 1, borderColor: r.userIds.includes(userId) ? c.accent : c.secondary + '30' }, children: [_jsx(Text, { style: { fontSize: 17 }, children: r.emoji }), _jsx(Text, { style: { color: c.foreground, fontSize: 12 }, children: r.userIds.length })] }, r.emoji)) }), _jsx(Modal, { visible: actions, onDismiss: menuDismissed, transparent: true, animationType: "fade", onRequestClose: () => setActions(false), children: _jsx(Pressable, { accessibilityLabel: "Close message options", onPress: () => setActions(false), style: { flex: 1, backgroundColor: '#00000022' }, children: _jsxs(View, { style: { position: 'absolute', left: menuPosition.x, ...(menuPosition.above ? { bottom: viewport.height - menuPosition.y + 8 } : { top: menuPosition.y + 8 }), width: Math.min(304, viewport.width - 16), maxHeight: menuPosition.available, backgroundColor: c.background, borderRadius: 18, boxShadow: '0 4px 20px #0003', overflow: 'hidden' }, children: [!menuPosition.above && reactionPicker, _jsxs(ScrollView, { style: { maxHeight: Math.max(44, menuPosition.available - (reactionPicker ? 50 : 0)) }, contentContainerStyle: { padding: 8, gap: 2 }, children: [canCopy && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); void (copyText ? copyText(message.text) : navigator.clipboard.writeText(message.text)).catch(() => setError('The text could not be copied.')); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Copy" }) }), quoting && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); setReply(message); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Quote" }) }), canInteract && client.canForward(message) && _jsx(Pressable, { accessibilityRole: "button", onPress: () => leaveMenu(() => setForwarding([message])), style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Forward" }) }), !pendingStatus && !message.deleted && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); setSelection([message]); Keyboard.dismiss(); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Select" }) }), !pendingStatus && !message.deleted && message.kind !== 'poll' && client.hasSavedMessages() && _jsx(Pressable, { accessibilityRole: "button", disabled: saving, onPress: () => { setActions(false); void toggleSaved(message); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: saved.some(r => r.key === savedMessageKey(message)) ? 'Remove from Saved' : 'Save' }) }), canChange && message.kind !== 'poll' && state.settings?.features[message.kind] && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); setEditing(message); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Edit" }) }), canChange && _jsx(Pressable, { accessibilityRole: "button", onPress: () => leaveMenu(() => setDeleting([message])), style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Delete" }) }), pendingStatus === 'failed' && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); void client.discardPending(message.clientMessageId).catch(() => setError('The unsent message could not be removed.')); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Remove unsent message" }) }), !mine && state.conversation?.safety?.canReport && _jsx(Pressable, { accessibilityRole: "button", onPress: () => leaveMenu(() => setReportOpen(true)), style: { padding: 12 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 15 }, children: "Report message" }) }), !pendingStatus && onMessageOptions && _jsx(Pressable, { accessibilityRole: "button", onPress: () => leaveMenu(() => onMessageOptions(message)), style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "More options" }) }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => setActions(false), style: { padding: 16 }, children: _jsx(Text, { style: { color: c.secondary, fontSize: 16 }, children: "Cancel" }) })] }), menuPosition.above && reactionPicker] }) }) }), reportOpen && _jsx(MessagingSafety, { client: client, state: state, appearance: design, name: participants?.[message.senderId]?.name ?? 'Contact', messageId: message.id, onClose: () => setReportOpen(false) })] })] });
427
+ const bigEmoji = message.kind === 'text' && !message.replyTo && !extra && !canTranslate && !transformed && isBigEmojiText(message.text);
428
+ const showingTransform = !!transformed && !showOriginal && (transformed.kind === 'transcript' || canTranslate);
429
+ const languageActions = (canTranscript || canTranslate || transformed) && _jsxs(View, { testID: "message-language-actions", style: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: voice && !showingTransform ? 'flex-end' : 'flex-start', columnGap: 8, rowGap: 0 }, children: [transformed && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: showOriginal ? (transformed.kind === 'transcript' ? 'Show transcript' : 'Show translation') : 'Show original', onPress: () => { setTranslationAutoPlay(false); setShowOriginal(v => !v); }, hitSlop: 8, style: { minHeight: 17, paddingVertical: 1, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 11, lineHeight: 15 }, children: showOriginal ? (transformed.kind === 'transcript' ? 'Show transcript' : 'Show translation') : 'Show original' }) }), canTranscript && !transformed && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Read voice message", disabled: transforming, onPress: () => void transform('transcript'), hitSlop: 8, style: { minHeight: 17, paddingVertical: 1, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 11, lineHeight: 15 }, children: transforming ? 'Transcribing…' : 'Transcript' }) }), canTranslate && transformed?.kind !== 'translation' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: translation.mode === 'voice' ? 'Play translation' : 'Translate message', disabled: transforming, onPress: () => void transform('translation', translation.mode === 'voice' ? 'voice' : 'text'), hitSlop: 8, style: { minHeight: 17, paddingVertical: 1, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 11, lineHeight: 15 }, children: translation.mode === 'voice' ? 'Play Translation' : 'Translate' }) })] });
430
+ const originalContent = extra?.weather || extra?.call ? extraCard : message.kind === 'poll' ? _jsx(PollMessage, { onLongPress: menu, message: message, userId: userId, ink: ink, accent: c.accent, background: c.background, foreground: c.foreground, resolveVoterName: id => id === userId ? 'You' : participants?.[id]?.name ?? (state.conversation?.kind === 'direct' ? contextContactName : 'Chat member'), enabled: canInteract && !!state.settings?.features.poll, onVote: options => client.vote(message.id, options), onClose: () => client.closePoll(message.id) }) : message.kind === 'text' ? _jsxs(View, { testID: "message-original-text", style: { position: 'relative', paddingBottom: extra?.address || socialReady ? 0 : 2 }, children: [_jsxs(Text, { selectable: true, style: { color: ink, fontSize: bigEmoji ? 48 : 16, lineHeight: bigEmoji ? 58 : 22 }, children: [renderText(message.text ?? ''), !extra?.address && !socialReady && _jsx(View, { pointerEvents: "none", accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : {}), style: { width: (footerWidth || (messageTimeLabel(message).length * 6.5 + (mine && pendingStatus ? 19 : 0)) * (viewport.fontScale || 1)) + 6, height: 1 } })] }), !extra?.address && !socialReady && _jsx(View, { pointerEvents: "none", testID: "message-timestamp", onLayout: e => { const width = e.nativeEvent.layout.width; setFooterWidth(old => Math.abs(old - width) < .5 ? old : width); }, style: { position: 'absolute', right: 0, bottom: 0 }, children: footer })] }) : _jsx(MediaContent, { message: message, ink: ink, accent: c.accent, footer: voice ? languageActions : undefined, onOpen: () => void open(), onLongPress: menu });
431
+ const content = showingTransform ? _jsxs(View, { children: [_jsx(Text, { style: { color: ink, fontSize: 11, opacity: .65, marginBottom: 2 }, children: transformed.kind === 'transcript' ? 'Transcript' : 'Translated · ' + supportedLanguageLabel(translationLanguage) }), transformed.audio && TranslationVoice ? _jsx(View, { testID: "message-translated-voice", children: _jsx(TranslationVoice, { transform: transformed, ink: ink, accent: c.accent, showWaveform: design.showWaveform !== false, autoPlay: translationAutoPlay }) }) : _jsx(Text, { testID: "message-language-result", selectable: true, style: { color: ink, fontSize: 16, lineHeight: 22 }, children: transformed.text }), !voice && footer] }) : originalContent;
432
+ const bubbleBody = _jsxs(_Fragment, { children: [!bigEmoji && (!mine || !pendingStatus) && _jsx(View, { testID: mine ? (message.readAt && state.settings?.features.readReceipts ? 'message-status-read' : 'message-status-sent') : 'message-status-incoming', pointerEvents: "none", accessible: false, style: { position: 'absolute', top: joinsBefore ? -5 : 0, bottom: joinsAfter ? -5 : 0, start: 0, end: 0, zIndex: 40, ...(mine ? { borderEndWidth: 2, borderEndColor: message.readAt && state.settings?.features.readReceipts ? c.accent : c.foreground, borderTopEndRadius: joinsBefore ? 0 : voice && !message.text && !showingTransform ? 24 : 8, borderBottomEndRadius: joinsAfter ? 0 : voice && !message.text && !showingTransform ? 24 : 8 } : { borderStartWidth: 2, borderStartColor: '#2B2B2B', borderTopStartRadius: joinsBefore ? 0 : voice && !message.text && !showingTransform ? 24 : 8, borderBottomStartRadius: joinsAfter ? 0 : voice && !message.text && !showingTransform ? 24 : 8 }) } }), message.replyTo && message.replyTo !== discussionRoot && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Go to quoted message", onLongPress: menu, onPress: () => { void client.focusMessage(message.replyTo).catch(e => setError(e instanceof Error ? e.message : 'This message is unavailable.')); }, style: { borderLeftWidth: 3, borderLeftColor: c.accent, paddingLeft: 9, paddingVertical: 5, marginBottom: 7 }, children: _jsx(QuotedPreview, { message: state.messages.find(m => m.id === message.replyTo) ?? state.quotedMessages?.find(m => m.id === message.replyTo), ink: ink }) }), !mine && !grouped && state.conversation?.kind === 'group' && _jsx(Text, { style: { color: ink, fontSize: 12, fontWeight: '600', marginBottom: 5 }, children: message.deleted ? 'Former member' : participants?.[message.senderId]?.name ?? 'Member' }), extra?.address || extra?.weather || extra?.call || voice || message.kind === 'poll' ? content : _jsx(Pressable, { accessibilityRole: message.kind === 'text' ? undefined : 'button', accessibilityLabel: message.kind === 'text' ? undefined : `${mediaNames[message.kind]}: ${message.media?.fileName ?? 'Open attachment'}`, disabled: busy, onLongPress: menu, onPress: message.kind === 'text' ? () => { if (quoteGesture.suppressPress())
433
+ return; if (extra?.address)
434
+ extraRef.current?.open();
435
+ else
436
+ socialRef.current?.open(); } : () => void open(), children: content }), !discussionPreview && state.discussionAvailable && !!message.replyCount && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: message.replyCount + ' ' + (message.replyCount === 1 ? 'reply' : 'replies'), onPress: () => openDiscussion(message.id), style: { minHeight: 32, justifyContent: 'center' }, children: _jsxs(Text, { style: { color: c.accent, fontSize: 12, fontWeight: '600' }, children: [message.replyCount, " ", message.replyCount === 1 ? 'reply' : 'replies'] }) }), message.kind !== 'text' && !!message.text && _jsx(Text, { selectable: true, onLongPress: menu, style: { color: ink, fontSize: 16, lineHeight: 22, marginTop: 4 }, children: renderText(message.text) }), message.kind !== 'text' && message.kind !== 'poll' && !voice && !visual && _jsx(View, { style: { marginTop: 4, alignSelf: 'flex-end' }, children: footer }), (!voice || showingTransform) && languageActions, transforming && !voice && _jsx(Text, { accessibilityLiveRegion: "polite", style: { color: ink, fontSize: 11 }, children: "Converting\u2026" }), busy && !voice && _jsx(Text, { style: { color: ink, fontSize: 12, marginTop: 4 }, children: "Opening\u2026" }), !!error && _jsxs(MessagingPopup, { compact: true, contentWidth: 640, title: "Message", foreground: c.foreground, background: chatSurface(c.background), onClose: () => setError(''), children: [_jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground, fontSize: 15, lineHeight: 21, marginTop: 8 }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close message alert", onPress: () => setError(''), style: { alignSelf: 'flex-end', minHeight: 44, justifyContent: 'center', paddingHorizontal: 8, marginTop: 8 }, children: _jsx(Text, { style: { color: c.accent }, children: "OK" }) })] }), pendingStatus === 'failed' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Retry sending message", onPress: () => void client.retry(message.clientMessageId).catch(() => { }), style: { minHeight: 32, justifyContent: 'center', alignSelf: 'flex-end' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 12 }, children: design.labels.retry }) }), Platform.OS === 'web' && !mine && canInteract && state.settings?.features.reactions && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "React to message", onFocus: () => setHovered(true), onBlur: () => setHovered(false), onPress: openMenu, style: { opacity: hovered || actions ? 1 : 0, position: 'absolute', top: 6, ...(mine ? { left: -34 } : { right: -34 }), width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "reaction", color: c.secondary, size: 20 }) }), !!message.reactions?.length && _jsx(View, { testID: "message-reaction-badge", pointerEvents: "none", accessibilityLabel: message.reactions.slice(-3).map(r => r.emoji).join(' '), style: { position: 'absolute', zIndex: 41, top: -16, ...(mine ? { end: -9 } : { start: -9 }), minWidth: 30, height: 20, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontSize: 16, lineHeight: 20 }, children: message.reactions.slice(-3).map(r => r.emoji).join(' ') }) }), _jsxs(MessageMenu, { visible: actions, position: menuPosition, screenHeight: viewport.height, mine: mine, reactions: reactionPicker, onClose: () => setActions(false), onDismiss: menuDismissed, children: [quoting && _jsx(MessageMenuRow, { icon: "quote", label: "Quote message", onPress: () => { setActions(false); setReply(message); } }), !discussionPreview && state.discussionAvailable && !pendingStatus && !message.deleted && _jsx(MessageMenuRow, { icon: "quote", label: "Replies", onPress: () => leaveMenu(() => openDiscussion(message.threadRoot ?? message.id)) }), state.discussionAvailable && canInteract && state.conversation?.ownerId === userId && _jsx(MessageMenuRow, { icon: "bookmark", label: message.pinned ? 'Unpin for group' : 'Pin for group', onPress: () => { setActions(false); void client.pinMessage(message.id, !message.pinned).catch(e => setError(e instanceof Error ? e.message : 'The pin could not change.')); } }), canCopy && _jsx(MessageMenuRow, { icon: "copy", label: "Copy", onPress: () => { setActions(false); void (copyText ? copyText(message.text) : navigator.clipboard.writeText(message.text)).catch(() => setError('The text could not be copied.')); } }), canInteract && client.canForward(message) && _jsx(MessageMenuRow, { icon: "redo", label: "Forward", onPress: () => leaveMenu(() => setForwarding([message])) }), !pendingStatus && !message.deleted && message.kind !== 'poll' && client.hasSavedMessages() && _jsx(MessageMenuRow, { icon: "bookmark", label: saved.some(r => r.key === savedMessageKey(message)) ? 'Remove from Saved' : 'Save', disabled: saving, onPress: () => { setActions(false); void toggleSaved(message); } }), canChange && message.kind !== 'poll' && state.settings?.features[message.kind] && _jsx(MessageMenuRow, { icon: "edit", label: "Edit", onPress: () => { setActions(false); setEditing(message); } }), !mine && state.conversation?.safety?.canReport && _jsx(MessageMenuRow, { icon: "flag", label: "Report", danger: true, onPress: () => leaveMenu(() => setReportOpen(true)) }), canChange && _jsx(MessageMenuRow, { icon: "trash", label: "Delete", danger: true, onPress: () => leaveMenu(() => setDeleting([message])) }), pendingStatus === 'failed' && _jsx(MessageMenuRow, { icon: "trash", label: "Remove unsent message", danger: true, onPress: () => { setActions(false); void client.discardPending(message.clientMessageId).catch(() => setError('The unsent message could not be removed.')); } }), !pendingStatus && onMessageOptions && _jsx(MessageMenuRow, { icon: "more", label: "More options", onPress: () => leaveMenu(() => onMessageOptions(message)) }), !pendingStatus && !message.deleted && _jsx(MessageMenuRow, { icon: "check", label: "Select Multiple", divider: true, onPress: () => { setActions(false); setSelection([message]); Keyboard.dismiss(); } })] }), reportOpen && _jsx(MessagingSafety, { client: client, state: state, appearance: design, name: participants?.[message.senderId]?.name ?? 'Contact', messageId: message.id, onClose: () => setReportOpen(false) })] });
437
+ return _jsxs(View, { ...(Platform.OS === 'web' ? { onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onContextMenu: (event) => { event.preventDefault(); menu(); } } : {}), style: { width: '100%', position: 'relative', marginTop: message.reactions?.length ? 24 : 8, marginBottom: 0 }, children: [quoteGesture.infoLoaded && _jsxs(Animated.View, { pointerEvents: "none", testID: "message-info-reveal", accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", style: { ...quoteGesture.revealWebStyle, position: 'absolute', top: '50%', marginTop: -26, minHeight: 52, width: messageInfoRevealWidth, justifyContent: 'center', ...(mine ? { start: 0, alignItems: 'flex-start' } : { end: 0, alignItems: 'flex-end' }), opacity: quoteGesture.infoOpacity }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'baseline', gap: 4 }, children: [_jsx(Text, { style: { color: c.secondary, fontSize: 8, lineHeight: 11, fontWeight: '900' }, children: "SENT:" }), _jsx(Text, { style: { color: c.foreground, fontSize: 10, lineHeight: 12, fontWeight: '800' }, children: messageTimeLabel(message) })] }), mine && state.settings?.features.readReceipts && _jsxs(View, { style: { flexDirection: 'row', alignItems: 'baseline', gap: 4 }, children: [_jsx(Text, { style: { color: c.secondary, fontSize: 8, lineHeight: 11, fontWeight: '900' }, children: "READ:" }), _jsx(Text, { style: { color: message.readAt ? c.foreground : c.secondary, fontSize: 10, lineHeight: 12, fontWeight: '800' }, children: message.readAt ? new Date(message.readAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) : 'Not yet' })] })] }), quoting && _jsxs(Animated.View, { pointerEvents: "none", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : { accessible: false, accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' }), testID: `quote-reveal-${mine ? 'outgoing' : 'incoming'}`, style: { ...quoteGesture.revealWebStyle, position: 'absolute', top: '50%', marginTop: -26, ...(mine ? { end: 0 } : { start: 0 }), width: messageReplyRevealWidth, minHeight: 52, gap: 3, alignItems: 'center', justifyContent: 'center', opacity: quoteGesture.revealOpacity }, children: [_jsx(View, { style: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: chatSurface(c.background), borderWidth: 1.5, borderColor: c.secondary + '40' }, children: _jsx(MessageIcon, { name: "quote", color: c.accent, size: 30 }) }), _jsx(Text, { style: { color: c.secondary, fontSize: 10, fontWeight: '900' }, children: "QUOTE MESSAGE" })] }), _jsx(Animated.View, { ref: node => { anchor.current = node; if (quoteGesture.webRef)
438
+ quoteGesture.webRef.current = node; }, ...quoteGesture.handlers, accessibilityActions: quoting ? [{ name: "quote", label: "Quote message" }] : undefined, onAccessibilityAction: event => { if (quoting && event.nativeEvent.actionName === "quote")
439
+ setReply(message); }, testID: `message-${message.kind}-${mine ? 'outgoing' : 'incoming'}`, style: { ...quoteGesture.webStyle, transform: [{ translateX: quoteGesture.offset }], ...(Platform.OS === "web" && !pendingStatus ? { touchAction: "pan-y", userSelect: "none" } : {}), alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '88%', backgroundColor: mine ? c.outgoing : c.incoming, borderRadius: 8, padding: visual ? 3 : 6, paddingBottom: visual || canTranscript || canTranslate ? 3 : 6, boxShadow: bigEmoji ? undefined : '0 1px 1px #00000010', ...(voice && !message.text && !showingTransform ? { backgroundColor: 'transparent', padding: 0, paddingBottom: 0, boxShadow: undefined } : {}), ...(message.kind === 'poll' ? { backgroundColor: chatSurface(c.background), borderWidth: 1, borderColor: c.accent + '60', paddingHorizontal: 8, paddingTop: 4, paddingBottom: 2 } : {}), ...(bigEmoji ? { backgroundColor: 'transparent', paddingHorizontal: 4, paddingTop: 0, paddingBottom: 0 } : {}), ...(extra?.address || socialReady ? { flexDirection: mine ? 'row-reverse' : 'row', alignItems: 'center', gap: 8, padding: 0, paddingBottom: 0, backgroundColor: 'transparent', boxShadow: undefined } : {}) }, children: extra?.address ? extraCard : socialReady ? socialCard : bubbleBody })] });
298
440
  }
299
441
  function MessageDeletion({ messages, onClose }) {
300
442
  const { client, design } = useMessagingUI(), [busy, setBusy] = useState(false), [error, setError] = useState('');
@@ -302,8 +444,8 @@ function MessageDeletion({ messages, onClose }) {
302
444
  if (!messages.length)
303
445
  return null;
304
446
  const c = design.colors;
305
- return _jsxs(MessagingPopup, { background: chatSurface(c.background), onClose: () => { if (!busy)
306
- onClose(); }, children: [_jsxs(Text, { accessibilityRole: "header", style: { color: c.foreground, fontSize: 17, fontWeight: '600' }, children: ["Delete ", messages.length === 1 ? 'message' : messages.length + ' messages', "?"] }), _jsx(Text, { style: { color: c.secondary, marginTop: 8 }, children: "Delete for everyone? Only messages that have not been read, heard or opened can be deleted." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', justifyContent: 'flex-end', gap: 20, marginTop: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: error ? 'Close message alert' : undefined, disabled: busy, onPress: onClose, style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground }, children: error ? 'OK' : 'Cancel' }) }), !error && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Delete for everyone", disabled: busy, onPress: () => { setBusy(true); void (async () => { try {
447
+ return _jsxs(MessagingPopup, { compact: true, contentWidth: 640, title: "Delete " + (messages.length === 1 ? "message" : messages.length + " messages") + "?", foreground: c.foreground, closeDisabled: busy, background: chatSurface(c.background), onClose: () => { if (!busy)
448
+ onClose(); }, children: [_jsx(Text, { style: { color: messagingPopupColors.secondary, marginTop: 8 }, children: "Delete for everyone? Only messages that have not been read, heard or opened can be deleted." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground, marginTop: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: error ? 'Close message alert' : undefined, disabled: busy, onPress: onClose, style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: messagingPopupColors.incoming, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 16, fontWeight: '600' }, children: error ? 'OK' : 'Cancel' }) }), !error && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Delete for everyone", disabled: busy, onPress: () => { setBusy(true); void (async () => { try {
307
449
  for (const message of messages)
308
450
  await client.deleteMessage(message.id, message.revision ?? 0);
309
451
  onClose();
@@ -313,7 +455,7 @@ function MessageDeletion({ messages, onClose }) {
313
455
  }
314
456
  finally {
315
457
  setBusy(false);
316
- } })(); }, style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Delete" }) })] })] });
458
+ } })(); }, style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: '#b72d34', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: '#ffffff', fontSize: 16, fontWeight: '600' }, children: "Delete" }) })] })] });
317
459
  }
318
460
  function MessageSelection() {
319
461
  const { selection, setSelection, setForwarding, setDeleting, setEditing, state, client, design, userId, copyText, saved, saving, toggleSaved } = useMessagingUI();
@@ -323,11 +465,12 @@ function MessageSelection() {
323
465
  const c = design.colors;
324
466
  const ordered = orderChatMessages([...selection].sort((a, b) => BigInt(a.id) < BigInt(b.id) ? -1 : BigInt(a.id) > BigInt(b.id) ? 1 : 0)), enabled = !!state.settings?.enabled && state.conversation?.canSend !== false;
325
467
  const forward = enabled && ordered.every(m => client.canForward(m)), remove = enabled && state.messageActionsAvailable && ordered.every(m => m.senderId === userId && !m.readAt && !m.deleted);
326
- return _jsxs(View, { accessibilityLabel: "Selected message actions", style: { flexDirection: 'row', alignItems: 'center', minHeight: 48, paddingHorizontal: 4, gap: 0, backgroundColor: c.background, borderTopWidth: 1, borderTopColor: c.secondary + '25' }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Clear message selection", onPress: () => setSelection([]), style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", color: c.foreground }) }), _jsx(Text, { accessibilityLiveRegion: "polite", accessibilityLabel: selection.length + ' selected', style: { flex: 1, color: c.foreground, fontSize: 15 }, children: selection.length }), selection.length === 1 && !!ordered[0].text && (copyText || Platform.OS === 'web') && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Copy selected message text", onPress: () => { void (copyText ? copyText(ordered[0].text) : navigator.clipboard.writeText(ordered[0].text)).then(() => setSelection([])).catch(() => setError('The text could not be copied.')); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "copy", color: c.accent }) }), selection.length === 1 && client.hasSavedMessages() && ordered[0].kind !== 'poll' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: saved.some(r => r.key === savedMessageKey(ordered[0])) ? 'Remove selected message from Saved' : 'Save selected message', disabled: saving, onPress: () => void toggleSaved(ordered[0]), style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "bookmark", color: c.accent }) }), forward && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Forward selected messages", onPress: () => { setForwarding(ordered); setSelection([]); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "forward", color: c.accent }) }), remove && selection.length === 1 && ordered[0].kind !== 'poll' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Edit selected message", onPress: () => { setEditing(ordered[0]); setSelection([]); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "edit", color: c.accent }) }), remove && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Delete selected messages", onPress: () => { setDeleting(ordered); setSelection([]); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "trash", color: c.accent }) }), !!error && _jsxs(MessagingPopup, { background: c.background, onClose: () => setError(''), children: [_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => setError(''), style: { minHeight: 44, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "OK" }) })] })] });
468
+ return _jsxs(View, { accessibilityLabel: "Selected message actions", style: { flexDirection: 'row', alignItems: 'center', minHeight: 48, paddingHorizontal: 4, gap: 0, backgroundColor: c.background, borderTopWidth: 1, borderTopColor: c.secondary + '25' }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Clear message selection", onPress: () => setSelection([]), style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", color: c.foreground }) }), _jsx(Text, { accessibilityLiveRegion: "polite", accessibilityLabel: selection.length + ' selected', style: { flex: 1, color: c.foreground, fontSize: 15 }, children: selection.length }), selection.length === 1 && !!ordered[0].text && (copyText || Platform.OS === 'web') && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Copy selected message text", onPress: () => { void (copyText ? copyText(ordered[0].text) : navigator.clipboard.writeText(ordered[0].text)).then(() => setSelection([])).catch(() => setError('The text could not be copied.')); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "copy", color: c.accent }) }), selection.length === 1 && client.hasSavedMessages() && ordered[0].kind !== 'poll' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: saved.some(r => r.key === savedMessageKey(ordered[0])) ? 'Remove selected message from Saved' : 'Save selected message', disabled: saving, onPress: () => void toggleSaved(ordered[0]), style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "bookmark", color: c.accent }) }), forward && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Forward selected messages", onPress: () => { setForwarding(ordered); setSelection([]); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "forward", color: c.accent }) }), remove && selection.length === 1 && ordered[0].kind !== 'poll' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Edit selected message", onPress: () => { setEditing(ordered[0]); setSelection([]); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "edit", color: c.accent }) }), remove && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Delete selected messages", onPress: () => { setDeleting(ordered); setSelection([]); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "trash", color: c.accent }) }), !!error && _jsxs(MessagingPopup, { compact: true, contentWidth: 640, title: "Message", foreground: c.foreground, background: c.background, onClose: () => setError(''), children: [_jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => setError(''), style: { minHeight: 44, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "OK" }) })] })] });
327
469
  }
328
470
  export function MessagingList() {
329
471
  const context = useMessagingUI();
330
472
  const { state, client, components, design, userId, selection, setSelection } = context;
473
+ const Privacy = components?.PrivacyLayer ?? MessagingPrivacyLayer;
331
474
  const items = orderChatMessages([...state.messages, ...state.pending.map(p => ({
332
475
  ...p.content, id: 'pending:' + p.clientMessageId, clientMessageId: p.clientMessageId, conversationId: state.conversationId, senderId: userId,
333
476
  createdAt: p.createdAt ?? new Date().toISOString(), readAt: null, pendingStatus: p.status,
@@ -335,6 +478,19 @@ export function MessagingList() {
335
478
  const list = useRef(null);
336
479
  const nearLatest = useRef(true), lastNewest = useRef(''), lastPending = useRef('');
337
480
  const [away, setAway] = useState(false), [newMessages, setNewMessages] = useState(false);
481
+ const [historyHeight, setHistoryHeight] = useState(0);
482
+ const [scrolling, setScrolling] = useState(false);
483
+ const scrollIdle = useRef(null);
484
+ const latestOpacity = useRef(new Animated.Value(0)).current;
485
+ const showLatest = (away || !!state.historyWindow) && !scrolling;
486
+ const scrollingStarted = () => { if (scrollIdle.current)
487
+ clearTimeout(scrollIdle.current); setScrolling(true); };
488
+ const scrollingSettled = () => { if (scrollIdle.current)
489
+ clearTimeout(scrollIdle.current); scrollIdle.current = setTimeout(() => { scrollIdle.current = null; setScrolling(false); }, 600); };
490
+ useEffect(() => { if (Platform.OS === 'web')
491
+ return; const animation = Animated.timing(latestOpacity, { toValue: showLatest ? 1 : 0, duration: 200, useNativeDriver: true }); animation.start(); return () => animation.stop(); }, [showLatest, latestOpacity]);
492
+ useEffect(() => { setScrolling(false); return () => { if (scrollIdle.current)
493
+ clearTimeout(scrollIdle.current); }; }, [state.conversationId]);
338
494
  const jumpToLatest = (animated = true) => { if (state.historyWindow) {
339
495
  void client.goToLatest().then(() => { list.current?.scrollToOffset({ offset: 0, animated }); nearLatest.current = true; setAway(false); setNewMessages(false); }).catch(() => { });
340
496
  return;
@@ -352,9 +508,14 @@ export function MessagingList() {
352
508
  setAway(true);
353
509
  setHighlight(state.focus.messageId);
354
510
  scrollTarget.current = { index, attempts: 0 };
355
- const frame = requestAnimationFrame(() => list.current?.scrollToIndex({ index, viewPosition: .5, animated: false }));
511
+ const target = scrollTarget.current;
512
+ const position = () => { if (scrollTarget.current === target)
513
+ list.current?.scrollToIndex({ index, viewPosition: .5, animated: false }); };
514
+ const frame = requestAnimationFrame(position);
515
+ // Native cells and keyboard geometry can settle after the initial layout callback.
516
+ const settling = [150, 400].map(delay => setTimeout(position, delay));
356
517
  const timer = setTimeout(() => { setHighlight(null); scrollTarget.current = null; }, 2500);
357
- return () => { cancelAnimationFrame(frame); clearTimeout(timer); };
518
+ return () => { cancelAnimationFrame(frame); settling.forEach(clearTimeout); clearTimeout(timer); };
358
519
  }, [state.focus]);
359
520
  const newest = items[0]?.clientMessageId ?? '', outgoing = state.pending.at(-1)?.clientMessageId ?? '';
360
521
  useEffect(() => {
@@ -391,23 +552,27 @@ export function MessagingList() {
391
552
  pending.current = "";
392
553
  });
393
554
  }).current;
394
- return (_jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(FlatList, { ref: list, testID: "messaging-history", maintainVisibleContentPosition: { minIndexForVisible: 0 }, initialNumToRender: 30, maxToRenderPerBatch: 20, onScrollToIndexFailed: info => { const target = scrollTarget.current; if (!target || target.attempts++ >= 5)
395
- return; list.current?.scrollToOffset({ offset: info.averageItemLength * info.index, animated: false }); setTimeout(() => { if (scrollTarget.current === target)
396
- list.current?.scrollToIndex({ index: target.index, viewPosition: .5, animated: false }); }, 60); }, onScroll: event => { const near = event.nativeEvent.contentOffset.y < 80; nearLatest.current = near; setAway(!near); if (near)
397
- setNewMessages(false); }, scrollEventThrottle: 32, data: items, inverted: true, keyExtractor: (m) => m.senderId + ':' + m.clientMessageId, style: { flex: 1 }, keyboardShouldPersistTaps: "handled", contentContainerStyle: { paddingHorizontal: 12, paddingVertical: 10, flexGrow: 1, justifyContent: "flex-end" }, onViewableItemsChanged: onVisible, viewabilityConfig: {
398
- itemVisiblePercentThreshold: 50,
399
- minimumViewTime: 500,
400
- }, renderItem: ({ item, index }) => {
401
- const previous = items[index + 1];
402
- const date = new Date(item.createdAt), start = !previous || date.toDateString() !== new Date(previous.createdAt).toDateString();
403
- const checked = selection.some(m => m.id === item.id), selectable = selection.length > 0 && !item.pendingStatus && !item.deleted;
404
- const toggle = () => setSelection(checked ? selection.filter(m => m.id !== item.id) : [...selection, item]);
405
- return _jsxs(View, { testID: highlight === item.id ? 'focused-message' : undefined, style: highlight === item.id || checked ? { backgroundColor: design.colors.accent + '20', borderRadius: 8 } : undefined, children: [start && _jsx(Text, { style: { alignSelf: 'center', fontSize: 12, color: design.colors.secondary, backgroundColor: chatSurface(design.colors.background), borderRadius: 7, paddingHorizontal: 12, paddingVertical: 5, marginTop: 4, marginBottom: 10 }, children: date.toDateString() === new Date().toDateString() ? 'Today' : date.toLocaleDateString([], { month: 'short', day: 'numeric' }) }), _jsx(View, { style: { paddingLeft: selection.length ? 32 : 0 }, pointerEvents: selection.length ? 'none' : 'auto', accessibilityElementsHidden: selection.length > 0, importantForAccessibility: selection.length ? 'no-hide-descendants' : 'auto', ...(Platform.OS === 'web' && selection.length ? { 'aria-hidden': true } : {}), children: components?.Message ? _jsx(components.Message, { message: item }) : _jsx(MessagingBubble, { message: item, pendingStatus: item.pendingStatus }) }), selectable && _jsx(Pressable, { accessibilityRole: "checkbox", accessibilityLabel: 'Select message: ' + (item.text || item.media?.fileName || mediaNames[item.kind] || 'Message'), accessibilityState: { checked }, onPress: toggle, style: { position: 'absolute', inset: 0, justifyContent: 'center' }, children: _jsx(View, { style: { width: 24, height: 24, borderRadius: 12, borderWidth: 1, borderColor: design.colors.accent, backgroundColor: checked ? design.colors.accent : design.colors.background, alignItems: 'center', justifyContent: 'center' }, children: checked && _jsx(MessageIcon, { name: "check", size: 16, color: "#ffffff" }) }) })] });
406
- }, onEndReached: () => {
407
- if (state.hasMore && !state.loading && !state.historyError) {
408
- void client.loadOlder().catch(() => { });
409
- }
410
- }, onEndReachedThreshold: 0.2, ListHeaderComponent: state.historyWindow && state.hasNewer ? _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Load newer messages", disabled: state.loading, onPress: () => void client.loadNewer().catch(() => { }), style: { padding: 10, minHeight: 44, alignItems: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: "Load newer messages" }) }) : null, ListFooterComponent: state.messages.length > 0 && (state.loading || state.historyError || state.hasMore) ? _jsx(View, { style: { padding: 10, alignItems: 'center' }, children: state.loading ? _jsx(ActivityIndicator, { accessibilityLabel: "Loading earlier messages", color: design.colors.accent }) : _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: state.historyError ? 'Retry earlier messages' : 'Load earlier messages', onPress: () => void client.loadOlder().catch(() => { }), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: state.historyError ? 'Earlier messages could not load. Retry' : 'Load earlier messages' }) }) }) : null, ListEmptyComponent: _jsx(Text, { style: { color: design.colors.secondary }, children: state.loading ? "Loading messages…" : design.labels.empty }) }), (away || state.historyWindow) && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: newMessages ? 'New messages. Go to latest' : 'Go to latest message', onPress: () => jumpToLatest(), style: { position: 'absolute', left: '50%', marginLeft: -22, bottom: 8, width: 44, height: 44, justifyContent: 'center', alignItems: 'center' }, children: _jsx(MessageIcon, { name: "arrow-down", color: design.colors.accent, size: 28 }) }), _jsx(MessageSelection, {})] }));
555
+ return (_jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(Privacy, { enabled: !!context.extrasState?.privacyLens, background: design.colors.background, children: _jsx(FlatList, { ref: list, testID: "messaging-history", onLayout: event => { setHistoryHeight(event.nativeEvent.layout.height); const target = scrollTarget.current; if (target)
556
+ requestAnimationFrame(() => { if (scrollTarget.current === target)
557
+ list.current?.scrollToIndex({ index: target.index, viewPosition: .5, animated: false }); }); }, maintainVisibleContentPosition: highlight ? undefined : { minIndexForVisible: 0 }, onContentSizeChange: () => { const target = scrollTarget.current; if (target)
558
+ requestAnimationFrame(() => { if (scrollTarget.current === target)
559
+ list.current?.scrollToIndex({ index: target.index, viewPosition: .5, animated: false }); }); }, initialNumToRender: 30, maxToRenderPerBatch: 20, onScrollToIndexFailed: info => { const target = scrollTarget.current; if (!target || target.attempts++ >= 5)
560
+ return; list.current?.scrollToOffset({ offset: info.averageItemLength * info.index, animated: false }); setTimeout(() => { if (scrollTarget.current === target)
561
+ list.current?.scrollToIndex({ index: target.index, viewPosition: .5, animated: false }); }, 60); }, onScrollBeginDrag: () => { scrollTarget.current = null; scrollingStarted(); }, onScrollEndDrag: scrollingSettled, onMomentumScrollBegin: scrollingStarted, onMomentumScrollEnd: scrollingSettled, onScroll: event => { scrollingStarted(); scrollingSettled(); const near = event.nativeEvent.contentOffset.y < 80; nearLatest.current = near; setAway(!near); if (near)
562
+ setNewMessages(false); }, scrollEventThrottle: 32, data: items, inverted: true, keyExtractor: (m) => m.senderId + ':' + m.clientMessageId, style: { flex: 1 }, keyboardShouldPersistTaps: "handled", contentContainerStyle: { paddingHorizontal: 12, paddingVertical: 10, paddingTop: context.extrasState?.privacyLens ? privacyReadingStripHeight + 10 : 10, flexGrow: 1, justifyContent: context.extrasState?.privacyLens ? "flex-start" : "flex-end" }, onViewableItemsChanged: onVisible, viewabilityConfig: {
563
+ itemVisiblePercentThreshold: 50,
564
+ minimumViewTime: 500,
565
+ }, renderItem: ({ item, index }) => {
566
+ const previous = items[index + 1];
567
+ const date = new Date(item.createdAt), start = !previous || date.toDateString() !== new Date(previous.createdAt).toDateString();
568
+ const checked = selection.some(m => m.id === item.id), selectable = selection.length > 0 && !item.pendingStatus && !item.deleted;
569
+ const toggle = () => setSelection(checked ? selection.filter(m => m.id !== item.id) : [...selection, item]);
570
+ return _jsxs(View, { testID: highlight === item.id ? 'focused-message' : undefined, style: highlight === item.id || checked ? { backgroundColor: design.colors.accent + '20', borderRadius: 8 } : undefined, children: [start && _jsx(Text, { style: { alignSelf: 'center', fontSize: 12, color: design.colors.secondary, backgroundColor: chatSurface(design.colors.background), borderRadius: 7, paddingHorizontal: 12, paddingVertical: 5, marginTop: 4, marginBottom: 10 }, children: date.toDateString() === new Date().toDateString() ? 'Today' : date.toLocaleDateString([], { month: 'short', day: 'numeric' }) }), _jsx(View, { style: { paddingLeft: selection.length ? 32 : 0 }, pointerEvents: selection.length ? 'none' : 'auto', accessibilityElementsHidden: selection.length > 0, importantForAccessibility: selection.length ? 'no-hide-descendants' : 'auto', ...(Platform.OS === 'web' && selection.length ? { 'aria-hidden': true } : {}), children: components?.Message ? _jsx(components.Message, { message: item }) : _jsx(MessagingBubble, { message: item, pendingStatus: item.pendingStatus, previousMessage: previous, nextMessage: items[index - 1] }) }), selectable && _jsx(Pressable, { accessibilityRole: "checkbox", accessibilityLabel: 'Select message: ' + (item.text || item.media?.fileName || mediaNames[item.kind] || 'Message'), accessibilityState: { checked }, onPress: toggle, style: { position: 'absolute', inset: 0, justifyContent: 'center' }, children: _jsx(View, { style: { width: 24, height: 24, borderRadius: 12, borderWidth: 1, borderColor: design.colors.accent, backgroundColor: checked ? design.colors.accent : design.colors.background, alignItems: 'center', justifyContent: 'center' }, children: checked && _jsx(MessageIcon, { name: "check", size: 16, color: "#ffffff" }) }) })] });
571
+ }, onEndReached: () => {
572
+ if (state.hasMore && !state.loading && !state.historyError) {
573
+ void client.loadOlder().catch(() => { });
574
+ }
575
+ }, onEndReachedThreshold: 0.2, ListHeaderComponent: state.historyWindow && state.hasNewer ? _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Load newer messages", disabled: state.loading, onPress: () => void client.loadNewer().catch(() => { }), style: { padding: 10, minHeight: 44, alignItems: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: "Load newer messages" }) }) : null, ListFooterComponent: _jsxs(_Fragment, { children: [state.messages.length > 0 && (state.loading || state.historyError || state.hasMore) ? _jsx(View, { style: { padding: 10, alignItems: 'center' }, children: state.loading ? _jsx(ActivityIndicator, { accessibilityLabel: "Loading earlier messages", color: design.colors.accent }) : _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: state.historyError ? 'Retry earlier messages' : 'Load earlier messages', onPress: () => void client.loadOlder().catch(() => { }), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: state.historyError ? 'Earlier messages could not load. Retry' : 'Load earlier messages' }) }) }) : null, context.extrasState?.privacyLens && _jsx(View, { testID: "privacy-history-spacer", style: { height: Math.max(0, historyHeight - privacyReadingStripHeight) } })] }), ListEmptyComponent: _jsx(Text, { style: { color: design.colors.secondary }, children: state.loading ? "Loading messages…" : design.labels.empty }) }, context.extrasState?.privacyLens ? "privacy-reading" : "regular-history") }), (away || state.historyWindow) && _jsx(Animated.View, { testID: "latest-message-control", pointerEvents: showLatest ? 'auto' : 'none', accessibilityElementsHidden: !showLatest, importantForAccessibility: showLatest ? 'auto' : 'no-hide-descendants', ...(Platform.OS === 'web' ? { 'aria-hidden': !showLatest } : {}), style: { position: 'absolute', left: '50%', marginLeft: -22, bottom: 8, opacity: Platform.OS === 'web' ? (showLatest ? 1 : 0) : latestOpacity, ...(Platform.OS === 'web' ? { transitionProperty: 'opacity', transitionDuration: '200ms' } : {}) }, children: _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: newMessages ? 'New messages. Go to latest' : 'Go to latest message', disabled: !showLatest, onPress: () => jumpToLatest(), style: { width: 44, height: 44, borderRadius: 22, borderWidth: 1.5, borderColor: design.colors.accent, backgroundColor: chatSurface(design.colors.background), justifyContent: 'center', alignItems: 'center' }, children: _jsx(MessageIcon, { name: "arrow-down", color: design.colors.accent, size: 26 }) }) }), _jsx(MessageSelection, {})] }));
411
576
  }
412
577
  /** One horizontal media row on phones and in the browser preview. */
413
578
  function AttachmentStrip({ children, label, height, recent = false }) {
@@ -433,8 +598,14 @@ function AttachmentStrip({ children, label, height, recent = false }) {
433
598
  }, []);
434
599
  return _jsx(ScrollView, { ref: scroll, horizontal: true, accessibilityLabel: label, directionalLockEnabled: true, alwaysBounceVertical: false, showsVerticalScrollIndicator: false, showsHorizontalScrollIndicator: false, style: { height, flexGrow: 0, flexShrink: 0, ...(recent ? { marginTop: 8 } : {}), ...(Platform.OS === 'web' ? { overflowY: 'hidden', touchAction: 'pan-x' } : {}) }, contentContainerStyle: { flexDirection: 'row', alignItems: 'flex-start', gap: 8, paddingHorizontal: recent ? 0 : 8, paddingBottom: recent ? 4 : 2 }, children: children });
435
600
  }
601
+ function WeatherComposerAction({ onClose }) {
602
+ const { extras, client, design } = useMessagingUI();
603
+ const [busy, setBusy] = useState(false), [error, setError] = useState('');
604
+ return _jsxs(_Fragment, { children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: "Share weather", disabled: busy, onPress: () => { setBusy(true); void extras?.sendWeather().then(() => client.refresh()).then(onClose).catch(e => setError(e instanceof Error ? e.message : 'Weather could not be sent.')).finally(() => setBusy(false)); }, style: { flex: 1, minWidth: 0, minHeight: 84, alignItems: 'center', gap: 3 }, children: [_jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#db7c2412', alignItems: 'center', justifyContent: 'center' }, children: busy ? _jsx(ActivityIndicator, { color: design.colors.accent }) : _jsx(MessageIcon, { name: "weather", color: design.colors.accent, size: 28 }) }), _jsx(Text, { style: { color: design.colors.foreground, fontSize: 13 }, children: "Weather" })] }), !!error && _jsx(ExtraPopup, { title: "Weather", background: design.colors.background, foreground: design.colors.foreground, onClose: () => setError(''), children: _jsx(Text, { style: { color: design.colors.foreground }, children: error }) })] });
605
+ }
436
606
  export function MessagingComposer() {
437
- const { state, client, design, pickAttachment, attachmentChoices, attachmentPreview, releaseAttachment, prepareAttachment, retainDraftAttachments, reply, setReply, editing, setEditing, components, openAttachment, userId } = useMessagingUI();
607
+ const weatherAllowed = !!useMessagingUI().extrasState?.available.weather;
608
+ const { state, client, design, pickAttachment, attachmentChoices, attachmentPreview, releaseAttachment, prepareAttachment, retainDraftAttachments, reply, setReply, editing, setEditing, components, openAttachment, userId, participants } = useMessagingUI();
438
609
  const VoiceComposer = components?.VoiceComposer ?? VoiceMessageComposer;
439
610
  const [savedDraft, setDraftState] = useState(state.draft ?? ""), [busy, setBusy] = useState(false), [error, setError] = useState("");
440
611
  const [editDraft, setEditDraft] = useState('');
@@ -445,6 +616,24 @@ export function MessagingComposer() {
445
616
  setDraftState(value);
446
617
  client.setDraft(value);
447
618
  } };
619
+ const [mentionIds, setMentionIds] = useState([]), [mentionMembers, setMentionMembers] = useState([]), [mentionError, setMentionError] = useState('');
620
+ const mentionQuery = !editing && state.discussionAvailable ? draft.match(/(?:^|\s)@([^@\n]{0,40})$/)?.[1] : undefined;
621
+ useEffect(() => { setMentionIds([]); setMentionMembers([]); setMentionError(''); }, [state.conversationId]);
622
+ useEffect(() => {
623
+ if (mentionQuery === undefined)
624
+ return;
625
+ let live = true;
626
+ void client.groupMembers().then(page => { if (live) {
627
+ setMentionMembers(page.memberIds ?? page.members.map(m => m.userId));
628
+ setMentionError('');
629
+ } }).catch(() => { if (live)
630
+ setMentionError('Group members could not load.'); });
631
+ return () => { live = false; };
632
+ }, [client, state.conversationId, mentionQuery !== undefined]);
633
+ const mentionSuggestions = mentionQuery === undefined ? [] : mentionMembers.filter(id => id !== userId && participants?.[id]?.name.toLocaleLowerCase().includes(mentionQuery.toLocaleLowerCase())).slice(0, 6);
634
+ const mentionsFor = (text) => mentionIds.filter(id => text.includes('@' + participants?.[id]?.name)).slice(0, 20);
635
+ const chooseMention = (id) => { const name = participants?.[id]?.name; if (!name)
636
+ return; setDraft(draft.slice(0, draft.lastIndexOf('@')) + '@' + name + ' '); setMentionIds(ids => [...new Set([...ids, id])].slice(0, 20)); requestAnimationFrame(() => input.current?.focus()); };
448
637
  useEffect(() => { setEditDraft(editing?.text ?? ''); setError(''); if (editing) {
449
638
  setAttachmentsOpen(false);
450
639
  requestAnimationFrame(() => input.current?.focus());
@@ -452,12 +641,17 @@ export function MessagingComposer() {
452
641
  useEffect(() => setDraftState(state.draft ?? ''), [state.draft, state.conversationId]);
453
642
  const [normalSelected, setNormalSelected] = useState([]);
454
643
  const selectionSequence = useRef(0);
644
+ const draftPreview = useRef(attachmentPreview);
645
+ draftPreview.current = attachmentPreview;
455
646
  useEffect(() => { setNormalSelected(items => { if (!retainDraftAttachments)
456
647
  for (const item of items)
457
648
  void releaseAttachment?.(item.media); return []; }); }, [state.conversationId]);
458
649
  useEffect(() => {
459
650
  if (!state.draftAttachments)
460
651
  return;
652
+ // A host may recreate its preview callback on every keystroke. Restore only
653
+ // when the saved draft changes, never when that callback identity changes.
654
+ const attachmentPreview = draftPreview.current;
461
655
  let active = true;
462
656
  const items = state.draftAttachments.filter(item => item.media && item.kind !== 'text' && item.kind !== 'poll').map(item => ({ id: item.media.assetId, kind: item.kind, media: item.media }));
463
657
  setNormalSelected(items);
@@ -466,7 +660,7 @@ export function MessagingComposer() {
466
660
  void attachmentPreview({ ...item, id: item.id, conversationId: state.conversationId, clientMessageId: item.id, senderId: '', createdAt: new Date().toISOString(), readAt: null }).then(preview => { if (active)
467
661
  setNormalSelected(current => current.map(value => value.id === item.id ? { ...value, thumbnail: preview?.uri } : value)); }).catch(() => { });
468
662
  return () => { active = false; };
469
- }, [state.draftAttachments, attachmentPreview]);
663
+ }, [state.draftAttachments, state.conversationId]);
470
664
  useEffect(() => { setNormalSelected(items => items.filter(item => { const allowed = state.settings?.enabled && state.conversation?.canSend !== false && state.settings.features[item.kind]; if (!allowed)
471
665
  void releaseAttachment?.(item.media); return allowed; })); if (!state.settings?.enabled || state.conversation?.canSend === false)
472
666
  setDraft(''); }, [state.settings, state.conversation?.canSend]);
@@ -498,7 +692,7 @@ export function MessagingComposer() {
498
692
  setAttachmentFilter(null); }, [attachmentsOpen]);
499
693
  useEffect(() => { if (attachmentFilter && !state.settings?.features[attachmentFilter])
500
694
  setAttachmentFilter(null); }, [attachmentFilter, state.settings]);
501
- const [inputHeight, setInputHeight] = useState(42);
695
+ const [inputHeight, setInputHeight] = useState(44);
502
696
  useEffect(() => { if (!draft)
503
697
  setInputHeight(42); }, [draft]);
504
698
  const inFlight = useRef(false);
@@ -509,10 +703,12 @@ export function MessagingComposer() {
509
703
  return; const back = BackHandler.addEventListener("hardwareBackPress", () => { setAttachmentsOpen(false); return true; }); return () => back.remove(); }, [attachmentsOpen]);
510
704
  const c = design.colors, features = state.settings?.features, enabled = state.settings?.enabled;
511
705
  const library = { image: !!features?.image && design.showPhotoLibrary !== false, video: !!features?.video && design.showVideoLibrary !== false, document: !!features?.document };
706
+ const camera = !!(features?.image || features?.video) && !!design.showCamera;
512
707
  async function submit(content) {
513
708
  const outgoing = {
514
709
  ...content,
515
- ...(reply && features?.replies ? { replyTo: reply.id } : {}),
710
+ ...(content.text && mentionsFor(content.text).length ? { mentions: mentionsFor(content.text) } : {}),
711
+ ...(reply && features?.replies ? { replyTo: reply.id, ...(reply.threadRoot ? { threadRoot: reply.threadRoot } : {}) } : {}),
516
712
  };
517
713
  const prepare = content.media && prepareAttachment && content.kind !== 'text' && content.kind !== 'poll'
518
714
  ? async () => ({ ...outgoing, media: await prepareAttachment(content.kind, content.media) })
@@ -553,12 +749,13 @@ export function MessagingComposer() {
553
749
  return;
554
750
  inFlight.current = true;
555
751
  const conversationId = state.conversationId;
556
- const quote = reply && features?.replies ? { replyTo: reply.id } : {};
752
+ const quote = reply && features?.replies ? { replyTo: reply.id, ...(reply.threadRoot ? { threadRoot: reply.threadRoot } : {}) } : {};
557
753
  const attachments = [...selectedRef.current];
558
754
  const batchId = attachments.length > 1 ? 'batch-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2) : null;
559
- const outgoing = attachments.length ? attachments.map((item, index) => ({ kind: item.kind, media: item.media, ...(batchId ? { attachmentBatch: { id: batchId, index, count: attachments.length } } : {}), ...(index === 0 ? quote : {}), ...(index === attachments.length - 1 && draft.trim() ? { text: draft.trim() } : {}) })) : [{ kind: 'text', text: draft.trim(), ...quote }];
755
+ const outgoing = attachments.length ? attachments.map((item, index) => ({ kind: item.kind, media: item.media, ...(batchId ? { attachmentBatch: { id: batchId, index, count: attachments.length } } : {}), ...(index === 0 ? quote : {}), ...(index === attachments.length - 1 && draft.trim() ? { text: draft.trim(), ...(mentionsFor(draft).length ? { mentions: mentionsFor(draft) } : {}) } : {}) })) : [{ kind: 'text', text: draft.trim(), ...quote, ...(mentionsFor(draft).length ? { mentions: mentionsFor(draft) } : {}) }];
560
756
  setSelected([]);
561
757
  setDraft('');
758
+ setMentionIds([]);
562
759
  setReply(null);
563
760
  setError('');
564
761
  // Put every item in the chat immediately; uploading must not lock the composer.
@@ -603,37 +800,38 @@ export function MessagingComposer() {
603
800
  }
604
801
  setError("");
605
802
  try {
606
- const media = await pickAttachment(kind, kind === "voice"
803
+ const result = await pickAttachment(kind, kind === "voice"
607
804
  ? { maxDurationMs: (state.settings?.voice?.maxDurationSeconds ?? 60) * 1000, intent: 'attach' }
608
- : { ...(source ? { source } : {}), ...(attachmentId ? { attachmentId } : {}), ...(!source ? { intent } : {}), ...(state.settings?.attachments ?? defaultAttachmentLimits())[kind],
805
+ : { maxCount: editing || source || attachmentId || intent === 'preview' ? 1 : 10 - selectedRef.current.length, ...(source ? { source } : {}), ...(attachmentId ? { attachmentId } : {}), ...(!source ? { intent } : {}), ...(state.settings?.attachments ?? defaultAttachmentLimits())[kind],
609
806
  ...(kind === "video" ? { maxDurationMs: (state.settings?.attachments ?? defaultAttachmentLimits()).video.maxDurationSeconds * 1000 } : {}) });
610
- if (media && (client.getSnapshot().conversationId !== conversationId || editingRef.current?.id !== editId)) {
611
- void releaseAttachment?.(media);
807
+ const picked = result ? (Array.isArray(result) ? result : [result]) : [];
808
+ const current = client.getSnapshot(), limit = editId ? 1 : Math.max(0, 10 - selectedRef.current.length);
809
+ if (current.conversationId !== conversationId || editingRef.current?.id !== editId || !current.settings?.enabled || current.conversation?.canSend === false) {
810
+ picked.forEach(media => void releaseAttachment?.(media));
612
811
  return;
613
812
  }
614
- if (media && client.getSnapshot().conversationId === conversationId) {
615
- setAttachmentsOpen(false);
616
- {
617
- const actualKind = source === 'camera' ? (media.mimeType.startsWith('video/') ? 'video' : 'image') : kind;
618
- const current = client.getSnapshot();
619
- if (!current.settings?.enabled || current.conversation?.canSend === false || !current.settings.features[actualKind]) {
620
- void releaseAttachment?.(media);
621
- return;
622
- }
623
- const id = 'attachment-' + (++selectionSequence.current);
624
- const thumbnail = attachmentChoices?.find(item => item.id === attachmentId)?.thumbnailUri;
625
- if (editId)
626
- for (const previous of selectedRef.current)
627
- if (previous.media.assetId !== editing?.media?.assetId)
628
- void releaseAttachment?.(previous.media);
629
- setSelected(items => [...(editId ? [] : items), { id, kind: actualKind, media, thumbnail }]);
630
- if (!thumbnail && attachmentPreview) {
631
- const message = { id, conversationId: conversationId, kind: actualKind, media, senderId: '', clientMessageId: id, createdAt: new Date().toISOString(), readAt: null };
632
- void attachmentPreview(message).then(result => setSelected(items => items.map(item => item.id === id ? { ...item, thumbnail: result.uri } : item))).catch(() => { });
633
- }
634
- return;
635
- }
813
+ if (picked.length > limit) {
814
+ picked.forEach(media => void releaseAttachment?.(media));
815
+ throw Error(`Choose up to ${limit} attachments.`);
816
+ }
817
+ if (!picked.length)
818
+ return;
819
+ const additions = picked.map(media => ({ id: 'attachment-' + (++selectionSequence.current), kind: source === 'camera' ? (media.mimeType.startsWith('video/') ? 'video' : 'image') : kind, media, thumbnail: attachmentChoices?.find(item => item.id === attachmentId)?.thumbnailUri }));
820
+ if (additions.some(item => !current.settings.features[item.kind])) {
821
+ picked.forEach(media => void releaseAttachment?.(media));
822
+ return;
636
823
  }
824
+ setAttachmentsOpen(false);
825
+ if (editId)
826
+ for (const previous of selectedRef.current)
827
+ if (previous.media.assetId !== editing?.media?.assetId)
828
+ void releaseAttachment?.(previous.media);
829
+ setSelected(items => [...(editId ? [] : items), ...additions]);
830
+ for (const item of additions)
831
+ if (!item.thumbnail && attachmentPreview) {
832
+ const message = { ...item, conversationId: conversationId, senderId: '', clientMessageId: item.id, createdAt: new Date().toISOString(), readAt: null };
833
+ void attachmentPreview(message).then(result => setSelected(items => items.map(existing => existing.id === item.id ? { ...existing, thumbnail: result.uri } : existing))).catch(() => { });
834
+ }
637
835
  }
638
836
  catch (e) {
639
837
  setError(e instanceof Error ? e.message : "The attachment could not be sent.");
@@ -656,41 +854,61 @@ export function MessagingComposer() {
656
854
  }
657
855
  function renderTextComposer(voice = { hasVoice: false, blocked: false }) {
658
856
  const blocked = voice.blocked || busy;
659
- return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 }, children: [(library.image || library.video || library.document || !editing && features?.poll) && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Attach", accessibilityState: { expanded: attachmentsOpen }, disabled: busy || blocked, onPress: () => { if (attachmentsOpen) {
660
- setAttachmentsOpen(false);
661
- input.current?.focus();
662
- }
663
- else {
664
- Keyboard.dismiss();
665
- setAttachmentsOpen(true);
666
- } }, style: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: attachmentsOpen ? "keyboard" : "plus", color: c.foreground, size: 27 }) }), features?.text && _jsx(View, { style: { flex: 1, minWidth: 0, flexDirection: 'row', alignItems: 'flex-end', borderWidth: 1, borderColor: c.secondary + '30', backgroundColor: chatSurface(c.background), borderRadius: 23 }, children: _jsx(TextInput, { ref: input, accessibilityLabel: design.labels.compose, placeholder: design.labels.compose, placeholderTextColor: c.secondary, value: draft, editable: !blocked, onChangeText: setDraft, onContentSizeChange: e => setInputHeight(Math.max(42, Math.min(120, e.nativeEvent.contentSize.height))), multiline: true, numberOfLines: 1, maxLength: 10000, ...(Platform.OS === 'web' ? { onKeyPress: (event) => { const native = event.nativeEvent; if (native.key === 'Enter' && !native.shiftKey && !native.isComposing && native.keyCode !== 229) {
667
- event.preventDefault();
668
- if (!blocked)
669
- void send();
670
- } } } : {}), onFocus: () => setAttachmentsOpen(false), style: { ...(Platform.OS === 'web' ? { outlineStyle: 'none' } : {}), flex: 1, minWidth: 0, minHeight: 42, height: inputHeight, maxHeight: 120, color: c.foreground, fontSize: 16, lineHeight: 21, paddingHorizontal: 13, paddingVertical: 10 } }) }), !features?.text && _jsx(View, { style: { flex: 1 } }), (features?.image || features?.video) && design.showCamera && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Camera", disabled: busy || blocked, onPress: () => void pick(features?.image ? 'image' : 'video', 'camera'), style: { width: 42, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "camera", color: c.foreground, size: 24 }) }), features?.voice && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Voice message", disabled: voicePending || busy || blocked, onPress: () => void pick('voice'), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', opacity: voicePending || busy || blocked ? 0.5 : 1 }, children: _jsx(MessageIcon, { name: "mic", color: c.foreground, size: 25 }) }), (features?.text || selected.length > 0) && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: editing ? "Save message" : design.labels.send, disabled: blocked || (!draft.trim() && !voice.sendVoice && !selected.length), onPress: () => { if (voice.sendVoice)
857
+ return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'flex-end', gap: 4 }, children: [_jsxs(View, { testID: "messaging-composer-field", style: { flex: 1, minWidth: 0, minHeight: 44, borderRadius: 22, backgroundColor: composerSurface(c.background), flexDirection: 'row', alignItems: 'flex-end', overflow: 'hidden' }, children: [(camera || library.image || library.video || library.document || !editing && (features?.poll || weatherAllowed)) && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Attach", accessibilityState: { expanded: attachmentsOpen }, disabled: busy || blocked, onPress: () => { if (attachmentsOpen) {
858
+ setAttachmentsOpen(false);
859
+ input.current?.focus();
860
+ }
861
+ else {
862
+ Keyboard.dismiss();
863
+ setAttachmentsOpen(true);
864
+ } }, style: { width: 42, height: 44, flexShrink: 0, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: attachmentsOpen ? "keyboard" : "plus", color: c.accent, size: 25 }) }), features?.text && _jsx(TextInput, { ref: input, accessibilityLabel: design.labels.compose, placeholder: design.labels.compose, placeholderTextColor: c.secondary, value: draft, editable: !blocked, onChangeText: setDraft, onContentSizeChange: e => setInputHeight(Math.max(44, Math.min(120, e.nativeEvent.contentSize.height))), multiline: true, numberOfLines: 1, maxLength: 10000, ...(Platform.OS === 'web' ? { onKeyPress: (event) => { const native = event.nativeEvent; if (native.key === 'Enter' && !native.shiftKey && !native.isComposing && native.keyCode !== 229) {
865
+ event.preventDefault();
866
+ if (!blocked)
867
+ void send();
868
+ } } } : {}), onFocus: () => setAttachmentsOpen(false), style: { ...(Platform.OS === 'web' ? { outlineStyle: 'none' } : {}), flex: 1, minWidth: 0, minHeight: 44, height: inputHeight, maxHeight: 120, color: c.foreground, fontSize: 16, lineHeight: 22, paddingHorizontal: 4, paddingVertical: 8, backgroundColor: 'transparent' } }), !features?.text && _jsx(View, { style: { flex: 1 } }), features?.voice && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Voice message", disabled: voicePending || busy || blocked, onPress: () => void pick('voice'), style: { width: 42, height: 44, flexShrink: 0, alignItems: 'center', justifyContent: 'center', opacity: voicePending || busy || blocked ? 0.5 : 1 }, children: _jsx(MessageIcon, { name: "mic", color: c.accent, size: 23 }) })] }), (features?.text || selected.length > 0) && _jsx(QuickSendButton, { choice: state.quickMessage, empty: !draft.trim() && !selected.length && !voice.hasVoice && !voice.sendVoice, enabled: !!features?.text, disabled: blocked, editing: !!editing, sendAvailable: !!draft.trim() || !!voice.sendVoice || selected.length > 0, appearance: design, onChoose: emoji => { void client.setQuickMessage(emoji).catch(() => setError('This quick message could not be saved on this device.')); }, onSend: () => { if (voice.sendVoice)
671
869
  voice.sendVoice();
672
870
  else
673
- void send(); }, style: { width: 42, height: 42, marginLeft: 4, borderRadius: 21, backgroundColor: c.accent, alignItems: 'center', justifyContent: 'center', opacity: blocked || (!draft.trim() && !voice.sendVoice && !selected.length) ? 0.4 : 1 }, children: _jsx(MessageIcon, { name: editing ? "check" : "send", color: "#ffffff", size: 21 }) })] });
871
+ void send(); }, onQuickSend: emoji => { void submit({ kind: 'text', text: emoji }).catch(() => { }); } }, state.conversationId)] });
674
872
  }
675
873
  if (!enabled || state.conversation?.canSend === false) {
676
- return (_jsx(Text, { style: { padding: 16, color: c.secondary }, children: state.conversation?.safety?.blockedByMe ? 'You blocked this contact. Use contact options to unblock.' : state.conversation?.canSend === false ? (state.conversation.kind === 'group' ? 'Only members allowed to send can post in this group.' : 'Messaging is unavailable for this conversation.') : design.labels.unavailable }));
874
+ return (_jsx(Text, { style: { padding: 16, color: c.secondary }, children: state.offline ? 'Offline · Recent messages only. Connect to send and load more.' : state.conversation?.safety?.blockedByMe ? 'You blocked this contact. Use contact options to unblock.' : state.conversation?.canSend === false ? (state.conversation.kind === 'group' ? 'Only members allowed to send can post in this group.' : 'Messaging is unavailable for this conversation.') : design.labels.unavailable }));
677
875
  }
678
- return (_jsxs(View, { style: { paddingHorizontal: 6, paddingTop: 5, paddingBottom: 6, gap: 6, backgroundColor: chatSurface(c.background) }, children: [editing && _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsxs(Text, { numberOfLines: 1, style: { flex: 1, color: c.secondary }, children: ["Editing: ", editing.text || editing.media?.fileName] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Cancel editing", disabled: busy, onPress: () => setEditing(null), style: { minHeight: 36, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Cancel" }) })] }), !editing && reply && (_jsxs(View, { testID: "composer-quote", style: { flexDirection: "row", gap: 8 }, children: [_jsx(View, { style: { flex: 1 }, children: _jsx(QuotedPreview, { message: reply, ink: c.secondary }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Cancel quote", onPress: () => setReply(null), children: _jsx(Text, { style: { color: c.foreground }, children: "Cancel" }) })] })), state.settings?.history?.mode === 'last_two' && _jsx(Text, { style: { color: c.secondary, fontSize: 11, textAlign: 'center' }, children: "Only the latest two messages are kept." }), editing ? _jsxs(_Fragment, { children: [renderAttachments(), renderTextComposer()] }) : features?.voice ? _jsx(VoiceComposer, { appearance: design, maxSeconds: state.settings?.voice?.maxDurationSeconds ?? 60, busy: busy, onRecord: () => { Keyboard.dismiss(); void pick('voice'); }, renderAttachments: renderAttachments, renderTextComposer: features?.text || selected.length ? renderTextComposer : undefined, actions: _jsxs(_Fragment, { children: [(library.image || library.video || library.document || !editing && features?.poll) && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Attach", accessibilityState: { expanded: attachmentsOpen }, disabled: busy, onPress: () => setAttachmentsOpen(!attachmentsOpen), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: attachmentsOpen ? 'close' : 'plus', color: c.foreground, size: 25 }) }), (features?.image || features?.video) && design.showCamera && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Camera", disabled: busy, onPress: () => void pick(features?.image ? 'image' : 'video', 'camera'), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "camera", color: c.foreground, size: 23 }) })] }) }) : _jsxs(_Fragment, { children: [renderAttachments(), renderTextComposer()] }), attachmentsOpen && (library.image || library.video || library.document || features?.poll) && _jsxs(View, { accessibilityLabel: "Attachments", style: { paddingTop: 24, paddingBottom: 16, paddingHorizontal: 8, backgroundColor: chatSurface(c.background) }, children: [_jsxs(View, { style: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-start' }, children: [!editing && features?.poll && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: state.conversation?.kind === 'group' ? "Create poll" : "Ask Yes/No question", disabled: busy, onPress: () => { setAttachmentsOpen(false); setPollOpen(true); }, style: { flex: 1, minWidth: 0, minHeight: 84, alignItems: 'center', gap: 3 }, children: [_jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#db7c2412', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "poll", color: "#b76920", size: 28 }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 13 }, children: state.conversation?.kind === 'group' ? 'Poll' : 'Yes/No' })] }), ['image', 'video', 'document'].filter(kind => library[kind]).map(kind => {
679
- const color = { image: '#1677e8', video: '#7b58cf', document: '#079ec3' }[kind];
680
- const label = { image: 'Photos', video: 'Videos', document: 'Document' }[kind];
681
- return _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: mediaNames[kind], accessibilityState: { selected: attachmentFilter === kind }, disabled: busy, onPress: () => { if (!attachmentChoices)
876
+ return (_jsxs(View, { testID: "messaging-composer", style: { paddingStart: 8, paddingEnd: 0, paddingVertical: 4, gap: 8, backgroundColor: c.background }, children: [mentionSuggestions.length > 0 && _jsx(ScrollView, { keyboardShouldPersistTaps: "handled", style: { maxHeight: 144 }, children: mentionSuggestions.map(id => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Mention ' + participants[id].name, onPress: () => chooseMention(id), style: { minHeight: 44, paddingHorizontal: 12, justifyContent: 'center', borderBottomWidth: .5, borderBottomColor: c.secondary + '30' }, children: _jsxs(Text, { style: { color: c.accent, fontSize: 16 }, children: ["@", participants[id].name] }) }, id)) }), mentionQuery !== undefined && !!mentionError && _jsx(Text, { style: { color: c.secondary, fontSize: 13 }, children: mentionError }), editing && _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsxs(Text, { numberOfLines: 1, style: { flex: 1, color: c.secondary }, children: ["Editing: ", editing.text || editing.media?.fileName] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Cancel editing", disabled: busy, onPress: () => setEditing(null), style: { minHeight: 36, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Cancel" }) })] }), !editing && reply && (_jsxs(View, { testID: "composer-quote", style: { flexDirection: "row", gap: 8 }, children: [_jsx(View, { style: { flex: 1 }, children: _jsx(QuotedPreview, { message: reply, ink: c.secondary }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Cancel quote", onPress: () => setReply(null), children: _jsx(Text, { style: { color: c.foreground }, children: "Cancel" }) })] })), state.settings?.history?.mode === 'last_two' && _jsx(Text, { style: { color: c.secondary, fontSize: 11, textAlign: 'center' }, children: "Only the latest two messages are kept." }), editing ? _jsxs(_Fragment, { children: [renderAttachments(), renderTextComposer()] }) : features?.voice ? _jsx(VoiceComposer, { appearance: design, maxSeconds: state.settings?.voice?.maxDurationSeconds ?? 60, busy: busy, onRecord: () => { Keyboard.dismiss(); void pick('voice'); }, renderAttachments: renderAttachments, renderTextComposer: features?.text || selected.length ? renderTextComposer : undefined, actions: _jsx(_Fragment, { children: (camera || library.image || library.video || library.document || !editing && (features?.poll || weatherAllowed)) && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Attach", accessibilityState: { expanded: attachmentsOpen }, disabled: busy, onPress: () => setAttachmentsOpen(!attachmentsOpen), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: attachmentsOpen ? 'close' : 'plus', color: c.foreground, size: 25 }) }) }) }) : _jsxs(_Fragment, { children: [renderAttachments(), renderTextComposer()] }), attachmentsOpen && (camera || library.image || library.video || library.document || features?.poll || weatherAllowed) && _jsxs(View, { accessibilityLabel: "Attachments", style: { paddingTop: 24, paddingBottom: 16, paddingHorizontal: 8, backgroundColor: chatSurface(c.background) }, children: [_jsxs(View, { style: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-start' }, children: [['image', 'video', 'camera', 'document'].filter(kind => kind === 'camera' ? camera : library[kind]).map(kind => {
877
+ const color = { image: '#1677e8', video: '#7b58cf', camera: c.accent, document: '#079ec3' }[kind];
878
+ const label = { image: 'Photos', video: 'Videos', camera: 'Camera', document: 'Files' }[kind];
879
+ return _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: kind === 'camera' ? 'Camera' : mediaNames[kind], accessibilityState: { selected: attachmentFilter === kind }, disabled: busy, onPress: () => { if (kind === 'camera')
880
+ void pick(features?.image ? 'image' : 'video', 'camera');
881
+ else if (!attachmentChoices)
682
882
  void pick(kind);
683
883
  else
684
884
  setAttachmentFilter(attachmentFilter === kind ? null : kind); }, style: { flex: 1, minWidth: 0, minHeight: 84, alignItems: 'center', gap: 3 }, children: [_jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: color + (attachmentFilter === kind ? '28' : '12'), borderWidth: attachmentFilter === kind ? 2 : 0, borderColor: color, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: kind, color: color, size: 28 }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 13 }, children: label })] }, kind);
685
- })] }), !!attachmentChoices?.some(item => library[item.kind]) && _jsx(AttachmentStrip, { label: "Recent attachments", height: 116, recent: true, children: attachmentChoices.filter(item => library[item.kind] && (!attachmentFilter || item.kind === attachmentFilter)).map(item => _jsxs(View, { style: { width: 112, height: 112, borderRadius: 10, overflow: 'hidden', backgroundColor: c.incoming, borderWidth: 1, borderColor: c.secondary + '20' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Choose ${item.name}`, disabled: busy, onPress: () => void pick(item.kind, undefined, item.id), style: { width: '100%', height: '100%' }, children: [item.thumbnailUri ? _jsx(Image, { source: { uri: item.thumbnailUri }, accessibilityLabel: item.name, resizeMode: "cover", style: { width: '100%', height: '100%' } }) : _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 9, gap: 8 }, children: [_jsx(MessageIcon, { name: item.kind, color: c.accent, size: 30 }), _jsx(Text, { numberOfLines: 2, style: { color: c.incomingText, fontSize: 11, textAlign: 'center' }, children: item.name })] }), item.kind === 'video' && _jsxs(View, { style: { position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: '#0008', flexDirection: 'row', alignItems: 'center', gap: 5, padding: 6 }, children: [_jsx(MessageIcon, { name: "play", size: 13, color: "#fff" }), _jsx(Text, { style: { color: '#fff', fontSize: 11 }, children: duration(item.durationMs) })] })] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: `Preview ${item.name}`, disabled: busy, onPress: () => void pick(item.kind, undefined, item.id, 'preview'), style: { position: 'absolute', top: 0, right: 0, width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(View, { style: { width: 30, height: 30, borderRadius: 15, backgroundColor: '#111b', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "search", size: 18, color: "#fff" }) }) })] }, item.id)) }), attachmentFilter && !attachmentChoices?.some(item => library[item.kind] && item.kind === attachmentFilter) && _jsxs(Text, { style: { color: c.secondary, paddingVertical: 16, textAlign: 'center' }, children: ["No recent ", attachmentFilter === 'image' ? 'photos' : attachmentFilter === 'video' ? 'videos' : 'files', "."] })] }), pollOpen && features?.poll && _jsx(PollComposer, { yesNo: state.conversation?.kind !== 'group', appearance: design, onSend: poll => submit({ kind: 'poll', poll }), onCancel: () => setPollOpen(false) }), error && (_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }))] }));
885
+ }), !editing && features?.poll && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: state.conversation?.kind === 'group' ? "Create poll" : "Ask Yes/No question", disabled: busy, onPress: () => { setAttachmentsOpen(false); setPollOpen(true); }, style: { flex: 1, minWidth: 0, minHeight: 84, alignItems: 'center', gap: 3 }, children: [_jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#db7c2412', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "poll", color: "#b76920", size: 28 }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 13 }, children: state.conversation?.kind === 'group' ? 'Poll' : 'Yes/No' })] }), !editing && weatherAllowed && _jsx(WeatherComposerAction, { onClose: () => setAttachmentsOpen(false) })] }), !!attachmentChoices?.some(item => library[item.kind]) && _jsx(AttachmentStrip, { label: "Recent attachments", height: 116, recent: true, children: attachmentChoices.filter(item => library[item.kind] && (!attachmentFilter || item.kind === attachmentFilter)).map(item => _jsxs(View, { style: { width: 112, height: 112, borderRadius: 10, overflow: 'hidden', backgroundColor: c.incoming, borderWidth: 1, borderColor: c.secondary + '20' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Choose ${item.name}`, disabled: busy, onPress: () => void pick(item.kind, undefined, item.id), style: { width: '100%', height: '100%' }, children: [item.thumbnailUri ? _jsx(Image, { source: { uri: item.thumbnailUri }, accessibilityLabel: item.name, resizeMode: "cover", style: { width: '100%', height: '100%' } }) : _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 9, gap: 8 }, children: [_jsx(MessageIcon, { name: item.kind, color: c.accent, size: 30 }), _jsx(Text, { numberOfLines: 2, style: { color: c.incomingText, fontSize: 11, textAlign: 'center' }, children: item.name })] }), item.kind === 'video' && _jsxs(View, { style: { position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: '#0008', flexDirection: 'row', alignItems: 'center', gap: 5, padding: 6 }, children: [_jsx(MessageIcon, { name: "play", size: 13, color: "#fff" }), _jsx(Text, { style: { color: '#fff', fontSize: 11 }, children: duration(item.durationMs) })] })] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: `Preview ${item.name}`, disabled: busy, onPress: () => void pick(item.kind, undefined, item.id, 'preview'), style: { position: 'absolute', top: 0, right: 0, width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(View, { style: { width: 30, height: 30, borderRadius: 15, backgroundColor: '#111b', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "search", size: 18, color: "#fff" }) }) })] }, item.id)) }), attachmentFilter && !attachmentChoices?.some(item => library[item.kind] && item.kind === attachmentFilter) && _jsxs(Text, { style: { color: c.secondary, paddingVertical: 16, textAlign: 'center' }, children: ["No recent ", attachmentFilter === 'image' ? 'photos' : attachmentFilter === 'video' ? 'videos' : 'files', "."] })] }), pollOpen && features?.poll && _jsx(PollComposer, { yesNo: state.conversation?.kind !== 'group', appearance: design, onSend: poll => submit({ kind: 'poll', poll }), onCancel: () => setPollOpen(false) }), error && (_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }))] }));
886
+ }
887
+ /** Account navigation. Mount in the inbox/app toolbar, never in a contact menu. */
888
+ export function MessagingAccountMenu({ onNewGroup } = {}) {
889
+ const { client, design, setSavedOpen, account, companyName } = useMessagingUI();
890
+ const [open, setOpen] = useState(false), [accountOpen, setAccountOpen] = useState(false), pending = useRef(null);
891
+ const button = useRef(null), viewport = useWindowDimensions(), [anchor, setAnchor] = useState({ x: 12, y: 12 });
892
+ const width = Math.min(218, viewport.width - 24);
893
+ const show = () => button.current?.measureInWindow((x, y, w, h) => { setAnchor({ x: Math.max(12, Math.min(x + w - width, viewport.width - width - 12)), y: Math.max(12, Math.min(y + h + 4, viewport.height - (client.hasSavedMessages() ? 116 : 72) - (onNewGroup ? 44 : 0))) }); setOpen(true); });
894
+ const dismissed = () => { const next = pending.current; if (!next)
895
+ return; pending.current = null; requestAnimationFrame(() => next === 'saved' ? setSavedOpen(true) : next === 'group' ? onNewGroup?.() : setAccountOpen(true)); };
896
+ useEffect(() => { if (!open && Platform.OS !== 'ios')
897
+ dismissed(); }, [open]);
898
+ useEffect(() => { if (!open || Platform.OS !== 'web')
899
+ return; const key = (event) => { if (event.key === 'Escape') {
900
+ event.stopPropagation();
901
+ setOpen(false);
902
+ } }; document.addEventListener('keydown', key, true); return () => document.removeEventListener('keydown', key, true); }, [open]);
903
+ return _jsxs(_Fragment, { children: [_jsx(Pressable, { ref: button, accessibilityRole: "button", accessibilityLabel: "Messaging menu", accessibilityState: { expanded: open }, onPress: show, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "more", color: design.colors.foreground, size: 23 }) }), _jsx(Modal, { visible: open, transparent: true, animationType: "none", onRequestClose: () => setOpen(false), onDismiss: dismissed, children: _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close messaging menu", onPress: () => setOpen(false), style: { position: 'absolute', inset: 0 } }), _jsxs(View, { testID: "messaging-account-dropdown", style: { position: 'absolute', left: anchor.x, top: anchor.y, width, borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: "Account", onPress: () => { pending.current = 'account'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "person-circle-outline", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: "Account" })] }), onNewGroup && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: "New group", onPress: () => { pending.current = 'group'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "people-outline", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: "New group" })] }), client.hasSavedMessages() && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: "Saved messages", onPress: () => { pending.current = 'saved'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "bookmark-outline", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: "Saved" })] })] })] }) }), accountOpen && _jsx(MessagingAccountScreen, { account: account, companyName: companyName ?? design.name, onClose: () => setAccountOpen(false) })] });
686
904
  }
687
- function Content() {
905
+ export function MessagingConversation() {
688
906
  const { state, client, components, design, style } = useMessagingUI();
689
907
  const Header = components?.Header ?? MessagingHeader, List = components?.List ?? MessagingList, Composer = components?.Composer ?? MessagingComposer;
690
- return (_jsxs(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, style: [{ flex: 1, backgroundColor: design.colors.background }, style], children: [_jsx(Header, {}), _jsx(List, {}), state.error && (_jsx(Text, { accessibilityRole: "alert", style: { padding: 12, color: design.colors.foreground }, children: state.error })), _jsx(Composer, {}, state.conversationId)] }));
908
+ return (_jsxs(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, style: [{ flex: 1, backgroundColor: design.colors.background }, style], children: [_jsx(MessagingPopupHeaderBoundary, { children: _jsx(Header, {}) }), _jsx(List, {}), state.error && (_jsx(Text, { accessibilityRole: "alert", style: { padding: 12, color: design.colors.foreground }, children: state.error })), _jsx(Composer, {}, state.conversationId)] }));
691
909
  }
692
910
  /** Embed inside your app's safe area and navigation. No engine, push or global audio side effects. */
693
911
  export function MessagingScreen(props) {
694
- return (_jsx(MessagingProvider, { ...props, children: _jsx(Content, {}) }));
912
+ return (_jsx(MessagingProvider, { ...props, children: _jsx(MessagingConversation, {}) }));
695
913
  }
696
914
  export { MessagingInbox } from './inbox.js';