@robono/messaging-react-native 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +92 -37
  3. package/SBOM.spdx.json +577 -217
  4. package/dist/account.js +7 -4
  5. package/dist/attachment-menu.js +5 -2
  6. package/dist/call-message.js +8 -5
  7. package/dist/calling.js +6 -2
  8. package/dist/conversation-details.d.ts +1 -0
  9. package/dist/conversation-details.js +11 -7
  10. package/dist/custom-message.d.ts +7 -0
  11. package/dist/custom-message.js +53 -0
  12. package/dist/customization.d.ts +49 -0
  13. package/dist/customization.js +1 -0
  14. package/dist/expo-connected-calling.js +7 -2
  15. package/dist/expo-extras.js +4 -1
  16. package/dist/expo-media.js +27 -5
  17. package/dist/expo-messenger.d.ts +41 -2
  18. package/dist/expo-messenger.js +86 -10
  19. package/dist/expo-outbox.js +22 -1
  20. package/dist/expo-screen.d.ts +1 -1
  21. package/dist/expo-screen.js +42 -31
  22. package/dist/expo-social.js +5 -2
  23. package/dist/expo.d.ts +1 -1
  24. package/dist/expo.js +1 -1
  25. package/dist/extras.js +11 -7
  26. package/dist/forward.js +8 -5
  27. package/dist/group-editor.js +9 -6
  28. package/dist/header-calls.js +9 -6
  29. package/dist/icons.js +2 -1
  30. package/dist/inbox-search.d.ts +3 -1
  31. package/dist/inbox-search.js +33 -10
  32. package/dist/inbox-view.d.ts +21 -0
  33. package/dist/inbox-view.js +32 -0
  34. package/dist/inbox.d.ts +6 -1
  35. package/dist/inbox.js +27 -18
  36. package/dist/index.d.ts +16 -6
  37. package/dist/index.js +122 -81
  38. package/dist/localization.d.ts +24 -0
  39. package/dist/localization.js +12 -0
  40. package/dist/maps-connection.js +5 -2
  41. package/dist/media-ui.js +15 -8
  42. package/dist/message-menu.js +5 -2
  43. package/dist/native-outbox.d.ts +10 -2
  44. package/dist/native-outbox.js +7 -0
  45. package/dist/polls.js +21 -16
  46. package/dist/popup.js +8 -4
  47. package/dist/public-rooms.d.ts +10 -0
  48. package/dist/public-rooms.js +78 -0
  49. package/dist/quick-send.js +6 -3
  50. package/dist/room-moderation.d.ts +22 -0
  51. package/dist/room-moderation.js +87 -0
  52. package/dist/safety.js +11 -8
  53. package/dist/saved.js +7 -4
  54. package/dist/scheduled.js +19 -14
  55. package/dist/search.js +7 -4
  56. package/dist/social.js +9 -5
  57. package/dist/translation-settings.js +7 -4
  58. package/dist/typography.d.ts +12 -0
  59. package/dist/typography.js +31 -0
  60. package/dist/voice-composer.js +11 -6
  61. package/package.json +10 -8
  62. package/setup.plugin.cjs +20 -0
package/dist/index.js CHANGED
@@ -1,4 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { MessagingRoomModeration, MessagingRoomRemoval } from './room-moderation.js';
3
+ import { CustomMessage } from './custom-message.js';
4
+ import { MessagingTypography } from './typography.js';
5
+ import { Text, TextInput } from './typography.js';
6
+ import { useRobonoLocale, RobonoLocaleProvider, localizeMessagingAppearance } from './localization.js';
7
+ export { RobonoLocaleProvider, useRobonoLocale } from './localization.js';
2
8
  import { MessagingMenuSurface } from './popup.js';
3
9
  import { ScheduleTimePicker, ScheduledMessages } from './scheduled.js';
4
10
  import { MessagingGroupEditor } from './group-editor.js';
@@ -26,7 +32,7 @@ export { MessagingSafety } from './safety.js';
26
32
  import { PollMessage, PollComposer } from "./polls.js";
27
33
  export { PollMessage, PollComposer } from "./polls.js";
28
34
  import { createContext, useContext, useEffect, useRef, useState, useSyncExternalStore, } from "react";
29
- import { Animated, ActivityIndicator, AppState, BackHandler, FlatList, Image, KeyboardAvoidingView, Keyboard, Linking, Platform, Pressable, ScrollView, Text, TextInput, View, useWindowDimensions, } from "react-native";
35
+ import { Animated, ActivityIndicator, AppState, BackHandler, FlatList, Image, KeyboardAvoidingView, Keyboard, Linking, Platform, Pressable, ScrollView, View, useWindowDimensions, } from "react-native";
30
36
  import { useQuoteGesture, messageInfoRevealWidth, messageReplyRevealWidth } from "./use-quote-gesture.js";
31
37
  import { MessageIcon, duration, fileSize, chatSurface, composerSurface } from "./icons.js";
32
38
  import { VoiceMessageComposer } from "./voice-composer.js";
@@ -43,7 +49,11 @@ export function useMessagingUI() {
43
49
  throw Error("Wrap messaging components in MessagingProvider.");
44
50
  return context;
45
51
  }
