@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
@@ -1,16 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createInboxViewMemory } from './inbox-view.js';
3
+ import { MessagingPublicRooms } from './public-rooms.js';
4
+ import { RobonoLocaleProvider, useRobonoLocale } from './localization.js';
2
5
  import { useMessengerMaps, MessengerMapsNotice } from './maps-connection.js';
3
6
  import { MessagingCallingScreen } from './calling.js';
4
7
  import { createExpoMessengerCalling, useExpoMessengerCalling, ExpoMessengerCalls } from './expo-connected-calling.js';
5
8
  import { isNativeCallAudioActive } from '@robono/calling-react-native/native';
6
- import { View } from 'react-native';
9
+ import { View, AppState } from 'react-native';
7
10
  import { createExpoMaps, RobonoMaps } from '@robono/location-react-native/expo';
8
11
  import { createExpoMessagingExtras } from './expo-extras.js';
9
12
  import { connectExpoMessagingNotifications } from './expo-notifications.js';
10
- import { useEffect, useState, useSyncExternalStore } from 'react';
11
- import { createMessagingTransport, defaultMessagingSettings, MessagingInboxClient } from '@robono/messaging';
13
+ import { useMemo, useLayoutEffect, useEffect, useState, useSyncExternalStore } from 'react';
14
+ import { MessagingAvailabilityClient, connectHostedRobono, createMessagingTransport, defaultMessagingSettings, MessagingInboxClient } from '@robono/messaging';
12
15
  import { ExpoMessagingScreen } from './expo-screen.js';
13
- import { createExpoMessagingClient } from './expo-outbox.js';
16
+ import { createExpoMessagingClient, expoOutbox } from './expo-outbox.js';
14
17
  import { createExpoMessagingAudioSession, createExpoMessagingStorage } from './expo-media.js';
15
18
  import { MessagingProvider, MessagingAccountMenu } from './index.js';
16
19
  import { MessagingInbox } from './inbox.js';
@@ -25,8 +28,30 @@ export function createExpoMessenger(options) {
25
28
  const client = createExpoMessagingClient({ ...options, url: endpoint.href });
26
29
  const mapConnection = options.maps === false ? undefined : createExpoMaps(options);
27
30
  const connectedExtras = options.extras === false ? undefined : createExpoMessagingExtras(options);
31
+ const availability = options.availability === false ? undefined : new MessagingAvailabilityClient({ request: createMessagingTransport({ ...options, url: endpoint.href }) });
32
+ const activityChanged = (state) => { if (state === 'active')
33
+ availability?.start();
34
+ else
35
+ void availability?.stop(); };
36
+ const activitySubscription = availability ? AppState.addEventListener('change', activityChanged) : undefined;
37
+ activityChanged(AppState.currentState);
28
38
  const inbox = new MessagingInboxClient({ request: createMessagingTransport({ ...options, url: endpoint.href }) });
29
39
  const storage = createExpoMessagingStorage({ url: media.href, getAccessToken: options.getAccessToken, ...(options.fetch ? { fetch: options.fetch } : {}) });
40
+ const outgoing = expoOutbox(client);
41
+ if (outgoing)
42
+ client.setRecoveryAdapter(worker => {
43
+ const abort = new AbortController(), conversationId = worker.getSnapshot().conversationId;
44
+ const release = worker.setAttachmentPreparer(async (content) => {
45
+ if (!content.media || content.kind === 'text' || content.kind === 'poll')
46
+ throw Error('The saved attachment is unavailable.');
47
+ const file = await outgoing.file(conversationId, content.media.assetId);
48
+ if (!file)
49
+ throw Error('The saved attachment is missing. Remove it and choose it again.');
50
+ const media = await storage.upload(file, { conversationId, uploadId: content.media.assetId, kind: content.kind, signal: abort.signal, onProgress: () => { } });
51
+ return { ...content, media };
52
+ });
53
+ return () => { abort.abort(); release(); };
54
+ });
30
55
  const calling = options.calling === false ? undefined : createExpoMessengerCalling({ ...options, inbox });
31
56
  const audioSession = options.audioSession ?? createExpoMessagingAudioSession({ isCallActive: () => isNativeCallAudioActive() || calling?.isActive() === true || options.isCallActive?.() === true });
32
57
  let connection = null, pending = null, closed = false;
@@ -69,20 +94,71 @@ export function createExpoMessenger(options) {
69
94
  throw Error('Notifications are not connected yet. Check your Apple/Google setup in Robono.'); if (!await connection.enable())
70
95
  throw Error('Allow notifications in your phone’s app settings.'); },
71
96
  };