46
- export function MessagingProvider({ children, ...props }) {
52
+ export function MessagingProvider(props) {
53
+ return _jsx(RobonoLocaleProvider, { value: props.interfaceLocale, children: _jsx(MessagingProviderContent, { ...props }) });
54
+ }
55
+ function MessagingProviderContent({ children, ...props }) {
56
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
47
57
  const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
48
58
  const extrasState = useSyncExternalStore(props.extras?.subscribe ?? noSubscribe, props.extras?.getSnapshot ?? noExtras, props.extras?.getSnapshot ?? noExtras);
49
59
  const messageIds = state.messages.filter(m => !m.deleted).map(m => m.id).join(',');
@@ -75,7 +85,7 @@ export function MessagingProvider({ children, ...props }) {
75
85
  useEffect(() => { let live = true; setSaved([]); setSavedOpen(false); if (props.client.hasSavedMessages())
76
86
  void props.client.savedMessages().then(rows => { if (live)
77
87
  setSaved(rows); }).catch(() => { if (live)
78
- setSavedError('Saved messages could not be opened.'); }); return () => { live = false; }; }, [props.client, state.conversationId]);
88
+ setSavedError(uiText("Saved messages could not be opened.")); }); return () => { live = false; }; }, [props.client, state.conversationId]);
79
89
  async function toggleSaved(message) { if (savingRef.current)
80
90
  return; const origin = savedContext.current, live = () => savedContext.current.client === origin.client && savedContext.current.conversationId === origin.conversationId; savingRef.current = true; setSaving(true); setSavedError(''); try {
81
91
  const key = savedMessageKey(message);
@@ -91,7 +101,7 @@ export function MessagingProvider({ children, ...props }) {
91
101
  }
92
102
  catch (e) {
93
103
  if (live())
94
- setSavedError(e instanceof Error ? e.message : 'This message could not be saved.');
104
+ setSavedError(e instanceof Error ? e.message : uiText("This message could not be saved."));
95
105
  }
96
106
  finally {
97
107
  savingRef.current = false;
@@ -138,19 +148,19 @@ export function MessagingProvider({ children, ...props }) {
138
148
  setReply,
139
149
  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,
140
150
  setEditing, setForwarding, setDeleting, selection: selected, setSelection, saved, saving, toggleSaved, setSavedOpen,
141
- design: props.appearance ?? state.settings?.appearance ??
142
- defaultMessagingSettings().appearance,
151
+ design: localizeMessagingAppearance(props.appearance ?? state.settings?.appearance ?? defaultMessagingSettings().appearance, uiText),
143
152
  };
144
- 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 {
145
- if (!props.openSavedAttachment)
146
- throw Error('Saved attachments cannot be opened here.');
147
- await props.openSavedAttachment(record);
148
- }
149
- catch (e) {
150
- setSavedError(e instanceof Error ? e.message : 'This saved attachment could not be opened.');
151
- } }, 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" }) })] })] }) });
153
+ return _jsx(Context.Provider, { value: value, children: _jsx(MessagingTypography, { appearance: value.design, 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 {
154
+ if (!props.openSavedAttachment)
155
+ throw Error('Saved attachments cannot be opened here.');
156
+ await props.openSavedAttachment(record);
157
+ }
158
+ catch (e) {
159
+ setSavedError(e instanceof Error ? e.message : uiText("This saved attachment could not be opened."));
160
+ } }, onRemove: async (key) => { await props.client.removeSavedMessage(key); setSaved(await props.client.savedMessages()); } }), !!savedError && _jsxs(MessagingPopup, { compact: true, contentWidth: 640, title: uiText("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: uiText("OK") }) })] })] }) }) });
152
161
  }
153
162
  function GroupDiscussion({ threadRoot, onClose }) {
163
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
154
164
  const context = useMessagingUI(), { client, state, design } = context;
155
165
  const [messages, setMessages] = useState([]), [root, setRoot] = useState(), [next, setNext] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState(''), [visible, setVisible] = useState(true);
156
166
  const loaded = useRef([]), loading = useRef(false), refreshPage = useRef(() => { });
@@ -196,7 +206,7 @@ function GroupDiscussion({ threadRoot, onClose }) {
196
206
  if (generation === version.current && !control.signal.aborted) {
197
207
  setMessages([]);
198
208
  setRoot(undefined);
199
- setError(e instanceof Error ? e.message : 'These messages could not load.');
209
+ setError(e instanceof Error ? e.message : uiText("These messages could not load."));
200
210
  }
201
211
  }
202
212
  finally {
@@ -215,8 +225,8 @@ function GroupDiscussion({ threadRoot, onClose }) {
215
225
  const open = (message, reply = false) => { pending.current = () => { void client.focusMessage(message.id).then(() => { if (reply && client.getSnapshot().conversationId === message.conversationId)
216
226
  context.setReply({ ...message, threadRoot: message.id }); }).catch(() => { }); }; setVisible(false); };
217
227
  const rows = root ? [root, ...messages.filter(m => m.id !== root.id)] : messages;
218
- 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)
219
- 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" }) })] });
228
+ return _jsxs(MessagingPopup, { visible: visible, onDismiss: dismiss, title: threadRoot ? uiText("Replies") : uiText("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)
229
+ 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: uiText("Earlier replies") }) }) : null, renderItem: ({ item, index }) => _jsxs(View, { children: [root && index === 0 && _jsx(Text, { style: { color: messagingPopupColors.secondary, marginBottom: 8 }, children: uiText("Original message") }), _jsx(MessagingBubble, { message: item }), !threadRoot && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Go to pinned message"), onPress: () => open(item), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: uiText("Go to message") }) })] }), ListEmptyComponent: !busy && !error ? _jsx(Text, { style: { color: messagingPopupColors.secondary, textAlign: 'center', padding: 20 }, children: uiText("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: uiText("Try again") }) })] }), root && state.conversation?.canSend !== false && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("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: uiText("Reply") }) })] });
220
230
  }
221
231
  export function messagingHeaderColors(design, group) {
222
232
  const background = group ? '#1F5F7A' : design.colors.accent;
@@ -225,20 +235,25 @@ export function messagingHeaderColors(design, group) {
225
235
  return { background, foreground };
226
236
  }
227
237
  function MessagingActivity({ ink }) {
238
+ const { text: uiText, count: uiCount, locale: uiLocale } = useRobonoLocale();
228
239
  const { state, design, participants } = useMessagingUI(), [now, setNow] = useState(Date.now());
229
240
  useEffect(() => { setNow(Date.now()); if (!state.presence?.peers.length)
230
241
  return; const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, [state.presence]);
231
242
  const typing = state.settings?.features.typing ? state.presence?.peers.filter(peer => peer.typingUntil > now) ?? [] : [];
232
243
  const online = state.settings?.features.online && state.presence?.peers.some(peer => peer.onlineUntil > now);
233
244
  const group = state.conversation?.kind === 'group';
234
- 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` : '';
245
+ const lastSeen = state.settings?.features.lastSeen ? state.presence?.peers.find(peer => Number.isFinite(peer.lastSeenAt) && peer.lastSeenAt > 0)?.lastSeenAt : null;
246
+ const lastSeenLabel = lastSeen ? new Date(lastSeen).toLocaleString(uiLocale, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
247
+ const names = typing.map(peer => participants?.[peer.userId]?.name ?? uiText('Someone'));
248
+ const text = typing.length ? (group ? (typing.length > 2 ? uiText('Several people are typing…') : typing.length === 2 ? uiText('{first} and {second} are typing…', { first: names[0], second: names[1] }) : uiText('{name} is typing…', { name: names[0] })) : uiText('Typing…')) : !group && online ? uiText('Online now') : !group && lastSeenLabel ? uiText('Last seen {time}', { time: lastSeenLabel }) : group ? uiCount(state.conversation?.memberCount ?? state.conversation?.memberIds?.length ?? 0, '{count} member', '{count} members') : '';
235
249
  return text ? _jsx(Text, { numberOfLines: 1, style: { color: ink, opacity: .8, fontSize: 14 }, children: text }) : null;
236
250
  }
237
251
  export function MessagingHeader({ children } = {}) {
252
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
238
253
  const { contact, design, state, client, openDiscussion, userId, participants, onBack, onContactOptions, selection, setSelection, language, components, callActions, attachmentPreview, openAttachment } = useMessagingUI();
239
254
  const [scheduledOpen, setScheduledOpen] = useState(false);
240
- const [safetyOpen, setSafetyOpen] = useState(false), [searchOpen, setSearchOpen] = useState(false), [detailsOpen, setDetailsOpen] = useState(false), [groupOpen, setGroupOpen] = useState(false);
241
- useEffect(() => { setDetailsOpen(false); setGroupOpen(false); setScheduledOpen(false); }, [state.conversationId]);
255
+ const [safetyOpen, setSafetyOpen] = useState(false), [searchOpen, setSearchOpen] = useState(false), [detailsOpen, setDetailsOpen] = useState(false), [groupOpen, setGroupOpen] = useState(false), [moderationOpen, setModerationOpen] = useState(false);
256
+ useEffect(() => { setDetailsOpen(false); setGroupOpen(false); setModerationOpen(false); setScheduledOpen(false); }, [state.conversationId]);
242
257
  const menuButton = useRef(null), viewport = useWindowDimensions(), [menuAnchor, setMenuAnchor] = useState({ x: 12, y: 12 });
243
258
  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); });
244
259
  const translationAvailable = !!state.settings?.enabled && !!state.settings.features.translation && client.hasLanguageTools();
@@ -250,7 +265,7 @@ export function MessagingHeader({ children } = {}) {
250
265
  paddingVertical: 4,
251
266
  backgroundColor: header.background,
252
267
  gap: 4,
253
- }, 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
268
+ }, children: [(onBack || selection.length > 0) && (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: selection.length ? uiText("Clear message selection") : uiText("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
254
269
  ? (_jsx(Image, { source: { uri: contact.avatarUrl }, style: { width: 38, height: 38, borderRadius: 19, borderWidth: 1, borderColor: "#ffffff" } }))
255
270
  : (_jsx(View, { style: {
256
271
  width: 38,
@@ -261,14 +276,15 @@ export function MessagingHeader({ children } = {}) {
261
276
  backgroundColor: c.incoming,
262
277
  alignItems: "center",
263
278
  justifyContent: "center",
264
- }, 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 || state.scheduledMessagesAvailable || 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, { onScheduled: state.scheduledMessagesAvailable ? () => { setSafetyOpen(false); setScheduledOpen(true); } : undefined, 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) })] }), scheduledOpen && _jsx(ScheduledMessages, { client: client, accent: c.accent, onClose: () => setScheduledOpen(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)] }));
279
+ }, 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, components?.HeaderActions && _jsx(components.HeaderActions, { conversationId: state.conversationId, contact: contact, appearance: design }), 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 || state.scheduledMessagesAvailable || translationAvailable) && (_jsx(Pressable, { accessibilityRole: "button", testID: "messaging-contact-options", accessibilityLabel: uiText("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, { onScheduled: state.scheduledMessagesAvailable ? () => { setSafetyOpen(false); setScheduledOpen(true); } : undefined, 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) })] }), scheduledOpen && _jsx(ScheduledMessages, { client: client, accent: c.accent, onClose: () => setScheduledOpen(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) }), moderationOpen && _jsx(MessagingRoomModeration, { client: client, userId: userId, participants: participants, accent: c.accent, onClose: () => setModerationOpen(false) }), detailsOpen && _jsx(ConversationDetails, { onModerate: () => setModerationOpen(true), 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)] }));
265
280
  }
266
281
  function PrivacyControl({ color }) {
282
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
267
283
  const { extras, extrasState, design } = useMessagingUI();
268
284
  const [error, setError] = useState(''), [busy, setBusy] = useState(false);
269
285
  if (!extras || !extrasState || !extrasState.available.privacyLens && !extrasState.privacyLens)
270
286
  return null;
271
- 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 }) })] });
287
+ return _jsxs(_Fragment, { children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: extrasState.privacyLens ? uiText("Turn off Privacy Lens") : uiText("Turn on Privacy Lens"), disabled: busy, onPress: () => { setBusy(true); void extras.setPrivacy(!extrasState.privacyLens).catch(() => setError(uiText("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: uiText("Privacy Lens"), background: design.colors.background, foreground: design.colors.foreground, onClose: () => setError(''), children: _jsx(Text, { style: { color: design.colors.foreground }, children: error }) })] });
272
288
  }
273
289
  const mediaNames = {
274
290
  voice: "Voice message",
@@ -276,9 +292,10 @@ const mediaNames = {
276
292
  video: "Video",
277
293
  document: "File",
278
294
  };
279
- function quoteSummary(message) { return message?.poll?.question ?? message?.text ?? (message && message.kind !== "text" && message.kind !== "poll" ? mediaNames[message.kind] : "Earlier message"); }
295
+ function quoteSummary(message, uiText) { return message?.poll?.question ?? message?.text ?? uiText(message && message.kind !== "text" && message.kind !== "poll" ? mediaNames[message.kind] : "Earlier message"); }
280
296
  /** The same compact quoted preview in the composer and delivered bubble. */
281
297
  function QuotedPreview({ message, ink }) {
298
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
282
299
  const { attachmentPreview, userId, participants, contact, state, design } = useMessagingUI();
283
300
  const [thumbnail, setThumbnail] = useState();
284
301
  useEffect(() => {
@@ -291,11 +308,12 @@ function QuotedPreview({ message, ink }) {
291
308
  }, [message?.id, message?.conversationId, message?.media?.assetId, message?.deleted, attachmentPreview]);
292
309
  const name = message && !message.deleted ? (message.senderId === userId ? 'You' : participants?.[message.senderId]?.name ?? (state.conversation?.kind === 'direct' ? contact.name : 'Chat member')) : null;
293
310
  const media = message && !message.deleted && message.kind !== 'text' && message.kind !== 'poll';
294
- 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)}` : ''] })] });
311
+ return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 7, minWidth: 0 }, children: [media && (thumbnail ? _jsx(Image, { accessibilityLabel: uiText("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 ? uiText("Message deleted") : quoteSummary(message, uiText), message?.kind === 'voice' && message.media?.durationMs ? ` · ${duration(message.media.durationMs)}` : ''] })] });
295
312
  }
296
313
  /** Thumbnails stay local to the view and are discarded on conversation/message changes. */
297
314
  const heardVoiceMessages = new WeakMap();
298
315
  function MediaContent({ message, ink, footer, onOpen, onLongPress }) {
316
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
299
317
  const { attachmentPreview, components, design, userId, client } = useMessagingUI();
300
318
  const [, updateHeard] = useState(0), voiceKey = message.conversationId + ':' + message.id + ':' + message.media?.assetId;
301
319
  const heard = heardVoiceMessages.get(client)?.has(voiceKey) ?? false;
@@ -319,28 +337,32 @@ function MediaContent({ message, ink, footer, onOpen, onLongPress }) {
319
337
  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 });
320
338
  }
321
339
  if (message.kind === 'image' || message.kind === 'video')
322
- 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) })] })] }) });
323
- 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)] })] })] });
340
+ 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 ?? uiText(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) })] })] }) });
341
+ 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 ?? uiText("File") }), _jsxs(Text, { style: { color: ink, fontSize: 12, opacity: 0.75 }, children: [message.media?.fileName?.split('.').pop()?.toUpperCase(), " \u00B7 ", fileSize(message.media?.byteSize)] })] })] });
324
342
  }
325
- function messageTimeLabel(message) { return (message.editedAt && !message.deleted ? 'edited · ' : '') + new Date(message.createdAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); }
343
+ function messageTimeLabel(message, locale, text) { return (message.editedAt && !message.deleted ? text('edited') + ' · ' : '') + new Date(message.createdAt).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit' }); }
326
344
  function MessageMetadata({ mine, ink, pendingStatus }) {
345
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
327
346
  if (!mine || !pendingStatus)
328
347
  return null;
329
- return _jsx(View, { accessibilityLabel: pendingStatus === 'sending' ? 'Sending message' : 'Message not sent', style: { alignSelf: 'flex-end' }, children: _jsx(MessageIcon, { name: pendingStatus === 'sending' ? 'clock' : 'warning', color: pendingStatus === 'failed' ? '#c94040' : ink, size: 15 }) });
348
+ return _jsx(View, { accessibilityLabel: pendingStatus === 'queued' ? uiText("Waiting for connection") : pendingStatus === 'sending' ? uiText("Sending message") : uiText("Message not sent"), style: { alignSelf: 'flex-end' }, children: _jsx(MessageIcon, { name: pendingStatus === 'failed' ? 'warning' : 'clock', color: pendingStatus === 'failed' ? '#c94040' : ink, size: 15 }) });
330
349
  }
331
350
  export function MessagingBubble({ message, pendingStatus, previousMessage, nextMessage }) {
351
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
332
352
  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();
333
- const extra = !pendingStatus && !message.deleted ? extrasState?.messages[message.id] : undefined;
353
+ const extra = !message.custom && !pendingStatus && !message.deleted ? extrasState?.messages[message.id] : undefined;
334
354
  const extraRef = useRef(null), socialRef = useRef(null);
335
355
  const socialOrigin = useMessagingUI().socialOrigin ?? 'https://robono.com';
336
- 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));
356
+ const socialReady = !message.custom && !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));
337
357
  const contextContactName = contact.name;
338
358
  const mine = message.senderId === userId, c = design.colors, ink = message.kind === 'poll' ? c.foreground : mine ? c.outgoingText : c.incomingText;
359
+ const bubbleRadius = design.presentation?.bubbleShape === 'square' ? 2 : design.presentation?.bubbleShape === 'rounded' ? 16 : 8;
360
+ const messageGap = design.presentation?.messageSpacing === 'compact' ? 4 : design.presentation?.messageSpacing === 'relaxed' ? 12 : 8;
339
361
  const visual = message.kind === 'image' || message.kind === 'video', voice = message.kind === 'voice';
340
362
  const previous = previousMessage ?? state.messages[state.messages.findIndex(m => m.id === message.id) - 1];
341
363
  const joinsBefore = continuesAttachmentSend(previousMessage, message), joinsAfter = continuesAttachmentSend(message, nextMessage);
342
364
  const grouped = previous?.senderId === message.senderId && new Date(previous.createdAt).toDateString() === new Date(message.createdAt).toDateString();
343
- const [error, setError] = useState(''), [busy, setBusy] = useState(false), [actions, setActions] = useState(false), [reportOpen, setReportOpen] = useState(false);
365
+ const [error, setError] = useState(''), [busy, setBusy] = useState(false), [actions, setActions] = useState(false), [reportOpen, setReportOpen] = useState(false), [roomRemoval, setRoomRemoval] = useState(false);
344
366
  const afterMenu = useRef(null);
345
367
  const menuDismissed = () => { const next = afterMenu.current; afterMenu.current = null; next?.(); };