72
- return { client, inbox, storage, audioSession, notifications, calling, maps: mapConnection?.workspace, ...(connectedExtras ? { extras: connectedExtras.extras, components: { TrackingMap: connectedExtras.TrackingMap } } : {}),
73
- async signOut() { closed = true; const extraCleanup = connectedExtras?.signOut(); inbox.dispose(); const results = await Promise.allSettled([extraCleanup, calling?.dispose(), mapConnection?.signOut(), client.signOut(), (async () => { await pending?.catch(() => { }); await connection?.signOut(); })()]); const failed = results.find(r => r.status === 'rejected'); if (failed?.status === 'rejected')
97
+ return { client, inbox, storage, audioSession, notifications, calling, availability, maps: mapConnection?.workspace, ...(connectedExtras ? { extras: connectedExtras.extras, components: { TrackingMap: connectedExtras.TrackingMap } } : {}),
98
+ async signOut() { if (closed)
99
+ return; closed = true; activitySubscription?.remove(); const extraCleanup = connectedExtras?.signOut(); inbox.dispose(); const results = await Promise.allSettled([availability?.signOut(), extraCleanup, calling?.dispose(), mapConnection?.signOut(), client.signOut(), (async () => { await pending?.catch(() => { }); await connection?.signOut(); })()]); const failed = results.find(r => r.status === 'rejected'); if (failed?.status === 'rejected')
74
100
  throw failed.reason; },
75
- dispose() { closed = true; void calling?.dispose().catch(() => { }); mapConnection?.dispose(); void connectedExtras?.dispose().catch(() => { }); connection?.dispose(); inbox.dispose(); client.dispose(); },
101
+ dispose() { if (closed)
102
+ return; closed = true; activitySubscription?.remove(); availability?.dispose(); void calling?.dispose().catch(() => { }); mapConnection?.dispose(); void connectedExtras?.dispose().catch(() => { }); connection?.dispose(); inbox.dispose(); client.dispose(); },
103
+ };
104
+ }
105
+ export async function createHostedExpoMessenger(options) {
106
+ const session = await connectHostedRobono(options);
107
+ if (options.signal?.aborted) {
108
+ await session.signOut();
109
+ throw Error('The messenger was closed.');
110
+ }
111
+ let connection;
112
+ try {
113
+ connection = createExpoMessenger({ ...options, endpoint: session.endpoint, getAccessToken: session.getAccessToken, namespace: session.namespace });
114
+ }
115
+ catch (error) {
116
+ await session.signOut().catch(() => { });
117
+ throw error;
118
+ }
119
+ let signingOut;
120
+ return { ...connection,
121
+ signOut() { return signingOut ??= (async () => { try {
122
+ await connection.signOut();
123
+ }
124
+ finally {
125
+ await session.signOut();
126
+ } })(); },
127
+ dispose() { if (!signingOut) {
128
+ connection.dispose();
129
+ session.dispose();
130
+ } },
76
131
  };
77
132
  }
78
133
  /** Supplied inbox, group creation, conversation, settings and media screens. */
79
- export function ExpoMessenger({ inbox, contacts = {}, initialConversation, selectedConversation, onSelectConversation, notifications, maps, calling, ...props }) {
134
+ export function ExpoMessenger(props) {
135
+ return _jsx(RobonoLocaleProvider, { value: props.interfaceLocale, children: _jsx(ExpoMessengerContent, { ...props }) });
136
+ }
137
+ function ExpoMessengerContent({ inbox, contacts = {}, initialConversation, selectedConversation, onSelectConversation, notifications, maps, calling, ...props }) {
138
+ const inboxMemory = useMemo(createInboxViewMemory, [inbox, props.userId]);
139
+ const { configuration: mapsLocale } = useRobonoLocale();
80
140
  const callState = useExpoMessengerCalling(calling);
81
141
  useEffect(() => { calling?.updateContacts(contacts); }, [calling, contacts]);
82
142
  const chatState = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
83
143
  const groupAllowed = chatState.conversation?.kind !== 'group' || !chatState.conversation.supergroup && (chatState.conversation.memberCount ?? 0) >= 2 && (chatState.conversation.memberCount ?? 0) <= 8;
84
- const [internal, setInternal] = useState(initialConversation ?? null), [newGroup, setNewGroup] = useState(false);
144
+ const [internal, setInternal] = useState(initialConversation ?? null), [newGroup, setNewGroup] = useState(false), [publicRooms, setPublicRooms] = useState(false);
85
145
  const selected = selectedConversation === undefined ? internal : selectedConversation;
146
+ // Declarative navigation loads the selected conversation itself. Internal open()
147
+ // changes the client synchronously, so this never opens it a second time.
148
+ useLayoutEffect(() => { if (!selected) {
149
+ props.client.stop();
150
+ return;
151
+ } if (props.client.getSnapshot().conversationId !== selected.id)
152
+ void props.client.open(selected.id).catch(() => { }); }, [selected?.id, props.client]);
153
+ useEffect(() => {
154
+ const sync = (state) => { if (selected && props.active !== false && (state === null || state === 'active'))
155
+ props.client.start();
156
+ else
157
+ props.client.stop(); };
158
+ sync(AppState.currentState);
159
+ const subscription = AppState.addEventListener('change', sync);
160
+ return () => { subscription.remove(); props.client.stop(); };
161
+ }, [selected?.id, props.client, props.active]);
86
162
  const autoCalls = calling && selected && groupAllowed ? { ...(callState.features.voice ? { voice: () => calling.call(selected.id, 'voice', selected.kind === 'group') } : {}), ...(callState.features.video ? { video: () => calling.call(selected.id, 'video', selected.kind === 'group') } : {}), voiceLabel: 'Internet call' } : undefined;
87
163
  const setSelected = (row) => { setInternal(row); onSelectConversation?.(row); };
88
164
  const [showMaps, setShowMaps] = useState(false), mapConnection = useMessengerMaps(maps), mapsAvailable = mapConnection.available;
@@ -97,5 +173,5 @@ export function ExpoMessenger({ inbox, contacts = {}, initialConversation, selec
97
173
  open(row, message); }).catch(() => { }); }, [notifications, inbox]);
98
174
  const account = notifications ? { name: props.account?.name ?? 'Your account', ...props.account, onEnableNotifications: () => notifications.enable() } : props.account;
99
175
  const contact = selected?.kind === 'group' ? { name: selected.name ?? 'Group' } : contacts[selected?.contactId ?? ''] ?? { name: selected?.contactId ?? 'Chat' };
100
- return _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(MessengerMapsNotice, { error: mapConnection.error, retry: mapConnection.retry }), showMaps && maps ? _jsx(RobonoMaps, { workspace: maps, contacts: contacts, onClose: () => setShowMaps(false) }) : _jsx(ExpoMessagingScreen, { ...props, account: account, contact: contact, participants: props.participants ?? contacts, extras: selected ? props.extras : undefined, callActions: selected ? { ...autoCalls, ...props.callActions, phoneNumber: contacts[selected.contactId ?? '']?.phoneNumber ?? props.callActions?.phoneNumber } : undefined, onBack: () => { props.client.stop(); setSelected(null); void inbox.refresh().catch(() => { }); }, renderConversation: screen => selected ? _jsx(MessagingCallingScreen, { ...screen, onCall: screen.callActions?.voice, onVideoCall: screen.callActions?.video, callLabel: "Internet call", unansweredCallId: callState.unanswered?.conversationId === selected.id ? callState.unanswered.id : undefined, onDismissCallMessage: calling?.dismissUnanswered }) : _jsxs(MessagingProvider, { ...screen, children: [_jsx(MessagingInbox, { client: inbox, userId: props.userId, contacts: contacts, companyName: props.companyName, appearance: props.appearance, active: props.active !== false, headerActions: _jsx(MessagingAccountMenu, { onMaps: mapsAvailable ? () => setShowMaps(true) : undefined, onNewGroup: inboxState.groupCreationAvailable ? () => setNewGroup(true) : undefined }), onSelect: open, onDeleted: id => props.client.forgetConversation(id) }), newGroup && _jsx(MessagingGroupEditor, { client: props.client, inbox: inbox, userId: props.userId, contacts: contacts, groups: inboxState.groups, accent: (props.appearance ?? inboxState.appearance ?? defaultMessagingSettings().appearance).colors.accent, onClose: () => setNewGroup(false), onSaved: (id, name) => { setNewGroup(false); const row = inbox.getSnapshot().conversations.find(c => c.id === id) ?? { id, kind: 'group', name, contactId: null, lastMessage: null, unreadCount: 0, canSend: true }; open(row); } })] }) }), _jsx(ExpoMessengerCalls, { calling: calling, state: callState })] });
176
+ return _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(MessengerMapsNotice, { error: mapConnection.error, retry: mapConnection.retry }), showMaps && maps ? _jsx(RobonoMaps, { interfaceLocale: mapsLocale, workspace: maps, contacts: contacts, onClose: () => setShowMaps(false) }) : _jsx(ExpoMessagingScreen, { ...props, account: account, contact: contact, participants: props.participants ?? contacts, extras: selected ? props.extras : undefined, callActions: selected ? { ...autoCalls, ...props.callActions, phoneNumber: contacts[selected.contactId ?? '']?.phoneNumber ?? props.callActions?.phoneNumber } : undefined, onBack: () => { props.client.stop(); setSelected(null); void inbox.refresh().catch(() => { }); }, renderConversation: screen => selected ? _jsx(MessagingCallingScreen, { ...screen, onCall: screen.callActions?.voice, onVideoCall: screen.callActions?.video, callLabel: "Internet call", unansweredCallId: callState.unanswered?.conversationId === selected.id ? callState.unanswered.id : undefined, onDismissCallMessage: calling?.dismissUnanswered }) : _jsxs(MessagingProvider, { ...screen, children: [_jsx(MessagingInbox, { viewMemory: inboxMemory, client: inbox, userId: props.userId, contacts: contacts, companyName: props.companyName, appearance: props.appearance, active: props.active !== false, headerActions: _jsx(MessagingAccountMenu, { onPublicRooms: inboxState.publicRoomsAvailable ? () => setPublicRooms(true) : undefined, onMaps: mapsAvailable ? () => setShowMaps(true) : undefined, onNewGroup: inboxState.groupCreationAvailable ? () => setNewGroup(true) : undefined }), onSelect: open, onDeleted: id => props.client.forgetConversation(id) }), publicRooms && _jsx(MessagingPublicRooms, { inbox: inbox, accent: (props.appearance ?? inboxState.appearance ?? defaultMessagingSettings().appearance).colors.accent, createRequestId: () => props.client.createForwardId(), onClose: () => setPublicRooms(false), onJoined: (id, name) => { setPublicRooms(false); open(inbox.getSnapshot().conversations.find(c => c.id === id) ?? { id, kind: 'group', name, contactId: null, lastMessage: null, unreadCount: 0, canSend: true }); } }), newGroup && _jsx(MessagingGroupEditor, { client: props.client, inbox: inbox, userId: props.userId, contacts: contacts, groups: inboxState.groups, accent: (props.appearance ?? inboxState.appearance ?? defaultMessagingSettings().appearance).colors.accent, onClose: () => setNewGroup(false), onSaved: (id, name) => { setNewGroup(false); const row = inbox.getSnapshot().conversations.find(c => c.id === id) ?? { id, kind: 'group', name, contactId: null, lastMessage: null, unreadCount: 0, canSend: true }; open(row); } })] }) }), _jsx(ExpoMessengerCalls, { calling: calling, state: callState })] });
101
177
  }
@@ -12,6 +12,27 @@ const root = async (scope) => { if (!FS.documentDirectory)
12
12
  const folder = async (scope, cid) => (await root(scope)) + await digest(cid) + '/';
13
13
  const asset = async (scope, cid, id) => (await folder(scope, cid)) + 'file-' + await digest(id);
14
14
  const files = {
15
+ async conversations(scope) {
16
+ const dir = await root(scope);
17
+ if (!(await FS.getInfoAsync(dir)).exists)
18
+ return [];
19
+ const ids = [];
20
+ for (const folderName of await FS.readDirectoryAsync(dir)) {
21
+ if (!/^[a-f0-9]{64}$/.test(folderName))
22
+ continue;
23
+ const folder = dir + folderName + '/';
24
+ const names = (await FS.readDirectoryAsync(folder)).filter(name => /^state-\d+\.json$/.test(name)).sort((a, b) => Number(b.slice(6, -5)) - Number(a.slice(6, -5)));
25
+ for (const name of names)
26
+ try {
27
+ const state = JSON.parse(await FS.readAsStringAsync(folder + name));
28
+ if (typeof state.conversationId === 'string' && Array.isArray(state.messages) && (state.messages.some((m) => m.retry) || Array.isArray(state.actions) && state.actions[0]?.retry))
29
+ ids.push(state.conversationId);
30
+ break;
31
+ }
32
+ catch { }
33
+ }
34
+ return [...new Set(ids)];
35
+ },
15
36
  async read(scope, cid) {
16
37
  const dir = await folder(scope, cid);
17
38
  if (!(await FS.getInfoAsync(dir)).exists)
@@ -35,7 +56,7 @@ const files = {
35
56
  const names = (await FS.readDirectoryAsync(dir)).filter(name => /^state-\d+\.json$/.test(name)).sort((a, b) => Number(b.slice(6, -5)) - Number(a.slice(6, -5)));
36
57
  const sequence = names.length ? Number(names[0].slice(6, -5)) + 1 : 1, temp = dir + 'pending-' + Crypto.randomUUID() + '.tmp';
37
58
  try {
38
- await FS.writeAsStringAsync(temp, JSON.stringify(state));
59
+ await FS.writeAsStringAsync(temp, JSON.stringify({ ...state, conversationId: cid }));
39
60
  await FS.moveAsync({ from: temp, to: dir + 'state-' + sequence + '.json' });
40
61
  }
41
62
  finally {
@@ -39,4 +39,4 @@ export interface ExpoMessagingScreenProps extends Omit<MessagingScreenProps, 'pi
39
39
  resolveAttachmentChoice?: (id: string, signal: AbortSignal) => Promise<LocalMessagingMedia | null>;
40
40
  }
41
41
  /** Optional ready-made recording, preview, upload and download tools for both mobile platforms. */
42
- export declare function ExpoMessagingScreen({ storage, audioSession, renderConversation, preloadRecentMedia, ...props }: ExpoMessagingScreenProps): React.JSX.Element;
42
+ export declare function ExpoMessagingScreen(props: ExpoMessagingScreenProps): React.JSX.Element;
@@ -1,4 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { MessagingTypography } from './typography.js';
3
+ import { Text } from './typography.js';
4
+ import { RobonoLocaleProvider } from './localization.js';
5
+ import { useRobonoLocale } from './localization.js';
2
6
  import * as Haptics from 'expo-haptics';
3
7
  import { Vibration } from 'react-native';
4
8
  import { MessagingPopupHeader, messagingPopupBackdrop } from './popup.js';
@@ -9,7 +13,7 @@ import { expoSaved } from './expo-saved.js';
9
13
  export { createExpoMessagingClient } from './expo-outbox.js';
10
14
  import { createExpoThumbnails } from './expo-thumbnails.js';
11
15
  import { useCallback, useEffect, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
12
- import { AppState, Image, Modal, Platform, Pressable, ScrollView, Text, View } from 'react-native';
16
+ import { AppState, Image, Modal, Platform, Pressable, ScrollView, View } from 'react-native';
13
17
  import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
14
18
  import MaterialIcons from "./visual/RobonoIcon";
15
19
  import { useAudioPlayer } from 'expo-audio';
@@ -68,6 +72,7 @@ async function acquireVoice(scope, audio) {
68
72
  return scope.alive && scope.epoch === epoch && !!scope.release;
69
73
  }
70
74
  function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
75
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
71
76
  const [playing, setPlaying] = useState(false), [position, setPosition] = useState(0), [error, setError] = useState('');
72
77
  const source = useMemo(() => file.stream ? { uri: file.uri, headers: file.stream.headers } : file.uri, [file]);
73
78
  const player = useAudioPlayer(source, { updateInterval: 100, downloadFirst: false, preferredForwardBufferDuration: 5, keepAudioSessionActive: true });
@@ -110,7 +115,7 @@ function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
110
115
  }
111
116
  catch { } await releaseVoice(scope); await scope.acquiring?.catch(() => { }); await releaseVoice(scope); };
112
117
  const fail = (e) => { cancelled = true; autoPreparing = false; scope.busy = false; clearTimeout(startupTimeout); if (scope.alive)
113
- setError(e instanceof Error ? e.message : 'Playback failed.'); scope.epoch++; void releaseVoice(scope); };
118
+ setError(e instanceof Error ? e.message : uiText("Playback failed.")); scope.epoch++; void releaseVoice(scope); };
114
119
  // Native file loading and audio-session preparation are independent. Start
115
120
  // both now; playback still requires both and an active app.
116
121
  const begin = () => {
@@ -217,7 +222,7 @@ function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
217
222
  }
218
223
  catch (e) {
219
224
  if (scope.alive)
220
- setError(e instanceof Error ? e.message : 'Playback failed.');
225
+ setError(e instanceof Error ? e.message : uiText("Playback failed."));
221
226
  await releaseVoice(scope);
222
227
  }
223
228
  finally {
@@ -230,9 +235,10 @@ function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
230
235
  if (draft)
231
236
  return _jsx(VoiceDraftPlayback, { compact: draft.compact, appearance: design, playing: playing, positionMs: position, durationMs: duration, waveform: draft.waveform, onToggle: () => void toggle(), onRemove: draft.onRemove, disabled: draft.disabled, error: error });
232
237
  return _jsx(VoicePlayback, { mine: inline?.mine, heard: inline?.heard, onPlayed: inline?.onPlayed, background: inline?.background, ink: inline?.ink ?? c.foreground, accent: inline?.accent ?? c.accent, waveform: inline?.waveform, showWaveform: inline?.showWaveform, footer: inline?.footer, onLongPress: inline?.onLongPress, durationMs: duration, positionMs: position, playing: playing, loading: !!file.stream && !loaded && !error, error: error, onToggle: () => void toggle(), onSeek: ms => { lastPosition.current = Math.max(0, Math.min(duration, ms)); void player.seekTo(lastPosition.current / 1000).catch(() => { if (!recoverStream())
233
- setError('Could not seek. Try again.'); }); } });
238
+ setError(uiText("Could not seek. Try again.")); }); } });
234
239
  }