346
368
  const leaveMenu = (next) => { afterMenu.current = next; setActions(false); if (Platform.OS !== 'ios')
@@ -379,7 +401,7 @@ export function MessagingBubble({ message, pendingStatus, previousMessage, nextM
379
401
  }
380
402
  catch {
381
403
  if (generation === transformGeneration.current)
382
- setError('This message could not be converted. Try again.');
404
+ setError(uiText("This message could not be converted. Try again."));
383
405
  }
384
406
  finally {
385
407
  if (generation === transformGeneration.current)
@@ -390,19 +412,19 @@ export function MessagingBubble({ message, pendingStatus, previousMessage, nextM
390
412
  const quoteGesture = useQuoteGesture(!pendingStatus && !selection.length, mine, quoting ? () => setReply(message) : undefined, extra ? () => extraRef.current?.open() : undefined, useMessagingUI().onQuoteReady);
391
413
  async function open() { if (pendingStatus || quoteGesture.suppressPress())
392
414
  return; if (!openAttachment) {
393
- setError('This attachment cannot be opened.');
415
+ setError(uiText("This attachment cannot be opened."));
394
416
  return;
395
417
  } setBusy(true); setError(''); try {
396
418
  await openAttachment(message);
397
419
  }
398
420
  catch {
399
- setError('The attachment could not be opened. Try again.');
421
+ setError(uiText("The attachment could not be opened. Try again."));
400
422
  }
401
423
  finally {
402
424
  setBusy(false);
403
425
  } }
404
426
  const menu = () => { if (pendingStatus === 'sending' || quoteGesture.suppressPress())
405
- return; if (!pendingStatus && !message.deleted || canChange || pendingStatus === 'failed' || canTranscript || canTranslate || canInteract && (quoting || state.settings?.features.reactions) || message.text && (copyText || Platform.OS === 'web') || onMessageOptions || !mine && state.conversation?.safety?.canReport)
427
+ return; if (!pendingStatus && !message.deleted || canChange || (pendingStatus === 'failed' || pendingStatus === 'queued') || canTranscript || canTranslate || canInteract && (quoting || state.settings?.features.reactions) || message.text && (copyText || Platform.OS === 'web') || onMessageOptions || !mine && state.conversation?.safety?.canReport)
406
428
  openMenu(); };
407
429
  async function react(emoji) {
408
430
  if (busy)
@@ -415,54 +437,59 @@ export function MessagingBubble({ message, pendingStatus, previousMessage, nextM
415
437
  await client.react(message.id, own === emoji ? null : emoji);
416
438
  }
417
439
  catch {
418
- setError('Your reaction could not be saved. Try again.');
440
+ setError(uiText("Your reaction could not be saved. Try again."));
419
441
  }
420
442
  finally {
421
443
  setBusy(false);
422
444
  }
423
445
  }
424
- 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);
425
- 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); };
426
- 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())
427
- 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;
428
- 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;
446
+ 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(uiText("This link could not be opened."))); }, style: { textDecorationLine: 'underline' }, children: part.text }, index) : part.text);
447
+ 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(uiText("Maps could not be opened."))); }, style: { color: c.accent, textDecorationLine: 'underline' }, children: address.text }), renderLinks(text.slice(address.end))] }) : renderLinks(text); };
448
+ 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: uiText("Show live tracking map"), hitSlop: { top: 6, bottom: 6, left: 4, right: 4 }, onLongPress: menu, onPress: event => { event.stopPropagation(); if (!quoteGesture.suppressPress())
449
+ extraRef.current?.open(); }, style: { flexShrink: 1, minWidth: 0, minHeight: 44, justifyContent: 'center', position: 'relative', backgroundColor: mine ? c.outgoing : c.incoming, borderRadius: bubbleRadius, paddingHorizontal: 6, paddingVertical: 3 }, children: [bubbleBody, people] }), action] }) }) : null;
450
+ 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: bubbleRadius, paddingHorizontal: 6, paddingVertical: 3 }, children: bubbleBody }), action] }) }) : null;
429
451
  const canCopy = !!message.text && (!!copyText || Platform.OS === 'web');
430
- 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)) }) });
452
+ const reactionPicker = !mine && canInteract && state.settings?.features.reactions && _jsx(View, { testID: "message-reaction-picker", accessibilityLabel: uiText("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: uiText("React {value1}", { value1: 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)) }) });
431
453
  const footer = _jsx(MessageMetadata, { message: message, mine: mine, ink: visual ? '#ffffff' : ink, pendingStatus: pendingStatus });
432
454
  const bigEmoji = message.kind === 'text' && !message.replyTo && !extra && !canTranslate && !transformed && isBigEmojiText(message.text);
433
455
  const showingTransform = !!transformed && !showOriginal && (transformed.kind === 'transcript' || canTranslate);
434
- 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' }) })] });
435
- 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", children: [_jsx(Text, { selectable: true, style: { color: ink, fontSize: bigEmoji ? 48 : 16, lineHeight: bigEmoji ? 58 : 22 }, children: renderText(message.text ?? '') }), pendingStatus && footer] }) : _jsx(MediaContent, { message: message, ink: ink, accent: c.accent, footer: voice ? languageActions : undefined, onOpen: () => void open(), onLongPress: menu });
436
- 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;
437
- 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())
456
+ 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') : uiText("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') : uiText("Show original") }) }), canTranscript && !transformed && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("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 ? uiText("Transcribing…") : uiText("Transcript") }) }), canTranslate && transformed?.kind !== 'translation' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: translation.mode === 'voice' ? uiText("Play translation") : uiText("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' ? uiText("Play Translation") : uiText("Translate") }) })] });
457
+ const fallbackContent = _jsx(View, { testID: "message-original-text", children: _jsx(Text, { selectable: true, style: { color: ink, fontSize: 16, lineHeight: 22 }, children: renderText(message.text ?? '') }) });
458
+ const originalContent = message.custom && !message.deleted ? _jsxs(Pressable, { onLongPress: menu, children: [_jsx(CustomMessage, { message: message, mine: mine, appearance: design, pendingStatus: pendingStatus, components: components, enabled: canInteract, children: fallbackContent }), pendingStatus && footer] }) : 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", children: [_jsx(Text, { selectable: true, style: { color: ink, fontSize: bigEmoji ? 48 : 16, lineHeight: bigEmoji ? 58 : 22 }, children: renderText(message.text ?? '') }), pendingStatus && footer] }) : _jsx(MediaContent, { message: message, ink: ink, accent: c.accent, footer: voice ? languageActions : undefined, onOpen: () => void open(), onLongPress: menu });
459
+ const content = showingTransform ? _jsxs(View, { children: [_jsx(Text, { style: { color: ink, fontSize: 11, opacity: .65, marginBottom: 2 }, children: transformed.kind === 'transcript' ? uiText("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;
460
+ const regionProps = { message, mine, appearance: design, pendingStatus };
461
+ const bodyContent = components?.MessageBody && !message.deleted ? _jsx(components.MessageBody, { ...regionProps, children: content }) : content;
462
+ 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 ? -(messageGap + 2) / 2 : 0, bottom: joinsAfter ? -(messageGap + 2) / 2 : 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 : bubbleRadius, borderBottomEndRadius: joinsAfter ? 0 : voice && !message.text && !showingTransform ? 24 : bubbleRadius } : { borderStartWidth: 2, borderStartColor: '#2B2B2B', borderTopStartRadius: joinsBefore ? 0 : voice && !message.text && !showingTransform ? 24 : bubbleRadius, borderBottomStartRadius: joinsAfter ? 0 : voice && !message.text && !showingTransform ? 24 : bubbleRadius }) } }), message.replyTo && message.replyTo !== discussionRoot && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Go to quoted message"), onLongPress: menu, onPress: () => { void client.focusMessage(message.replyTo).catch(e => setError(e instanceof Error ? e.message : uiText("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 ? uiText("Former member") : participants?.[message.senderId]?.name ?? uiText("Member") }), message.custom || extra?.address || extra?.weather || extra?.call || voice || message.kind === 'poll' ? bodyContent : _jsx(Pressable, { accessibilityRole: message.kind === 'text' ? undefined : 'button', accessibilityLabel: message.kind === 'text' ? undefined : uiText('{kind}: {name}', { kind: uiText(mediaNames[message.kind]), name: message.media?.fileName ?? uiText('Open attachment') }), disabled: busy, onLongPress: menu, onPress: message.kind === 'text' ? () => { if (quoteGesture.suppressPress())
438
463
  return; if (extra?.address)
439
464
  extraRef.current?.open();
440
465
  else
441
- 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) })] });
442
- 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)
443
- quoteGesture.webRef.current = node; }, ...quoteGesture.handlers, accessibilityActions: quoting ? [{ name: "quote", label: "Quote message" }] : undefined, onAccessibilityAction: event => { if (quoting && event.nativeEvent.actionName === "quote")
444
- 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 })] });
466
+ socialRef.current?.open(); } : () => void open(), children: bodyContent }), !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 ? uiText("reply") : uiText("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, components?.MessageFooter && !message.deleted && _jsx(components.MessageFooter, { ...regionProps }), transforming && !voice && _jsx(Text, { accessibilityLiveRegion: "polite", style: { color: ink, fontSize: 11 }, children: uiText("Converting…") }), busy && !voice && _jsx(Text, { style: { color: ink, fontSize: 12, marginTop: 4 }, children: uiText("Opening…") }), !!error && _jsxs(MessagingPopup, { compact: true, contentWidth: 640, title: uiText("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: uiText("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: uiText("OK") }) })] }), pendingStatus === 'failed' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("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: uiText("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: uiText("Quote message"), onPress: () => { setActions(false); setReply(message); } }), !discussionPreview && state.discussionAvailable && !pendingStatus && !message.deleted && _jsx(MessageMenuRow, { icon: "quote", label: uiText("Replies"), onPress: () => leaveMenu(() => openDiscussion(message.threadRoot ?? message.id)) }), state.discussionAvailable && canInteract && state.conversation?.ownerId === userId && _jsx(MessageMenuRow, { icon: "bookmark", label: message.pinned ? uiText("Unpin for group") : uiText("Pin for group"), onPress: () => { setActions(false); void client.pinMessage(message.id, !message.pinned).catch(e => setError(e instanceof Error ? e.message : uiText("The pin could not change."))); } }), canCopy && _jsx(MessageMenuRow, { icon: "copy", label: uiText("Copy"), onPress: () => { setActions(false); void (copyText ? copyText(message.text) : navigator.clipboard.writeText(message.text)).catch(() => setError(uiText("The text could not be copied."))); } }), canInteract && client.canForward(message) && _jsx(MessageMenuRow, { icon: "redo", label: uiText("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)) ? uiText("Remove from Saved") : uiText("Save"), disabled: saving, onPress: () => { setActions(false); void toggleSaved(message); } }), canChange && !message.custom && message.kind !== 'poll' && state.settings?.features[message.kind] && _jsx(MessageMenuRow, { icon: "edit", label: uiText("Edit"), onPress: () => { setActions(false); setEditing(message); } }), !mine && state.conversation?.safety?.canReport && _jsx(MessageMenuRow, { icon: "flag", label: uiText("Report"), danger: true, onPress: () => leaveMenu(() => setReportOpen(true)) }), !pendingStatus && !message.deleted && ['owner', 'moderator'].includes(state.conversation?.roomRole ?? '') && _jsx(MessageMenuRow, { icon: "trash", label: uiText('Remove from group'), danger: true, onPress: () => leaveMenu(() => setRoomRemoval(true)) }), canChange && _jsx(MessageMenuRow, { icon: "trash", label: uiText("Delete"), danger: true, onPress: () => leaveMenu(() => setDeleting([message])) }), (pendingStatus === 'failed' || pendingStatus === 'queued') && _jsx(MessageMenuRow, { icon: "trash", label: uiText("Remove unsent message"), danger: true, onPress: () => { setActions(false); void client.discardPending(message.clientMessageId).catch(() => setError(uiText("The unsent message could not be removed."))); } }), !pendingStatus && onMessageOptions && _jsx(MessageMenuRow, { icon: "more", label: uiText("More options"), onPress: () => leaveMenu(() => onMessageOptions(message)) }), !pendingStatus && !message.deleted && _jsx(MessageMenuRow, { icon: "check", label: uiText("Select Multiple"), divider: true, onPress: () => { setActions(false); setSelection([message]); Keyboard.dismiss(); } })] }), roomRemoval && _jsx(MessagingRoomRemoval, { client: client, message: message, accent: c.accent, onClose: () => setRoomRemoval(false) }), reportOpen && _jsx(MessagingSafety, { client: client, state: state, appearance: design, name: participants?.[message.senderId]?.name ?? 'Contact', messageId: message.id, onClose: () => setReportOpen(false) })] });
467
+ return _jsxs(View, { ...(Platform.OS === 'web' ? { onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onContextMenu: (event) => { event.preventDefault(); menu(); } } : {}), style: { width: '100%', position: 'relative', marginTop: messageGap + (message.reactions?.length ? 16 : 0), 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: uiText("SENT:") }), _jsx(Text, { style: { color: c.foreground, fontSize: 10, lineHeight: 12, fontWeight: '800' }, children: messageTimeLabel(message, uiLocale, uiText) })] }), 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: uiText("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(uiLocale, { hour: 'numeric', minute: '2-digit' }) : uiText("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: uiText("QUOTE MESSAGE") })] }), _jsx(Animated.View, { ref: node => { anchor.current = node; if (quoteGesture.webRef)
468
+ quoteGesture.webRef.current = node; }, ...quoteGesture.handlers, accessibilityActions: quoting ? [{ name: "quote", label: uiText("Quote message") }] : undefined, onAccessibilityAction: event => { if (quoting && event.nativeEvent.actionName === "quote")
469
+ 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: bubbleRadius, 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 })] });
445
470
  }
446
471
  function MessageDeletion({ messages, onClose }) {
472
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
447
473
  const { client, design } = useMessagingUI(), [busy, setBusy] = useState(false), [error, setError] = useState('');
448
474
  useEffect(() => setError(''), [messages]);
449
475
  if (!messages.length)
450
476
  return null;
451
477
  const c = design.colors;
452
- 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)
453
- 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 {
478
+ return _jsxs(MessagingPopup, { compact: true, contentWidth: 640, title: uiCount(messages.length, "Delete message?", "Delete {count} messages?"), foreground: c.foreground, closeDisabled: busy, background: chatSurface(c.background), onClose: () => { if (!busy)
479
+ onClose(); }, children: [_jsx(Text, { style: { color: messagingPopupColors.secondary, marginTop: 8 }, children: uiText("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 ? uiText("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 ? uiText("OK") : uiText("Cancel") }) }), !error && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Delete for everyone"), disabled: busy, onPress: () => { setBusy(true); void (async () => { try {
454
480
  for (const message of messages)
455
481
  await client.deleteMessage(message.id, message.revision ?? 0);
456
482
  onClose();
457
483
  }
458
484
  catch (e) {
459
- setError(e instanceof Error ? e.message : 'This message could not be deleted.');
485
+ setError(e instanceof Error ? e.message : uiText("This message could not be deleted."));
460
486
  }
461
487
  finally {
462
488
  setBusy(false);
463
- } })(); }, 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" }) })] })] });
489
+ } })(); }, style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: '#b72d34', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: '#ffffff', fontSize: 16, fontWeight: '600' }, children: uiText("Delete") }) })] })] });
464
490
  }
465
491
  function MessageSelection() {
492
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
466
493
  const { selection, setSelection, setForwarding, setDeleting, setEditing, state, client, design, userId, copyText, saved, saving, toggleSaved } = useMessagingUI();
467
494
  const [error, setError] = useState('');
468
495
  if (!selection.length)
@@ -470,9 +497,10 @@ function MessageSelection() {
470
497
  const c = design.colors;
471
498
  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;
472
499
  const forward = enabled && ordered.every(m => client.canForward(m)), remove = enabled && state.messageActionsAvailable && ordered.every(m => m.senderId === userId && !m.readAt && !m.deleted);
473
- 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" }) })] })] });
500
+ return _jsxs(View, { accessibilityLabel: uiText("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: uiText("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: uiText('{count} selected', { count: selection.length }), 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: uiText("Copy selected message text"), onPress: () => { void (copyText ? copyText(ordered[0].text) : navigator.clipboard.writeText(ordered[0].text)).then(() => setSelection([])).catch(() => setError(uiText("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])) ? uiText("Remove selected message from Saved") : uiText("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: uiText("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: uiText("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: uiText("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: uiText("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: uiText("OK") }) })] })] });
474
501
  }
475
502
  export function MessagingList() {
503
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
476
504
  const context = useMessagingUI();
477
505
  const { state, client, components, design, userId, selection, setSelection } = context;
478
506
  const Privacy = components?.PrivacyLayer ?? MessagingPrivacyLayer;
@@ -572,12 +600,12 @@ export function MessagingList() {
572
600
  const date = new Date(item.createdAt), start = !previous || date.toDateString() !== new Date(previous.createdAt).toDateString();
573
601
  const checked = selection.some(m => m.id === item.id), selectable = selection.length > 0 && !item.pendingStatus && !item.deleted;
574
602
  const toggle = () => setSelection(checked ? selection.filter(m => m.id !== item.id) : [...selection, item]);
575
- 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" }) }) })] });
603
+ 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() ? uiText("Today") : date.toLocaleDateString(uiLocale, { 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: uiText('Select message: {message}', { message: item.text || item.media?.fileName || uiText(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" }) }) })] });
576
604
  }, onEndReached: () => {
577
605
  if (state.hasMore && !state.loading && !state.historyError) {
578
606
  void client.loadOlder().catch(() => { });
579
607
  }
580
- }, 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, {})] }));
608
+ }, onEndReachedThreshold: 0.2, ListHeaderComponent: state.historyWindow && state.hasNewer ? _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("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: uiText("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: uiText("Loading earlier messages"), color: design.colors.accent }) : _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: state.historyError ? uiText("Retry earlier messages") : uiText("Load earlier messages"), onPress: () => void client.loadOlder().catch(() => { }), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: state.historyError ? uiText("Earlier messages could not load. Retry") : uiText("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 ? uiText("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 ? uiText("New messages. Go to latest") : uiText("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, {})] }));
581
609
  }
582
610
  /** One horizontal media row on phones and in the browser preview. */
583
611
  function AttachmentStrip({ children, label, height, recent = false }) {
@@ -604,6 +632,7 @@ function AttachmentStrip({ children, label, height, recent = false }) {
604
632
  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 });
605
633
  }