235
240
  function VideoPlayer({ file, audio }) {
241
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
236
242
  const mediaReady = useContext(MediaReadiness), recover = useContext(StreamRecovery);
237
243
  const source = useMemo(() => file.stream ? { uri: file.uri, headers: file.stream.headers, contentType: 'progressive', useCaching: false } : file.uri, [file]);
238
244
  const player = useVideoPlayer(source, p => { p.loop = false; p.audioMixingMode = 'mixWithOthers'; if (file.stream)
@@ -271,7 +277,7 @@ function VideoPlayer({ file, audio }) {
271
277
  desired.current = false;
272
278
  player.pause();
273
279
  setReady(false);
274
- setError('Playback paused for a call. Close and reopen the video to continue.');
280
+ setError(uiText("Playback paused for a call. Close and reopen the video to continue."));
275
281
  void release?.().catch(() => { });
276
282
  release = undefined;
277
283
  } }, 100);
@@ -287,14 +293,14 @@ function VideoPlayer({ file, audio }) {
287
293
  });
288
294
  const listener = player.addListener('statusChange', s => { if (s.status === 'error') {
289
295
  if (!recoverStream())
290
- setError('This video could not be played on this phone.');
296
+ setError(uiText("This video could not be played on this phone."));
291
297
  }
292
298
  else
293
299
  begin(); });
294
300
  if (player.status === 'error')
295
301
  recoverStream();
296
302
  const timeout = setTimeout(() => { if (alive && desired.current && player.currentTime === 0 && player.status !== 'readyToPlay' && !recoverStream())
297
- setError('This video could not be loaded. Try again.'); }, 15000);
303
+ setError(uiText("This video could not be loaded. Try again.")); }, 15000);
298
304
  const app = AppState.addEventListener('change', s => { if (s !== 'active') {
299
305
  desired.current = false;
300
306
  player.pause();
@@ -307,25 +313,27 @@ function VideoPlayer({ file, audio }) {
307
313
  return _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(MessengerVideoView, { testID: "messenger-video-player", onFirstFrameRender: () => mediaReady('video-frame', file.uri), player: player, nativeControls: ready, style: { height: '100%', width: '100%' } }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { position: 'absolute', bottom: 12, left: 12, right: 12, color: '#fff', backgroundColor: '#000c', padding: 12 }, children: error })] });
308
314
  }
309
315
  function FileViewer({ file, kind, audio, design }) {
316
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
310
317
  const [error, setError] = useState('');
311
318
  if (kind === 'voice')
312
319
  return _jsx(VoicePlayer, { file: file, audio: audio, design: design });
313
320
  if (kind === 'video')
314
321
  return _jsx(VideoPlayer, { file: file, audio: audio, design: design });
315
322
  if (kind === 'image')
316
- return _jsxs(View, { children: [_jsx(Image, { source: { uri: file.uri }, resizeMode: "contain", accessibilityLabel: file.fileName, onError: () => setError('This image could not be displayed on this phone.'), style: { height: 300, width: '100%' } }), !!error && _jsx(Text, { style: { color: design.colors.foreground }, children: error })] });
317
- return _jsxs(View, { children: [_jsx(Control, { label: "File options", icon: "open-in-new", color: design.colors.accent, onPress: () => {
323
+ return _jsxs(View, { children: [_jsx(Image, { source: { uri: file.uri }, resizeMode: "contain", accessibilityLabel: file.fileName, onError: () => setError(uiText("This image could not be displayed on this phone.")), style: { height: 300, width: '100%' } }), !!error && _jsx(Text, { style: { color: design.colors.foreground }, children: error })] });
324
+ return _jsxs(View, { children: [_jsx(Control, { label: uiText("File options"), icon: "open-in-new", color: design.colors.accent, onPress: () => {
318
325
  void (async () => { try {
319
326
  if (!await Sharing.isAvailableAsync())
320
327
  throw Error('No file-opening menu is available.');
321
328
  await Sharing.shareAsync(file.uri, { mimeType: file.mimeType, dialogTitle: file.fileName });
322
329
  }
323
330
  catch (e) {
324
- setError(e instanceof Error ? e.message : 'The file could not be opened.');
331
+ setError(e instanceof Error ? e.message : uiText("The file could not be opened."));
325
332
  } })();
326
333
  } }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: design.colors.foreground }, children: error })] });
327
334
  }
328
335
  export function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice = false, inlineRecorder = false, viewerActions, onDismiss }) {
336
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
329
337
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
330
338
  const c = appearance.colors;
331
339
  const mediaReady = useContext(MediaReadiness);
@@ -338,24 +346,27 @@ export function MessagingMediaOverlay({ controller, audio, appearance, inlineVoi
338
346
  return null;
339
347
  if ((typeof inlineVoice === 'function' ? inlineVoice() : inlineVoice) && state.kind === 'voice' && ['opening', 'viewing', 'failed'].includes(state.phase) && !review)
340
348
  return null;
341
- const viewer = state.file && (review || state.phase === 'viewing') ? _jsx(MediaViewer, { expanded: expanded, onExpandedChange: setExpanded, kind: state.kind, uri: state.file.uri, title: review ? 'Preview attachment' : state.kind === 'image' ? 'Photo' : state.kind === 'video' ? 'Video' : state.kind === 'voice' ? 'Voice message' : 'File', subtitle: state.file.fileName, fileName: state.file.fileName, byteSize: state.file.byteSize, error: state.error ?? fileError, onClose: () => { setFileError(''); void controller.cancel(); }, reviewAction: review ? (state.phase === 'failed' ? 'Retry upload' : 'Add attachment') : undefined, onReview: review ? () => void controller.send() : undefined, ...(!review ? viewerActions?.() : {}), downloading: downloading, onDownload: !review && appearance.showDownloadButton === true && ['image', 'video', 'document'].includes(state.kind) ? () => { if (saving.current || !state.file)
342
- return; saving.current = true; setDownloading(true); setFileError(''); void controller.exportFile(async (file) => { await saveMessagingAttachment(file, state.kind); mediaReady('file-opened', state.file.uri); }).catch(e => setFileError(e instanceof Error ? e.message : 'Could not save the attachment.')).finally(() => { saving.current = false; setDownloading(false); }); } : undefined, onShare: !review ? () => { void controller.exportFile(async (file) => { await Sharing.shareAsync(file.uri, { mimeType: file.mimeType, dialogTitle: file.fileName }); mediaReady('file-opened', state.file.uri); }).catch(() => setFileError('Could not share this attachment.')); } : undefined, onFileAction: () => { void (async () => { try {
349
+ const viewer = state.file && (review || state.phase === 'viewing') ? _jsx(MediaViewer, { expanded: expanded, onExpandedChange: setExpanded, kind: state.kind, uri: state.file.uri, title: review ? uiText("Preview attachment") : state.kind === 'image' ? uiText("Photo") : state.kind === 'video' ? uiText("Video") : state.kind === 'voice' ? uiText("Voice message") : uiText("File"), subtitle: state.file.fileName, fileName: state.file.fileName, byteSize: state.file.byteSize, error: state.error ?? fileError, onClose: () => { setFileError(''); void controller.cancel(); }, reviewAction: review ? (state.phase === 'failed' ? 'Retry upload' : 'Add attachment') : undefined, onReview: review ? () => void controller.send() : undefined, ...(!review ? viewerActions?.() : {}), downloading: downloading, onDownload: !review && appearance.showDownloadButton === true && ['image', 'video', 'document'].includes(state.kind) ? () => { if (saving.current || !state.file)
350
+ return; saving.current = true; setDownloading(true); setFileError(''); void controller.exportFile(async (file) => { await saveMessagingAttachment(file, state.kind); mediaReady('file-opened', state.file.uri); }).catch(e => setFileError(e instanceof Error ? e.message : uiText("Could not save the attachment."))).finally(() => { saving.current = false; setDownloading(false); }); } : undefined, onShare: !review ? () => { void controller.exportFile(async (file) => { await Sharing.shareAsync(file.uri, { mimeType: file.mimeType, dialogTitle: file.fileName }); mediaReady('file-opened', state.file.uri); }).catch(() => setFileError(uiText("Could not share this attachment."))); } : undefined, onFileAction: () => { void (async () => { try {
343
351
  if (!await Sharing.isAvailableAsync())
344
352
  throw Error('File opening is unavailable.');
345
353
  await Sharing.shareAsync(state.file.uri, { mimeType: state.file.mimeType, dialogTitle: state.file.fileName });
346
354
  mediaReady('file-opened', state.file.uri);
347
355
  }
348
356
  catch (e) {
349
- setFileError(e instanceof Error ? e.message : 'Could not open the file.');
357
+ setFileError(e instanceof Error ? e.message : uiText("Could not open the file."));
350
358
  } })(); }, children: state.kind === 'video' ? _jsx(VideoPlayer, { file: state.file, audio: audio, design: appearance }, state.file.uri) : state.kind === 'voice' ? _jsx(View, { style: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 }, children: _jsx(VoicePlayer, { file: state.file, audio: audio, design: { ...appearance, colors: { ...appearance.colors, foreground: '#ffffff', accent: '#8ab4f8' } } }) }) : null }) : null;
351
359
  // A native modal has its own window; measure its safe area inside that window.
352
360
  return _jsx(Modal, { transparent: true, visible: state.phase !== 'idle' && state.phase !== 'choosing', onDismiss: onDismiss, animationType: "fade", onRequestClose: () => { if (expanded)
353
361
  setExpanded(false);
354
362
  else
355
- void controller.cancel(); }, children: _jsx(SafeAreaProvider, { style: { flex: 1, backgroundColor: state.kind === 'image' || state.kind === 'video' ? 'rgba(0,0,0,0.94)' : messagingPopupBackdrop }, children: _jsx(SafeAreaView, { edges: expanded ? [] : ['top', 'bottom', 'left', 'right'], style: { flex: 1 }, children: _jsx(View, { style: { flex: 1, margin: state.kind === 'image' || state.kind === 'video' ? 0 : 8, borderRadius: state.kind === 'image' || state.kind === 'video' ? 0 : 18, overflow: 'hidden', backgroundColor: state.kind === 'image' || state.kind === 'video' ? 'transparent' : '#000000' }, children: viewer ?? (_jsx(View, { style: { flex: 1, backgroundColor: c.background }, children: _jsxs(ScrollView, { contentContainerStyle: { padding: 8, paddingTop: 0, gap: 12, flexGrow: 1 }, children: [_jsx(MessagingPopupHeader, { title: state.kind === 'voice' ? 'Voice message' : state.kind === 'image' ? 'Photo' : state.kind === 'video' ? 'Video' : 'File', foreground: c.foreground, closeLabel: "Close attachment", onClose: () => void controller.cancel() }), state.phase === 'recording' ? _jsxs(View, { style: { alignItems: 'center', gap: 20, paddingVertical: 32 }, children: [_jsx(MaterialIcons, { name: "mic", size: 48, color: c.accent }), _jsx(Text, { style: { color: c.foreground, fontSize: 32, fontVariant: ['tabular-nums'] }, children: time(state.elapsedMs) }), _jsxs(Text, { style: { color: c.secondary }, children: [time(Math.max(0, state.maxDurationMs - state.elapsedMs)), " remaining"] }), _jsx(Control, { label: "Stop recording", icon: "stop", color: c.accent, onPress: () => void controller.finishRecording() })] }) : null, ['choosing', 'preparing', 'uploading', 'opening'].includes(state.phase) && _jsx(Text, { accessibilityRole: "progressbar", accessibilityValue: { min: 0, max: 100, now: Math.round(state.progress * 100) }, style: { color: c.foreground, fontSize: 18 }, children: state.phase === 'uploading' ? (state.processing ? 'Checking attachment… You can cancel.' : `Uploading… ${Math.round(state.progress * 100)}%`) : state.phase === 'opening' ? (state.progress > 0 ? `Opening… ${Math.round(state.progress * 100)}%` : 'Opening…') : state.phase === 'choosing' ? 'Choose an attachment.' : 'Preparing recording…' }), state.file && _jsxs(_Fragment, { children: [_jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: state.file.fileName }), _jsx(Text, { style: { color: c.secondary }, children: size(state.file.byteSize) }), (review || state.phase === 'viewing') && _jsx(FileViewer, { file: state.file, kind: state.kind, audio: audio, design: appearance }, state.file.uri)] }), state.error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: state.error }), review && (state.phase === 'review' || state.retryUpload) && _jsx(Control, { label: state.phase === 'failed' ? 'Retry upload' : 'Add attachment', icon: state.phase === 'failed' ? 'refresh' : 'send', color: c.accent, onPress: () => void controller.send() })] }) })) }) }) }) });
363
+ void controller.cancel(); }, children: _jsx(SafeAreaProvider, { style: { flex: 1, backgroundColor: state.kind === 'image' || state.kind === 'video' ? 'rgba(0,0,0,0.94)' : messagingPopupBackdrop }, children: _jsx(SafeAreaView, { edges: expanded ? [] : ['top', 'bottom', 'left', 'right'], style: { flex: 1 }, children: _jsx(View, { style: { flex: 1, margin: state.kind === 'image' || state.kind === 'video' ? 0 : 8, borderRadius: state.kind === 'image' || state.kind === 'video' ? 0 : 18, overflow: 'hidden', backgroundColor: state.kind === 'image' || state.kind === 'video' ? 'transparent' : '#000000' }, children: viewer ?? (_jsx(View, { style: { flex: 1, backgroundColor: c.background }, children: _jsxs(ScrollView, { contentContainerStyle: { padding: 8, paddingTop: 0, gap: 12, flexGrow: 1 }, children: [_jsx(MessagingPopupHeader, { title: state.kind === 'voice' ? uiText("Voice message") : state.kind === 'image' ? uiText("Photo") : state.kind === 'video' ? uiText("Video") : uiText("File"), foreground: c.foreground, closeLabel: uiText("Close attachment"), onClose: () => void controller.cancel() }), state.phase === 'recording' ? _jsxs(View, { style: { alignItems: 'center', gap: 20, paddingVertical: 32 }, children: [_jsx(MaterialIcons, { name: "mic", size: 48, color: c.accent }), _jsx(Text, { style: { color: c.foreground, fontSize: 32, fontVariant: ['tabular-nums'] }, children: time(state.elapsedMs) }), _jsx(Text, { style: { color: c.secondary }, children: uiText('{time} remaining', { time: time(Math.max(0, state.maxDurationMs - state.elapsedMs)) }) }), _jsx(Control, { label: uiText("Stop recording"), icon: "stop", color: c.accent, onPress: () => void controller.finishRecording() })] }) : null, ['choosing', 'preparing', 'uploading', 'opening'].includes(state.phase) && _jsx(Text, { accessibilityRole: "progressbar", accessibilityValue: { min: 0, max: 100, now: Math.round(state.progress * 100) }, style: { color: c.foreground, fontSize: 18 }, children: state.phase === 'uploading' ? (state.processing ? uiText('Checking attachment… You can cancel.') : uiText('Uploading… {progress}%', { progress: Math.round(state.progress * 100) })) : state.phase === 'opening' ? (state.progress > 0 ? uiText('Opening… {progress}%', { progress: Math.round(state.progress * 100) }) : uiText('Opening…')) : state.phase === 'choosing' ? uiText("Choose an attachment.") : uiText("Preparing recording…") }), state.file && _jsxs(_Fragment, { children: [_jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: state.file.fileName }), _jsx(Text, { style: { color: c.secondary }, children: size(state.file.byteSize) }), (review || state.phase === 'viewing') && _jsx(FileViewer, { file: state.file, kind: state.kind, audio: audio, design: appearance }, state.file.uri)] }), state.error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: state.error }), review && (state.phase === 'review' || state.retryUpload) && _jsx(Control, { label: state.phase === 'failed' ? uiText("Retry upload") : uiText("Add attachment"), icon: state.phase === 'failed' ? 'refresh' : 'send', color: c.accent, onPress: () => void controller.send() })] }) })) }) }) }) });
356
364
  }
357
365
  /** Optional ready-made recording, preview, upload and download tools for both mobile platforms. */