606
634
  export function MessagingComposer() {
635
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount, direction: uiDirection } = useRobonoLocale();
607
636
  const weatherAllowed = !!useMessagingUI().extrasState?.available.weather;
608
637
  const { state, client, design, extras, pickAttachment, attachmentChoices, attachmentPreview, releaseAttachment, prepareAttachment, retainDraftAttachments, reply, setReply, editing, setEditing, components, openAttachment, userId, participants } = useMessagingUI();
609
638
  const VoiceComposer = components?.VoiceComposer ?? VoiceMessageComposer;
@@ -627,7 +656,7 @@ export function MessagingComposer() {
627
656
  setMentionMembers(page.memberIds ?? page.members.map(m => m.userId));
628
657
  setMentionError('');
629
658
  } }).catch(() => { if (live)
630
- setMentionError('Group members could not load.'); });
659
+ setMentionError(uiText("Group members could not load.")); });
631
660
  return () => { live = false; };
632
661
  }, [client, state.conversationId, mentionQuery !== undefined]);
633
662
  const mentionSuggestions = mentionQuery === undefined ? [] : mentionMembers.filter(id => id !== userId && participants?.[id]?.name.toLocaleLowerCase().includes(mentionQuery.toLocaleLowerCase())).slice(0, 6);
@@ -736,7 +765,7 @@ export function MessagingComposer() {
736
765
  }
737
766
  catch (e) {
738
767
  if (client.getSnapshot().conversationId === current.conversationId)
739
- setError(e instanceof Error ? e.message : 'This message could not be changed.');
768
+ setError(e instanceof Error ? e.message : uiText("This message could not be changed."));
740
769
  }
741
770
  finally {
742
771
  inFlight.current = false;
@@ -768,7 +797,7 @@ export function MessagingComposer() {
768
797
  void releaseAttachment?.(content.media);
769
798
  }));
770
799
  if (client.getSnapshot().conversationId === conversationId && results.some(result => result.status === 'rejected') && !client.getSnapshot().pending.some(item => item.status === 'failed'))
771
- setError('The message could not be prepared. Please try again.');
800
+ setError(uiText("The message could not be prepared. Please try again."));
772
801
  }
773
802
  async function schedule(time) {
774
803
  if (inFlight.current)
@@ -817,7 +846,7 @@ export function MessagingComposer() {
817
846
  // and original time, even after that time passes, to avoid a second delivery.
818
847
  if (!requested || e instanceof MessagingError && e.status >= 400 && e.status < 500 && ![408, 409, 429].includes(e.status))
819
848
  scheduleAttempt.current = null;
820
- setScheduleError(e instanceof Error ? e.message : 'This message could not be scheduled.');
849
+ setScheduleError(e instanceof Error ? e.message : uiText("This message could not be scheduled."));
821
850
  }
822
851
  }
823
852
  finally {
@@ -827,7 +856,7 @@ export function MessagingComposer() {
827
856
  }
828
857
  async function pick(kind, source, attachmentId, intent = "send", mixedLibrary = false) {
829
858
  if (!editing && selectedRef.current.length >= 10) {
830
- setError('Choose up to 10 attachments per message.');
859
+ setError(uiText("Choose up to 10 attachments per message."));
831
860
  return;
832
861
  }
833
862
  if (intent === 'send')
@@ -835,7 +864,7 @@ export function MessagingComposer() {
835
864
  if (inFlight.current)
836
865
  return;
837
866
  if (!pickAttachment) {
838
- setError("Attachments are unavailable.");
867
+ setError(uiText("Attachments are unavailable."));
839
868
  return;
840
869
  }
841
870
  const conversationId = state.conversationId;
@@ -888,7 +917,7 @@ export function MessagingComposer() {
888
917
  }
889
918
  }
890
919
  catch (e) {
891
- setError(e instanceof Error ? e.message : "The attachment could not be sent.");
920
+ setError(e instanceof Error ? e.message : uiText("The attachment could not be sent."));
892
921
  }
893
922
  finally {
894
923
  if (recording) {
@@ -904,11 +933,11 @@ export function MessagingComposer() {
904
933
  function removeSelected(id) { const item = selectedRef.current.find(item => item.id === id); setSelected(items => items.filter(item => item.id !== id)); if (item && (!editing || item.media.assetId !== editing.media?.assetId))
905
934
  void releaseAttachment?.(item.media); }
906
935
  function renderAttachments(voiceReview, blocked = busy) {
907
- return selected.length > 0 || voiceReview ? _jsxs(AttachmentStrip, { label: "Pending attachments", height: selected.some(item => item.kind === 'document') ? 104 : 86, children: [selected.map(item => item.kind === 'voice' && item.media.assetId !== editing?.media?.assetId && components?.VoiceAttachment ? _jsx(components.VoiceAttachment, { media: item.media, appearance: design, disabled: blocked, onRemove: () => removeSelected(item.id) }, item.id) : _jsxs(View, { style: { width: 88, paddingTop: 8, paddingRight: 10 }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Preview ' + (item.media.fileName ?? mediaNames[item.kind]), disabled: blocked || !openAttachment, onPress: () => void openAttachment?.({ ...item, id: item.id, clientMessageId: item.id, conversationId: state.conversationId, senderId: userId, createdAt: new Date().toISOString(), readAt: null }).catch(() => setError('This attachment could not be opened.')), style: { width: 76, height: 76, borderRadius: 9, overflow: 'hidden', backgroundColor: c.incoming, borderWidth: 1, borderColor: c.secondary + '40', alignItems: 'center', justifyContent: 'center' }, children: [item.thumbnail ? _jsx(Image, { source: { uri: item.thumbnail }, accessibilityLabel: item.media.fileName ?? mediaNames[item.kind], resizeMode: "cover", style: { width: '100%', height: '100%' } }) : _jsx(MessageIcon, { name: item.kind === 'voice' ? 'mic' : item.kind, color: c.accent, size: 28 }), item.kind === 'video' && _jsx(View, { style: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: 4, backgroundColor: '#0009' }, children: _jsx(Text, { style: { color: '#fff', fontSize: 10 }, children: duration(item.media.durationMs) }) })] }), item.kind === 'document' && _jsx(Text, { numberOfLines: 1, style: { fontSize: 10, color: c.foreground, marginTop: 3 }, children: item.media.fileName ?? mediaNames[item.kind] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Remove ' + (item.media.fileName ?? mediaNames[item.kind]), disabled: blocked, onPress: () => removeSelected(item.id), style: { position: 'absolute', right: 0, top: 0, width: 30, height: 30, alignItems: 'center', justifyContent: 'center' }, children: _jsx(View, { style: { backgroundColor: c.foreground, borderRadius: 11, padding: 3 }, children: _jsx(MessageIcon, { name: "close", size: 16, color: c.background }) }) })] }, item.id)), voiceReview] }) : null;
936
+ return selected.length > 0 || voiceReview ? _jsxs(AttachmentStrip, { label: uiText("Pending attachments"), height: selected.some(item => item.kind === 'document') ? 104 : 86, children: [selected.map(item => item.kind === 'voice' && item.media.assetId !== editing?.media?.assetId && components?.VoiceAttachment ? _jsx(components.VoiceAttachment, { media: item.media, appearance: design, disabled: blocked, onRemove: () => removeSelected(item.id) }, item.id) : _jsxs(View, { style: { width: 88, paddingTop: 8, paddingRight: 10 }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText('Preview {name}', { name: item.media.fileName ?? uiText(mediaNames[item.kind]) }), disabled: blocked || !openAttachment, onPress: () => void openAttachment?.({ ...item, id: item.id, clientMessageId: item.id, conversationId: state.conversationId, senderId: userId, createdAt: new Date().toISOString(), readAt: null }).catch(() => setError(uiText("This attachment could not be opened."))), style: { width: 76, height: 76, borderRadius: 9, overflow: 'hidden', backgroundColor: c.incoming, borderWidth: 1, borderColor: c.secondary + '40', alignItems: 'center', justifyContent: 'center' }, children: [item.thumbnail ? _jsx(Image, { source: { uri: item.thumbnail }, accessibilityLabel: item.media.fileName ?? uiText(mediaNames[item.kind]), resizeMode: "cover", style: { width: '100%', height: '100%' } }) : _jsx(MessageIcon, { name: item.kind === 'voice' ? 'mic' : item.kind, color: c.accent, size: 28 }), item.kind === 'video' && _jsx(View, { style: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: 4, backgroundColor: '#0009' }, children: _jsx(Text, { style: { color: '#fff', fontSize: 10 }, children: duration(item.media.durationMs) }) })] }), item.kind === 'document' && _jsx(Text, { numberOfLines: 1, style: { fontSize: 10, color: c.foreground, marginTop: 3 }, children: item.media.fileName ?? uiText(mediaNames[item.kind]) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText('Remove {name}', { name: item.media.fileName ?? uiText(mediaNames[item.kind]) }), disabled: blocked, onPress: () => removeSelected(item.id), style: { position: 'absolute', right: 0, top: 0, width: 30, height: 30, alignItems: 'center', justifyContent: 'center' }, children: _jsx(View, { style: { backgroundColor: c.foreground, borderRadius: 11, padding: 3 }, children: _jsx(MessageIcon, { name: "close", size: 16, color: c.background }) }) })] }, item.id)), voiceReview] }) : null;
908
937
  }
909
938
  function renderTextComposer(voice = { hasVoice: false, blocked: false }) {
910
939
  const blocked = voice.blocked || busy;
911
- 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, { ref: attachmentButton, accessibilityRole: "button", accessibilityLabel: "Attach", accessibilityState: { expanded: attachmentsOpen }, disabled: busy || blocked, onPress: () => { if (attachmentsOpen) {
940
+ 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, { ref: attachmentButton, accessibilityRole: "button", accessibilityLabel: uiText("Attach"), accessibilityState: { expanded: attachmentsOpen }, disabled: busy || blocked, onPress: () => { if (attachmentsOpen) {
912
941
  setAttachmentsOpen(false);
913
942
  input.current?.focus();
914
943
  }
@@ -918,36 +947,37 @@ export function MessagingComposer() {
918
947
  event.preventDefault();
919
948
  if (!blocked)
920
949
  void send();
921
- } } } : {}), 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, onSchedule: state.scheduledMessagesAvailable && !editing && (!!draft.trim() || selected.length > 0) && !voice.hasVoice && !voice.sendVoice ? () => { setScheduleError(''); setScheduleOpen(true); } : undefined, onChoose: emoji => { void client.setQuickMessage(emoji).catch(() => setError('This quick message could not be saved on this device.')); }, onSend: () => { if (voice.sendVoice)
950
+ } } } : {}), onFocus: () => setAttachmentsOpen(false), style: { ...(Platform.OS === 'web' ? { outlineStyle: 'none' } : {}), writingDirection: draft ? 'auto' : uiDirection, textAlign: uiDirection === 'rtl' ? 'right' : 'left', 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: uiText("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, onSchedule: state.scheduledMessagesAvailable && !editing && (!!draft.trim() || selected.length > 0) && !voice.hasVoice && !voice.sendVoice ? () => { setScheduleError(''); setScheduleOpen(true); } : undefined, onChoose: emoji => { void client.setQuickMessage(emoji).catch(() => setError(uiText("This quick message could not be saved on this device."))); }, onSend: () => { if (voice.sendVoice)
922
951
  voice.sendVoice();
923
952
  else
924
953
  void send(); }, onQuickSend: emoji => { void submit({ kind: 'text', text: emoji }).catch(() => { }); } }, state.conversationId)] });
925
954
  }
926
955
  const attachmentActions = [];
927
956
  if (library.image || library.video)
928
- attachmentActions.push({ key: 'library', label: library.image && library.video ? 'Photos & Videos' : library.image ? 'Photos' : 'Videos', icon: 'image', run: () => { void pick(library.image ? 'image' : 'video', undefined, undefined, 'send', true); } });
957
+ attachmentActions.push({ key: 'library', label: library.image && library.video ? uiText("Photos & Videos") : library.image ? uiText("Photos") : uiText("Videos"), icon: 'image', run: () => { void pick(library.image ? 'image' : 'video', undefined, undefined, 'send', true); } });
929
958
  if (camera)
930
- attachmentActions.push({ key: 'camera', label: 'Camera', icon: 'camera', run: () => { void pick(features?.image ? 'image' : 'video', 'camera'); } });
959
+ attachmentActions.push({ key: 'camera', label: uiText("Camera"), icon: 'camera', run: () => { void pick(features?.image ? 'image' : 'video', 'camera'); } });
931
960
  if (library.document)
932
- attachmentActions.push({ key: 'files', label: 'Files', icon: 'document', run: () => { void pick('document'); } });
961
+ attachmentActions.push({ key: 'files', label: uiText("Files"), icon: 'document', run: () => { void pick('document'); } });
933
962
  if (!editing && features?.poll)
934
- attachmentActions.push({ key: 'poll', label: state.conversation?.kind === 'group' ? 'Poll' : 'Yes/No question', icon: 'poll', run: () => setPollOpen(true) });
963
+ attachmentActions.push({ key: 'poll', label: state.conversation?.kind === 'group' ? uiText("Poll") : uiText("Yes/No question"), icon: 'poll', run: () => setPollOpen(true) });
935
964
  if (!editing && weatherAllowed)
936
- attachmentActions.push({ key: 'weather', label: 'Weather', icon: 'weather', run: () => { setBusy(true); setError(''); void extras?.sendWeather().then(() => client.refresh()).catch(e => setError(e instanceof Error ? e.message : 'Weather could not be sent.')).finally(() => setBusy(false)); } });
965
+ attachmentActions.push({ key: 'weather', label: uiText("Weather"), icon: 'weather', run: () => { setBusy(true); setError(''); void extras?.sendWeather().then(() => client.refresh()).catch(e => setError(e instanceof Error ? e.message : uiText("Weather could not be sent."))).finally(() => setBusy(false)); } });
937
966
  if (!enabled || state.conversation?.canSend === false) {
938
- 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 }));
967
+ return (_jsx(Text, { style: { padding: 16, color: c.secondary }, children: state.offline ? uiText("Offline · Recent messages only. Connect to send and load more.") : state.conversation?.safety?.blockedByMe ? uiText("You blocked this contact. Use contact options to unblock.") : state.conversation?.canSend === false ? (state.conversation.kind === 'group' ? uiText('Only members allowed to send can post in this group.') : uiText('Messaging is unavailable for this conversation.')) : design.labels.unavailable }));
939
968
  }
940
- 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, { ref: attachmentButton, 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 && _jsx(AttachmentMenu, { anchor: attachmentButton, accent: c.accent, onClose: () => setAttachmentsOpen(false), actions: attachmentActions }), scheduleOpen && _jsx(ScheduleTimePicker, { initial: scheduleAttempt.current?.time, retryAt: scheduleError ? scheduleAttempt.current?.time : undefined, accent: c.accent, busy: busy, error: scheduleError, onSave: time => void schedule(time), onClose: () => setScheduleOpen(false) }), 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 }))] }));
969
+ 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: uiText('Mention {name}', { name: 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: [uiText("Editing:"), editing.text || editing.media?.fileName] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Cancel editing"), disabled: busy, onPress: () => setEditing(null), style: { minHeight: 36, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: uiText("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: uiText("Cancel quote"), onPress: () => setReply(null), children: _jsx(Text, { style: { color: c.foreground }, children: uiText("Cancel") }) })] })), state.settings?.history?.mode === 'last_two' && _jsx(Text, { style: { color: c.secondary, fontSize: 11, textAlign: 'center' }, children: uiText("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, { ref: attachmentButton, accessibilityRole: "button", accessibilityLabel: uiText("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 && _jsx(AttachmentMenu, { anchor: attachmentButton, accent: c.accent, onClose: () => setAttachmentsOpen(false), actions: attachmentActions }), scheduleOpen && _jsx(ScheduleTimePicker, { initial: scheduleAttempt.current?.time, retryAt: scheduleError ? scheduleAttempt.current?.time : undefined, accent: c.accent, busy: busy, error: scheduleError, onSave: time => void schedule(time), onClose: () => setScheduleOpen(false) }), 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 }))] }));
941
970
  }