358
- export function ExpoMessagingScreen({ storage, audioSession, renderConversation, preloadRecentMedia = true, ...props }) {
366
+ export function ExpoMessagingScreen(props) {
367
+ return _jsx(RobonoLocaleProvider, { value: props.interfaceLocale, children: _jsx(ExpoMessagingScreenContent, { ...props }) });
368
+ }
369
+ function ExpoMessagingScreenContent({ storage, audioSession, renderConversation, preloadRecentMedia = true, ...props }) {
359
370
  useEffect(() => { void props.client.reportUsage({ platform: Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'other', components: ['media-tools'] }).catch(() => { }); }, [props.client]);
360
371
  const [requestedMessageAction, setRequestedMessageAction] = useState();
361
372
  const afterViewer = useRef(undefined);
@@ -545,20 +556,20 @@ export function ExpoMessagingScreen({ storage, audioSession, renderConversation,
545
556
  current.current.onMediaReady?.({ messageId: event.messageId, kind: event.kind, stage, elapsedMs: Date.now() - event.started });
546
557
  }
547
558
  catch { } };
548
- return _jsx(StreamRecovery.Provider, { value: (uri, position, playing) => { void controller.fallbackStream(uri, position, playing); }, children: _jsxs(MediaReadiness.Provider, { value: mediaReady, children: [renderConversation ? renderConversation(screenProps) : _jsx(MessagingScreen, { ...screenProps }), _jsx(MessagingMediaOverlay, { onDismiss: viewerDismissed, viewerActions: () => {
549
- const m = message.current;
550
- if (!m || !state.messages.some(row => row.id === m.id && !row.deleted))
551
- return {};
552
- const action = (kind) => {
553
- afterViewer.current = { kind, messageId: m.id, sequence: Date.now() };
554
- void controller.cancel().then(() => {
555
- // Some installed native runtimes omit onDismiss after a media modal closes.
556
- // Keep the normal event fast path; recover only after the native fade ends.
557
- // Navigation/unmount clears the queued action, so it cannot open in another chat.
558
- if (afterViewer.current)
559
- dismissFallback.current = setTimeout(viewerDismissed, Platform.OS === 'ios' ? 500 : 250);
560
- });
561
- };
562
- return { onForward: props.client.canForward(m) ? () => action('forward') : undefined, onDelete: m.senderId === props.userId && !m.readAt && state.messageActionsAvailable ? () => action('delete') : undefined };
563
- }, inlineVoice: () => !!message.current, inlineRecorder: () => !!message.current && !!current.current.client.getSnapshot().settings?.features.voice, controller: controller, audio: audioSession, appearance: props.appearance ?? state.settings?.appearance ?? defaultMessagingSettings().appearance })] }) });
559
+ return _jsx(MessagingTypography, { appearance: props.appearance ?? state.settings?.appearance ?? defaultMessagingSettings().appearance, children: _jsx(StreamRecovery.Provider, { value: (uri, position, playing) => { void controller.fallbackStream(uri, position, playing); }, children: _jsxs(MediaReadiness.Provider, { value: mediaReady, children: [renderConversation ? renderConversation(screenProps) : _jsx(MessagingScreen, { ...screenProps }), _jsx(MessagingMediaOverlay, { onDismiss: viewerDismissed, viewerActions: () => {
560
+ const m = message.current;
561
+ if (!m || !state.messages.some(row => row.id === m.id && !row.deleted))
562
+ return {};
563
+ const action = (kind) => {
564
+ afterViewer.current = { kind, messageId: m.id, sequence: Date.now() };
565
+ void controller.cancel().then(() => {
566
+ // Some installed native runtimes omit onDismiss after a media modal closes.
567
+ // Keep the normal event fast path; recover only after the native fade ends.
568
+ // Navigation/unmount clears the queued action, so it cannot open in another chat.
569
+ if (afterViewer.current)
570
+ dismissFallback.current = setTimeout(viewerDismissed, Platform.OS === 'ios' ? 500 : 250);
571
+ });
572
+ };
573
+ return { onForward: props.client.canForward(m) ? () => action('forward') : undefined, onDelete: m.senderId === props.userId && !m.readAt && state.messageActionsAvailable ? () => action('delete') : undefined };
574
+ }, inlineVoice: () => !!message.current, inlineRecorder: () => !!message.current && !!current.current.client.getSnapshot().settings?.features.voice, controller: controller, audio: audioSession, appearance: props.appearance ?? state.settings?.appearance ?? defaultMessagingSettings().appearance })] }) }) });
564
575
  }
@@ -1,11 +1,14 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
- import { Linking, Text, UIManager } from 'react-native';
2
+ import { Text } from './typography.js';
3
+ import { useRobonoLocale } from './localization.js';
4
+ import { Linking, UIManager } from 'react-native';
3
5
  // Expo builds include this native module when the documented peer is installed.
4
6
  const available = !!UIManager.getViewManagerConfig('RNCWebView');
5
7
  const WebView = available ? require('react-native-webview').WebView : null;
6
8
  export function ExpoSocialPlayer({ html, origin, onError }) {
9
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
7
10
  if (!WebView)
8
- return _jsx(Text, { children: "Update the app to play this preview." });
11
+ return _jsx(Text, { children: uiText("Update the app to play this preview.") });
9
12
  const open = (url) => { try {
10
13
  const u = new URL(url);
11
14
  if (u.protocol === 'https:' && !u.username && !u.password)
package/dist/expo.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  export * from './expo-screen.js';
2
- export { ExpoMessenger, createExpoMessenger, type ExpoMessengerProps } from './expo-messenger.js';
2
+ export { ExpoMessenger, createExpoMessenger, createHostedExpoMessenger, type ExpoMessengerProps } from './expo-messenger.js';
package/dist/expo.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export * from './expo-screen.js';
2
- export { ExpoMessenger, createExpoMessenger } from './expo-messenger.js';
2
+ export { ExpoMessenger, createExpoMessenger, createHostedExpoMessenger } from './expo-messenger.js';
package/dist/extras.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Text } from './typography.js';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
3
- import { ActivityIndicator, Image, Linking, Platform, Pressable, ScrollView, Text, View } from 'react-native';
4
- import { currentTrackers, trackerFresh, trackerETA, weatherTemperature } from '@robono/messaging';
5
+ import { ActivityIndicator, Image, Linking, Platform, Pressable, ScrollView, View } from 'react-native';
6
+ import { currentTrackers, trackerFresh, weatherTemperature } from '@robono/messaging';
5
7
  import { MessagingPopup, messagingPopupColors } from './popup.js';
6
8
  import { MessageIcon } from './icons.js';
7
9
  import WeatherArtwork from './visual/WeatherArtwork.js';
@@ -26,6 +28,7 @@ else
26
28
  await Linking.openURL(`https://www.google.com/maps/search/?api=1&query=${query}`); }
27
29
  export function ExtraPopup({ title, onClose, background, foreground, children, expanded = true }) { return _jsx(MessagingPopup, { expanded: expanded, title: title, foreground: foreground, background: background, onClose: onClose, children: children }); }
28
30
  export const MessageExtra = forwardRef(function MessageExtra({ addressBubbleHeight = 44, extra, messageId, userId, extras, ink, accent, background, foreground, footer, renderAddress, Map, onCall, onLongPress }, ref) {
31
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
29
32
  const [mapHeight, setMapHeight] = useState(230), [pendingShare, setPendingShare] = useState(false);
30
33
  const resolution = useRef(null), shareIntent = useRef(false);
31
34
  const [open, setOpen] = useState(false), [fahrenheit, setFahrenheit] = useState(extra.weather?.fahrenheit ?? false), [imperial, setImperial] = useState(false), [now, setNow] = useState(Date.now()), [error, setError] = useState(''), [busy, setBusy] = useState(false), [selected, setSelected] = useState();
@@ -39,7 +42,7 @@ export const MessageExtra = forwardRef(function MessageExtra({ addressBubbleHeig
39
42
  catch (e) {
40
43
  shareIntent.current = false;
41
44
  setPendingShare(false);
42
- setError(e instanceof Error ? e.message : 'Please try again.');
45
+ setError(e instanceof Error ? e.message : uiText("Please try again."));
43
46
  }
44
47
  finally {
45
48
  setBusy(false);
@@ -60,10 +63,11 @@ export const MessageExtra = forwardRef(function MessageExtra({ addressBubbleHeig
60
63
  }
61
64
  } await extras.startTracking(messageId); setPendingShare(false); };
62
65
  const avatarSize = Math.max(8, Math.min(32, addressBubbleHeight - 34));
63
- const addressAction = _jsxs(View, { testID: "address-tracking", style: { flexShrink: 0, maxWidth: 136, height: trackers.length ? addressBubbleHeight : 32, justifyContent: 'center', alignItems: 'center', gap: 2, overflow: 'hidden' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: mine ? 'End tracking' : 'Allow tracking', accessibilityHint: "Share your live location with this chat for 15 minutes", disabled: busy || !mine && !extras.getSnapshot().available.tracking, hitSlop: { top: 6, bottom: 6, left: 0, right: 0 }, onPress: event => { event.stopPropagation(); void act(share); }, accessibilityState: { busy, disabled: busy || !mine && !extras.getSnapshot().available.tracking }, style: ({ pressed }) => ({ minHeight: 32, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 16, borderWidth: 1, borderColor: accent + (mine ? 'b0' : '70'), backgroundColor: accent + (pressed ? '30' : mine ? '20' : '0d'), flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, opacity: (busy || !mine && !extras.getSnapshot().available.tracking) ? 0.45 : 1 }), children: [busy ? _jsx(ActivityIndicator, { size: 16, color: accent }) : _jsx(MessageIcon, { name: mine ? 'stop' : 'location-outline', size: 16, color: accent }), _jsx(Text, { numberOfLines: 1, style: { color: foreground, fontSize: 12, lineHeight: 18, fontWeight: '600' }, children: mine ? 'End tracking' : 'Allow tracking' })] }), trackers.length > 0 && _jsx(View, { testID: "tracking-map-avatars", style: { flexDirection: 'row', flexWrap: 'nowrap', justifyContent: 'center', gap: 2 }, children: trackers.slice(0, 4).map(t => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Show ' + (t.userId === userId ? 'your' : t.name + "’s") + ' location on map', onPress: event => { event.stopPropagation(); setSelected(t.userId); setOpen(true); }, style: ({ pressed }) => ({ width: avatarSize, height: avatarSize, borderRadius: avatarSize / 2, alignItems: 'center', justifyContent: 'center', backgroundColor: accent + (pressed ? '30' : '12'), borderWidth: 1, borderColor: accent + '70' }), children: t.avatarUrl ? _jsx(Image, { source: { uri: t.avatarUrl }, style: { width: avatarSize - 4, height: avatarSize - 4, borderRadius: avatarSize / 2 } }) : _jsx(Text, { style: { color: foreground, fontSize: Math.min(18, avatarSize * .55), fontWeight: '600' }, children: t.name.slice(0, 1).toUpperCase() }) }, t.sessionId)) })] });
64
- const addressParticipants = trackers.length > 0 && _jsx(View, { testID: "address-participants", pointerEvents: "none", style: { alignSelf: 'stretch' }, children: _jsx(Text, { style: { color: ink, fontSize: 13, lineHeight: 18 }, children: trackers.map(t => `${t.userId === userId ? 'You' : t.name} · ${trackerETA(t, now)}`).join(' · ') }) });
65
- return _jsxs(_Fragment, { children: [weather && _jsxs(Pressable, { testID: "weather-card", accessibilityRole: "button", accessibilityLabel: "Open weather", onLongPress: onLongPress, onPress: () => setOpen(true), style: { width: 268, maxWidth: '100%', paddingHorizontal: 4, paddingVertical: 2 }, children: [_jsxs(View, { style: row, children: [_jsx(ForecastIcon, { period: weather.current, size: 34 }), _jsxs(View, { style: { flex: 1 }, children: [_jsxs(Text, { style: title, children: [weatherTemperature(weather.current.c, weather.fahrenheit ?? false), " \u00B7 ", weather.current.condition] }), _jsx(Text, { style: { color: ink, fontSize: 12, opacity: .75 }, children: weather.place })] })] }), _jsxs(View, { style: { ...row, justifyContent: 'space-between', marginTop: 2 }, children: [_jsx(Text, { style: { color: ink, fontSize: 11 }, children: weather.current.detail }), footer] })] }), address && (renderAddress ? renderAddress(addressAction, addressParticipants) : _jsxs(_Fragment, { children: [addressAction, addressParticipants] })), call && _jsxs(Pressable, { testID: "call-card", accessibilityRole: "button", accessibilityLabel: "Open call details", onLongPress: onLongPress, onPress: () => setOpen(true), style: { ...row, width: 268, maxWidth: '100%', padding: 4 }, children: [_jsx(MessageIcon, { name: call.kind === 'video' ? 'video' : 'call', color: call.outcome === 'missed' ? '#c94040' : accent, size: 26 }), _jsxs(View, { style: { flex: 1 }, children: [_jsxs(Text, { style: title, children: [call.outcome === 'missed' ? 'Missed ' : call.direction === 'incoming' ? 'Incoming ' : 'Outgoing ', call.kind === 'video' ? 'video' : 'voice', " call"] }), _jsx(Text, { style: { color: ink, fontSize: 12 }, children: call.outcome === 'completed' ? `${Math.floor(call.durationSeconds / 60)}:${String(Math.floor(call.durationSeconds % 60)).padStart(2, '0')}` : call.outcome })] }), footer] }), open && _jsxs(ExtraPopup, { expanded: !!address || !!weather, title: weather ? 'Weather' : address ? 'Live tracking' : 'Call details', onClose: () => { shareIntent.current = false; setPendingShare(false); resolution.current = null; setError(''); setOpen(false); }, background: background, foreground: foreground, children: [!!error && _jsxs(View, { style: { paddingVertical: 8 }, children: [_jsx(Text, { accessibilityRole: "alert", style: text, children: error }), address && !address.destination && _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => void act(async () => { await extras.resolveAddress(messageId); }), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: "Try again" }) })] }), address && _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [!address.destination && !!address.candidates?.length && _jsxs(View, { style: { gap: 8, paddingVertical: 12 }, children: [_jsx(Text, { style: { ...text, fontSize: 17, fontWeight: '600' }, children: "Choose the destination" }), address.candidates.map((place, index) => _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => void act(async () => { await extras.resolveAddress(messageId, index); if (pendingShare && shareIntent.current) {
66
+ const addressAction = _jsxs(View, { testID: "address-tracking", style: { flexShrink: 0, maxWidth: 136, height: trackers.length ? addressBubbleHeight : 32, justifyContent: 'center', alignItems: 'center', gap: 2, overflow: 'hidden' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: mine ? uiText("End tracking") : uiText("Allow tracking"), accessibilityHint: uiText("Share your live location with this chat for 15 minutes"), disabled: busy || !mine && !extras.getSnapshot().available.tracking, hitSlop: { top: 6, bottom: 6, left: 0, right: 0 }, onPress: event => { event.stopPropagation(); void act(share); }, accessibilityState: { busy, disabled: busy || !mine && !extras.getSnapshot().available.tracking }, style: ({ pressed }) => ({ minHeight: 32, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 16, borderWidth: 1, borderColor: accent + (mine ? 'b0' : '70'), backgroundColor: accent + (pressed ? '30' : mine ? '20' : '0d'), flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, opacity: (busy || !mine && !extras.getSnapshot().available.tracking) ? 0.45 : 1 }), children: [busy ? _jsx(ActivityIndicator, { size: 16, color: accent }) : _jsx(MessageIcon, { name: mine ? 'stop' : 'location-outline', size: 16, color: accent }), _jsx(Text, { numberOfLines: 1, style: { color: foreground, fontSize: 12, lineHeight: 18, fontWeight: '600' }, children: mine ? uiText("End tracking") : uiText("Allow tracking") })] }), trackers.length > 0 && _jsx(View, { testID: "tracking-map-avatars", style: { flexDirection: 'row', flexWrap: 'nowrap', justifyContent: 'center', gap: 2 }, children: trackers.slice(0, 4).map(t => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: t.userId === userId ? uiText('Show your location on map') : uiText('Show {name} on map', { name: t.name }), onPress: event => { event.stopPropagation(); setSelected(t.userId); setOpen(true); }, style: ({ pressed }) => ({ width: avatarSize, height: avatarSize, borderRadius: avatarSize / 2, alignItems: 'center', justifyContent: 'center', backgroundColor: accent + (pressed ? '30' : '12'), borderWidth: 1, borderColor: accent + '70' }), children: t.avatarUrl ? _jsx(Image, { source: { uri: t.avatarUrl }, style: { width: avatarSize - 4, height: avatarSize - 4, borderRadius: avatarSize / 2 } }) : _jsx(Text, { style: { color: foreground, fontSize: Math.min(18, avatarSize * .55), fontWeight: '600' }, children: t.name.slice(0, 1).toUpperCase() }) }, t.sessionId)) })] });
67
+ const trackingETA = (t) => trackerFresh(t, now) && t.etaSeconds !== null && Number.isFinite(t.etaSeconds) ? uiText('{count} min', { count: uiNumber(Math.max(1, Math.ceil(t.etaSeconds / 60))) }) : uiText('Updating…');
68
+ const addressParticipants = trackers.length > 0 && _jsx(View, { testID: "address-participants", pointerEvents: "none", style: { alignSelf: 'stretch' }, children: _jsx(Text, { style: { color: ink, fontSize: 13, lineHeight: 18 }, children: trackers.map(t => `${t.userId === userId ? uiText('You') : t.name} · ${trackingETA(t)}`).join(' · ') }) });
69
+ return _jsxs(_Fragment, { children: [weather && _jsxs(Pressable, { testID: "weather-card", accessibilityRole: "button", accessibilityLabel: uiText("Open weather"), onLongPress: onLongPress, onPress: () => setOpen(true), style: { width: 268, maxWidth: '100%', paddingHorizontal: 4, paddingVertical: 2 }, children: [_jsxs(View, { style: row, children: [_jsx(ForecastIcon, { period: weather.current, size: 34 }), _jsxs(View, { style: { flex: 1 }, children: [_jsxs(Text, { style: title, children: [weatherTemperature(weather.current.c, weather.fahrenheit ?? false), " \u00B7 ", weather.current.condition] }), _jsx(Text, { style: { color: ink, fontSize: 12, opacity: .75 }, children: weather.place })] })] }), _jsxs(View, { style: { ...row, justifyContent: 'space-between', marginTop: 2 }, children: [_jsx(Text, { style: { color: ink, fontSize: 11 }, children: weather.current.detail }), footer] })] }), address && (renderAddress ? renderAddress(addressAction, addressParticipants) : _jsxs(_Fragment, { children: [addressAction, addressParticipants] })), call && _jsxs(Pressable, { testID: "call-card", accessibilityRole: "button", accessibilityLabel: uiText("Open call details"), onLongPress: onLongPress, onPress: () => setOpen(true), style: { ...row, width: 268, maxWidth: '100%', padding: 4 }, children: [_jsx(MessageIcon, { name: call.kind === 'video' ? 'video' : 'call', color: call.outcome === 'missed' ? '#c94040' : accent, size: 26 }), _jsxs(View, { style: { flex: 1 }, children: [_jsx(Text, { style: title, children: call.outcome === 'missed' ? (call.kind === 'video' ? uiText('Missed video call') : uiText('Missed voice call')) : call.direction === 'incoming' ? (call.kind === 'video' ? uiText('Incoming video call') : uiText('Incoming voice call')) : (call.kind === 'video' ? uiText('Outgoing video call') : uiText('Outgoing voice call')) }), _jsx(Text, { style: { color: ink, fontSize: 12 }, children: call.outcome === 'completed' ? `${Math.floor(call.durationSeconds / 60)}:${String(Math.floor(call.durationSeconds % 60)).padStart(2, '0')}` : call.outcome })] }), footer] }), open && _jsxs(ExtraPopup, { expanded: !!address || !!weather, title: weather ? uiText("Weather") : address ? uiText("Live tracking") : uiText("Call details"), onClose: () => { shareIntent.current = false; setPendingShare(false); resolution.current = null; setError(''); setOpen(false); }, background: background, foreground: foreground, children: [!!error && _jsxs(View, { style: { paddingVertical: 8 }, children: [_jsx(Text, { accessibilityRole: "alert", style: text, children: error }), address && !address.destination && _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => void act(async () => { await extras.resolveAddress(messageId); }), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: uiText("Try again") }) })] }), address && _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [!address.destination && !!address.candidates?.length && _jsxs(View, { style: { gap: 8, paddingVertical: 12 }, children: [_jsx(Text, { style: { ...text, fontSize: 17, fontWeight: '600' }, children: uiText("Choose the destination") }), address.candidates.map((place, index) => _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => void act(async () => { await extras.resolveAddress(messageId, index); if (pendingShare && shareIntent.current) {
66
70
  await extras.startTracking(messageId);
67
71
  setPendingShare(false);
68
- } }), style: { minHeight: 48, padding: 12, borderRadius: 10, borderWidth: 1, borderColor: accent }, children: _jsx(Text, { style: { ...text, color: accent }, children: place.address }) }, index))] }), _jsx(Pressable, { accessibilityRole: "link", accessibilityLabel: "Open destination in maps", onPress: () => void act(() => openChatAddress(address.text)), children: _jsx(Text, { style: { ...text, color: accent, marginBottom: 10 }, numberOfLines: 2, children: address.text }) }), _jsx(View, { testID: "tracking-map-area", onLayout: event => setMapHeight(Math.max(1, Math.round(event.nativeEvent.layout.height))), style: { flex: 1, minHeight: 120, overflow: 'hidden' }, children: address.destination && Map ? _jsx(Map, { imperial: imperial, now: now, height: mapHeight, destination: address.destination, trackers: trackers.filter(t => trackerFresh(t, now)), accent: accent, selectedUserId: selected, onSelect: setSelected }) : _jsx(Text, { style: small, children: busy ? 'Finding the destination…' : address.candidates?.length ? 'Choose the matching destination above.' : 'The map is unavailable. Check Maps setup or try again.' }) }), _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 14 }, children: "Sharing ends after 15 minutes." }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Change speed and distance units", onPress: () => setImperial(v => !v), style: { minHeight: 44, paddingHorizontal: 4, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 17, fontWeight: '600' }, children: imperial ? 'km/h · km' : 'mph · mi' }) })] }), _jsxs(ScrollView, { style: { flexGrow: 0, maxHeight: 156 }, children: [!trackers.length && _jsx(Text, { style: text, children: "No one is sharing their location." }), trackers.map(t => _jsxs(Pressable, { onPress: () => setSelected(t.userId), accessibilityRole: "button", accessibilityLabel: 'Locate ' + t.name, style: { minHeight: 74, paddingVertical: 8, borderBottomWidth: 1, borderColor: messagingPopupColors.foreground + '18' }, children: [_jsxs(View, { style: { ...row, justifyContent: 'space-between' }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 21, lineHeight: 27, fontWeight: '600', flexShrink: 1 }, children: t.userId === userId ? 'You' : t.name }), _jsx(Text, { style: { color: accent, fontSize: 25, lineHeight: 30, fontWeight: '700' }, children: trackerETA(t, now) })] }), _jsxs(View, { style: { ...row, justifyContent: 'space-between', alignItems: 'flex-start' }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 19, lineHeight: 26, flex: 1 }, children: trackerFresh(t, now) ? t.speedMps === null ? 'Speed unavailable' : Math.round(t.speedMps * (imperial ? 2.23694 : 3.6)) + (imperial ? ' mph' : ' km/h') : 'Waiting for a fresh location' }), trackerFresh(t, now) && _jsx(Text, { testID: "tracking-distance", style: { color: messagingPopupColors.foreground, fontSize: 19, lineHeight: 26, textAlign: 'right', flexShrink: 1 }, children: t.distanceMeters === null ? 'Distance unavailable' : (t.distanceMeters / (imperial ? 1609.344 : 1000)).toFixed(1) + (imperial ? ' mi' : ' km') + ' away' })] })] }, t.sessionId))] })] }), !address && _jsxs(ScrollView, { style: weather ? { flex: 1, minHeight: 0 } : { flexShrink: 1 }, children: [weather && (weather.expiresAt <= now ? _jsx(Text, { style: text, children: "Weather details expired." }) : _jsxs(_Fragment, { children: [_jsx(Text, { style: small, children: weather.place }), _jsxs(View, { style: { ...row, marginVertical: 8 }, children: [_jsx(ForecastIcon, { period: weather.current, size: 36 }), _jsxs(Text, { style: { ...text, flex: 1, fontWeight: '600' }, children: [weatherTemperature(weather.current.c, fahrenheit), " \u00B7 ", weather.current.condition] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Change temperature unit", onPress: () => setFahrenheit(v => !v), style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 16, fontWeight: '600' }, children: fahrenheit ? '°C' : '°F' }) })] }), weather.alert && _jsx(Text, { style: { ...text, color: accent }, children: weather.alert }), _jsxs(View, { style: { marginTop: 20, paddingTop: 16, borderTopWidth: 1, borderTopColor: messagingPopupColors.foreground + '24' }, children: [_jsx(Text, { style: { ...text, fontWeight: '600' }, children: "Next three hours" }), _jsx(View, { style: { ...row, marginTop: 12, alignItems: 'flex-start' }, children: weather.hours.slice(0, 3).map(p => _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: small, children: p.label }), _jsxs(View, { style: row, children: [_jsx(ForecastIcon, { period: p, size: 32 }), _jsx(Text, { style: text, children: weatherTemperature(p.c, fahrenheit) })] }), _jsx(Text, { style: small, children: p.detail })] }, p.at)) })] }), _jsxs(View, { style: { marginTop: 20, paddingTop: 16, borderTopWidth: 1, borderTopColor: messagingPopupColors.foreground + '24' }, children: [_jsx(Text, { style: { ...text, fontWeight: '600', marginBottom: 8 }, children: "Three-day forecast" }), weather.days.slice(0, 3).map(p => _jsxs(View, { style: { ...row, paddingVertical: 8 }, children: [_jsx(Text, { style: { ...text, width: 45 }, children: p.label }), _jsx(ForecastIcon, { period: p, size: 32 }), _jsxs(View, { style: { flex: 1 }, children: [_jsx(Text, { style: text, children: p.condition }), _jsx(Text, { style: small, children: p.detail })] }), _jsxs(Text, { style: text, children: [weatherTemperature(p.c, fahrenheit), p.lowC !== undefined ? ' / ' + weatherTemperature(p.lowC, fahrenheit) : ''] })] }, p.at))] }), !!weather.attribution && _jsx(Text, { style: { ...small, marginTop: 8 }, children: weather.attribution })] })), call && _jsxs(_Fragment, { children: [_jsxs(Text, { style: { ...text, fontWeight: '600', marginVertical: 8 }, children: [call.kind === 'video' ? 'Video' : 'Voice', " call \u00B7 ", call.outcome] }), _jsx(Text, { style: text, children: new Date(call.startedAt).toLocaleString() }), call.durationSeconds > 0 && _jsxs(Text, { style: { ...text, marginTop: 8 }, children: [Math.floor(call.durationSeconds / 60), " min ", Math.floor(call.durationSeconds % 60), " sec"] }), call.endedByWarning && _jsx(Text, { style: { ...small, marginTop: 10 }, children: "Ended after the network\u2019s time warning." }), onCall && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Call again", onPress: () => { setOpen(false); onCall(call.kind); }, style: { minHeight: 44, justifyContent: 'center', marginTop: 10 }, children: _jsx(Text, { style: { color: accent, fontWeight: '600' }, children: "Call again" }) })] })] })] }), !!error && !open && _jsx(ExtraPopup, { title: "Message", onClose: () => setError(''), background: background, foreground: foreground, children: _jsx(Text, { accessibilityRole: "alert", style: text, children: error }) })] });
72
+ } }), style: { minHeight: 48, padding: 12, borderRadius: 10, borderWidth: 1, borderColor: accent }, children: _jsx(Text, { style: { ...text, color: accent }, children: place.address }) }, index))] }), _jsx(Pressable, { accessibilityRole: "link", accessibilityLabel: uiText("Open destination in maps"), onPress: () => void act(() => openChatAddress(address.text)), children: _jsx(Text, { style: { ...text, color: accent, marginBottom: 10 }, numberOfLines: 2, children: address.text }) }), _jsx(View, { testID: "tracking-map-area", onLayout: event => setMapHeight(Math.max(1, Math.round(event.nativeEvent.layout.height))), style: { flex: 1, minHeight: 120, overflow: 'hidden' }, children: address.destination && Map ? _jsx(Map, { imperial: imperial, now: now, height: mapHeight, destination: address.destination, trackers: trackers.filter(t => trackerFresh(t, now)), accent: accent, selectedUserId: selected, onSelect: setSelected }) : _jsx(Text, { style: small, children: busy ? uiText("Finding the destination…") : address.candidates?.length ? uiText("Choose the matching destination above.") : uiText("The map is unavailable. Check Maps setup or try again.") }) }), _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 14 }, children: uiText("Sharing ends after 15 minutes.") }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Change speed and distance units"), onPress: () => setImperial(v => !v), style: { minHeight: 44, paddingHorizontal: 4, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 17, fontWeight: '600' }, children: imperial ? uiText("km/h · km") : uiText("mph · mi") }) })] }), _jsxs(ScrollView, { style: { flexGrow: 0, maxHeight: 156 }, children: [!trackers.length && _jsx(Text, { style: text, children: uiText("No one is sharing their location.") }), trackers.map(t => _jsxs(Pressable, { onPress: () => setSelected(t.userId), accessibilityRole: "button", accessibilityLabel: uiText('Locate {name}', { name: t.name }), style: { minHeight: 74, paddingVertical: 8, borderBottomWidth: 1, borderColor: messagingPopupColors.foreground + '18' }, children: [_jsxs(View, { style: { ...row, justifyContent: 'space-between' }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 21, lineHeight: 27, fontWeight: '600', flexShrink: 1 }, children: t.userId === userId ? uiText("You") : t.name }), _jsx(Text, { style: { color: accent, fontSize: 25, lineHeight: 30, fontWeight: '700' }, children: trackingETA(t) })] }), _jsxs(View, { style: { ...row, justifyContent: 'space-between', alignItems: 'flex-start' }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 19, lineHeight: 26, flex: 1 }, children: trackerFresh(t, now) ? t.speedMps === null ? uiText("Speed unavailable") : Math.round(t.speedMps * (imperial ? 2.23694 : 3.6)) + (imperial ? ' mph' : ' km/h') : uiText("Waiting for a fresh location") }), trackerFresh(t, now) && _jsx(Text, { testID: "tracking-distance", style: { color: messagingPopupColors.foreground, fontSize: 19, lineHeight: 26, textAlign: 'right', flexShrink: 1 }, children: t.distanceMeters === null ? uiText("Distance unavailable") : uiText('{distance} {unit} away', { distance: uiNumber(t.distanceMeters / (imperial ? 1609.344 : 1000), { minimumFractionDigits: 1, maximumFractionDigits: 1 }), unit: imperial ? uiText('mi') : uiText('km') }) })] })] }, t.sessionId))] })] }), !address && _jsxs(ScrollView, { style: weather ? { flex: 1, minHeight: 0 } : { flexShrink: 1 }, children: [weather && (weather.expiresAt <= now ? _jsx(Text, { style: text, children: uiText("Weather details expired.") }) : _jsxs(_Fragment, { children: [_jsx(Text, { style: small, children: weather.place }), _jsxs(View, { style: { ...row, marginVertical: 8 }, children: [_jsx(ForecastIcon, { period: weather.current, size: 36 }), _jsxs(Text, { style: { ...text, flex: 1, fontWeight: '600' }, children: [weatherTemperature(weather.current.c, fahrenheit), " \u00B7 ", weather.current.condition] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Change temperature unit"), onPress: () => setFahrenheit(v => !v), style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 16, fontWeight: '600' }, children: fahrenheit ? uiText("°C") : uiText("°F") }) })] }), weather.alert && _jsx(Text, { style: { ...text, color: accent }, children: weather.alert }), _jsxs(View, { style: { marginTop: 20, paddingTop: 16, borderTopWidth: 1, borderTopColor: messagingPopupColors.foreground + '24' }, children: [_jsx(Text, { style: { ...text, fontWeight: '600' }, children: uiText("Next three hours") }), _jsx(View, { style: { ...row, marginTop: 12, alignItems: 'flex-start' }, children: weather.hours.slice(0, 3).map(p => _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: small, children: p.label }), _jsxs(View, { style: row, children: [_jsx(ForecastIcon, { period: p, size: 32 }), _jsx(Text, { style: text, children: weatherTemperature(p.c, fahrenheit) })] }), _jsx(Text, { style: small, children: p.detail })] }, p.at)) })] }), _jsxs(View, { style: { marginTop: 20, paddingTop: 16, borderTopWidth: 1, borderTopColor: messagingPopupColors.foreground + '24' }, children: [_jsx(Text, { style: { ...text, fontWeight: '600', marginBottom: 8 }, children: uiText("Three-day forecast") }), weather.days.slice(0, 3).map(p => _jsxs(View, { style: { ...row, paddingVertical: 8 }, children: [_jsx(Text, { style: { ...text, width: 45 }, children: p.label }), _jsx(ForecastIcon, { period: p, size: 32 }), _jsxs(View, { style: { flex: 1 }, children: [_jsx(Text, { style: text, children: p.condition }), _jsx(Text, { style: small, children: p.detail })] }), _jsxs(Text, { style: text, children: [weatherTemperature(p.c, fahrenheit), p.lowC !== undefined ? ' / ' + weatherTemperature(p.lowC, fahrenheit) : ''] })] }, p.at))] }), !!weather.attribution && _jsx(Text, { style: { ...small, marginTop: 8 }, children: weather.attribution })] })), call && _jsxs(_Fragment, { children: [_jsxs(Text, { style: { ...text, fontWeight: '600', marginVertical: 8 }, children: [call.kind === 'video' ? uiText('Video call') : uiText('Voice call'), " \u00B7 ", uiText(call.outcome)] }), _jsx(Text, { style: text, children: new Date(call.startedAt).toLocaleString(uiLocale) }), call.durationSeconds > 0 && _jsx(Text, { style: { ...text, marginTop: 8 }, children: uiText('{minutes} min {seconds} sec', { minutes: Math.floor(call.durationSeconds / 60), seconds: Math.floor(call.durationSeconds % 60) }) }), call.endedByWarning && _jsx(Text, { style: { ...small, marginTop: 10 }, children: uiText("Ended after the network’s time warning.") }), onCall && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Call again"), onPress: () => { setOpen(false); onCall(call.kind); }, style: { minHeight: 44, justifyContent: 'center', marginTop: 10 }, children: _jsx(Text, { style: { color: accent, fontWeight: '600' }, children: uiText("Call again") }) })] })] })] }), !!error && !open && _jsx(ExtraPopup, { title: uiText("Message"), onClose: () => setError(''), background: background, foreground: foreground, children: _jsx(Text, { accessibilityRole: "alert", style: text, children: error }) })] });
69
73
  });
package/dist/forward.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from './typography.js';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import { messagingPopupColors } from './popup.js';
3
5
  import { useEffect, useRef, useState } from 'react';
4
- import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
6
+ import { ActivityIndicator, Pressable, ScrollView, View } from 'react-native';
5
7
  import { MessagingPopup } from './popup.js';
6
8
  /** Messenger's compact destination picker, retaining per-message retry identities. */
7
9
  export function MessagingForward({ messages, client, appearance, participants = {}, userId, onClose }) {
10
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
8
11
  const [page, setPage] = useState(null), [loading, setLoading] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState('');
9
12
  const live = useRef(true), inFlight = useRef(false), attempts = useRef(new Map()), done = useRef(new Set());
10
13
  const c = { ...appearance.colors, ...messagingPopupColors };
@@ -17,7 +20,7 @@ export function MessagingForward({ messages, client, appearance, participants =
17
20
  }
18
21
  catch (e) {
19
22
  if (live.current)
20
- setError(e instanceof Error ? e.message : 'Chats could not be loaded.');
23
+ setError(e instanceof Error ? e.message : uiText("Chats could not be loaded."));
21
24
  }
22
25
  finally {
23
26
  inFlight.current = false;
@@ -49,7 +52,7 @@ export function MessagingForward({ messages, client, appearance, participants =
49
52
  }
50
53
  catch (e) {
51
54
  if (live.current)
52
- setError(e instanceof Error ? e.message : 'The message could not be forwarded. Try again.');
55
+ setError(e instanceof Error ? e.message : uiText("The message could not be forwarded. Try again."));
53
56
  }
54
57
  finally {
55
58
  inFlight.current = false;
@@ -57,6 +60,6 @@ export function MessagingForward({ messages, client, appearance, participants =
57
60
  setBusy(false);
58
61
  }
59
62
  }
60
- return _jsxs(MessagingPopup, { contentWidth: 640, title: "Forward to", foreground: c.foreground, closeLabel: "Close forwarding", closeDisabled: busy, background: c.background, onClose: () => { if (!busy)
61
- onClose(); }, children: [_jsx(ScrollView, { style: { flex: 1, minHeight: 0, marginTop: 8 }, children: page?.conversations.filter(row => row.canSend).map(row => { const name = row.kind === 'group' ? row.name ?? 'Group' : row.contactId === userId ? 'You' : participants[row.contactId ?? '']?.name ?? row.contactId ?? 'Chat'; return _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Forward to ' + name, disabled: busy || loading, onPress: () => void send(row), style: { minHeight: 52, justifyContent: 'center', paddingVertical: 12, paddingHorizontal: 8, borderBottomWidth: 1, borderBottomColor: '#ffffff14' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: name }) }, row.id); }) }), loading || busy ? _jsx(ActivityIndicator, { color: c.accent, style: { margin: 12 } }) : page && !page.conversations.some(row => row.canSend) && _jsx(Text, { style: { color: c.secondary, marginVertical: 12 }, children: "No chats available." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', justifyContent: 'flex-end', gap: 20, marginTop: 8 }, children: [!page && error && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void load(false), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Try again" }) }), page?.hasMore && _jsx(Pressable, { accessibilityRole: "button", disabled: loading || busy, onPress: () => void load(true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "More chats" }) })] })] });
63
+ return _jsxs(MessagingPopup, { contentWidth: 640, title: uiText("Forward to"), foreground: c.foreground, closeLabel: uiText("Close forwarding"), closeDisabled: busy, background: c.background, onClose: () => { if (!busy)
64
+ onClose(); }, children: [_jsx(ScrollView, { style: { flex: 1, minHeight: 0, marginTop: 8 }, children: page?.conversations.filter(row => row.canSend).map(row => { const name = row.kind === 'group' ? row.name ?? uiText('Group') : row.contactId === userId ? uiText('You') : participants[row.contactId ?? '']?.name ?? row.contactId ?? uiText('Chat'); return _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText('Forward to {name}', { name }), disabled: busy || loading, onPress: () => void send(row), style: { minHeight: 52, justifyContent: 'center', paddingVertical: 12, paddingHorizontal: 8, borderBottomWidth: 1, borderBottomColor: '#ffffff14' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: name }) }, row.id); }) }), loading || busy ? _jsx(ActivityIndicator, { color: c.accent, style: { margin: 12 } }) : page && !page.conversations.some(row => row.canSend) && _jsx(Text, { style: { color: c.secondary, marginVertical: 12 }, children: uiText("No chats available.") }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', justifyContent: 'flex-end', gap: 20, marginTop: 8 }, children: [!page && error && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void load(false), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: uiText("Try again") }) }), page?.hasMore && _jsx(Pressable, { accessibilityRole: "button", disabled: loading || busy, onPress: () => void load(true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: uiText("More chats") }) })] })] });
62
65
  }