942
971
  /** Account navigation. Mount in the inbox/app toolbar, never in a contact menu. */
943
- export function MessagingAccountMenu({ onNewGroup, onMaps } = {}) {
972
+ export function MessagingAccountMenu({ onNewGroup, onMaps, onPublicRooms } = {}) {
973
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
944
974
  const { client, design, setSavedOpen, account, companyName } = useMessagingUI();
945
975
  const [open, setOpen] = useState(false), [accountOpen, setAccountOpen] = useState(false), pending = useRef(null);
946
976
  const button = useRef(null), viewport = useWindowDimensions(), [anchor, setAnchor] = useState({ x: 12, y: 12 });
947
977
  const width = Math.min(218, viewport.width - 24);
948
- 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) - (onMaps ? 44 : 0))) }); setOpen(true); });
978
+ 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) - (onMaps ? 44 : 0) - (onPublicRooms ? 44 : 0))) }); setOpen(true); });
949
979
  const dismissed = () => { const next = pending.current; if (!next)
950
- return; pending.current = null; requestAnimationFrame(() => next === 'saved' ? setSavedOpen(true) : next === 'group' ? onNewGroup?.() : next === 'maps' ? onMaps?.() : setAccountOpen(true)); };
980
+ return; pending.current = null; requestAnimationFrame(() => next === 'saved' ? setSavedOpen(true) : next === 'group' ? onNewGroup?.() : next === 'maps' ? onMaps?.() : next === 'rooms' ? onPublicRooms?.() : setAccountOpen(true)); };
951
981
  useEffect(() => { if (!open && Platform.OS !== 'ios')
952
982
  dismissed(); }, [open]);
953
983
  useEffect(() => { if (!open || Platform.OS !== 'web')
@@ -955,15 +985,26 @@ export function MessagingAccountMenu({ onNewGroup, onMaps } = {}) {
955
985
  event.stopPropagation();
956
986
  setOpen(false);
957
987
  } }; document.addEventListener('keydown', key, true); return () => document.removeEventListener('keydown', key, true); }, [open]);
958
- 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(MessagingMenuSurface, { visible: open, onClose: () => setOpen(false), onDismiss: dismissed, children: rect => _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close messaging menu", onPress: () => setOpen(false), style: { position: 'absolute', inset: 0 } }), _jsx(View, { testID: "messaging-account-dropdown", style: { position: 'absolute', left: Math.max(8, Math.min(anchor.x - rect.left, rect.width - Math.min(width, rect.width - 16) - 8)), top: Math.max(8, Math.min(anchor.y - rect.top, rect.height - (client.hasSavedMessages() ? 116 : 72) - (onNewGroup ? 44 : 0) - (onMaps ? 44 : 0) - 8)), width: Math.min(width, rect.width - 16), maxHeight: Math.max(44, rect.height - 16), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006' }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", 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" })] }), onMaps && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: "Maps", onPress: () => { pending.current = 'maps'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "location", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: "Maps" })] }), 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) })] });
988
+ return _jsxs(_Fragment, { children: [_jsx(Pressable, { ref: button, accessibilityRole: "button", accessibilityLabel: uiText("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(MessagingMenuSurface, { visible: open, onClose: () => setOpen(false), onDismiss: dismissed, children: rect => _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: uiText("Close messaging menu"), onPress: () => setOpen(false), style: { position: 'absolute', inset: 0 } }), _jsx(View, { testID: "messaging-account-dropdown", style: { position: 'absolute', left: Math.max(8, Math.min(anchor.x - rect.left, rect.width - Math.min(width, rect.width - 16) - 8)), top: Math.max(8, Math.min(anchor.y - rect.top, rect.height - (client.hasSavedMessages() ? 116 : 72) - (onNewGroup ? 44 : 0) - (onMaps ? 44 : 0) - (onPublicRooms ? 44 : 0) - 8)), width: Math.min(width, rect.width - 16), maxHeight: Math.max(44, rect.height - 16), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006' }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("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: uiText("Account") })] }), onMaps && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Maps"), onPress: () => { pending.current = 'maps'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "location", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("Maps") })] }), onPublicRooms && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Public groups"), onPress: () => { pending.current = 'rooms'; 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: uiText("Public groups") })] }), onNewGroup && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("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: uiText("New group") })] }), client.hasSavedMessages() && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("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: uiText("Saved") })] })] }) })] }) }), accountOpen && _jsx(MessagingAccountScreen, { account: account, companyName: companyName ?? design.name, onClose: () => setAccountOpen(false) })] });
989
+ }
990
+ function MessagingWaitingChanges() {
991
+ const { state, client, design } = useMessagingUI(), { text: uiText, count } = useRobonoLocale();
992
+ const [open, setOpen] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState('');
993
+ useEffect(() => { setOpen(false); setError(''); }, [state.conversationId]);
994
+ const actions = state.pendingActions ?? [];
995
+ if (!actions.length)
996
+ return null;
997
+ const work = (id, retry) => { setBusy(true); setError(''); void (retry ? client.retryMessageAction(id) : client.discardMessageAction(id)).catch(e => setError(e instanceof Error ? e.message : uiText("This change could not be saved."))).finally(() => setBusy(false)); };
998
+ return _jsxs(_Fragment, { children: [_jsx(Pressable, { accessibilityRole: "button", onPress: () => setOpen(true), style: { minHeight: 44, justifyContent: 'center', paddingHorizontal: 12 }, children: _jsx(Text, { style: { color: design.colors.accent }, children: actions.some(a => !a.retry) ? uiText("A message change needs your attention") : count(actions.length, "{count} change waiting for connection", "{count} changes waiting for connection") }) }), open && _jsxs(MessagingPopup, { title: uiText("Waiting changes"), onClose: () => setOpen(false), foreground: design.colors.foreground, background: chatSurface(design.colors.background), children: [_jsx(ScrollView, { children: actions.map(action => _jsxs(View, { style: { paddingVertical: 12, gap: 8, borderBottomWidth: 1, borderBottomColor: messagingPopupColors.secondary }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontWeight: '600' }, children: action.operation === 'react' ? uiText("Reaction") : action.operation === 'edit' ? uiText("Edit message") : uiText("Delete message") }), _jsx(Text, { numberOfLines: 3, style: { color: messagingPopupColors.foreground }, children: typeof action.input.text === 'string' ? action.input.text : state.messages.find(m => m.id === action.messageId)?.text ?? uiText("Message") }), _jsx(Text, { style: { color: messagingPopupColors.secondary }, children: action.retry ? uiText("Waiting for connection. This change has not been confirmed.") : uiText(action.error ?? "This change could not be saved.") }), _jsxs(View, { style: { flexDirection: 'row', gap: 20 }, children: [!action.retry && _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => work(action.id, true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: uiText("Retry") }) }), _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => work(action.id, false), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: uiText("Discard change") }) })] })] }, action.id)) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground }, children: error })] })] });
959
999
  }
960
1000
  export function MessagingConversation() {
1001
+ const { text: uiText } = useRobonoLocale();
961
1002
  const { state, client, components, design, style } = useMessagingUI();
962
1003
  const Header = components?.Header ?? MessagingHeader, List = components?.List ?? MessagingList, Composer = components?.Composer ?? MessagingComposer;
963
- 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)] }));
1004
+ 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: uiText(state.error) })), _jsx(MessagingWaitingChanges, {}), _jsx(Composer, {}, state.conversationId)] }));
964
1005
  }
965
1006
  /** Embed inside your app's safe area and navigation. No engine, push or global audio side effects. */
966
1007
  export function MessagingScreen(props) {
967
- return (_jsx(MessagingProvider, { ...props, children: _jsx(MessagingConversation, {}) }));
1008
+ return (_jsx(MessagingProvider, { ...props, children: props.renderLayout ? props.renderLayout(_jsx(MessagingConversation, {})) : _jsx(MessagingConversation, {}) }));
968
1009
  }
969
1010
  export { MessagingInbox } from './inbox.js';