@robono/messaging-react-native 0.8.0 → 0.9.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 (46) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +40 -37
  3. package/SBOM.spdx.json +229 -157
  4. package/dist/account.js +5 -3
  5. package/dist/attachment-menu.js +3 -1
  6. package/dist/call-message.js +6 -4
  7. package/dist/calling.js +3 -1
  8. package/dist/conversation-details.js +9 -6
  9. package/dist/expo-connected-calling.js +4 -1
  10. package/dist/expo-extras.js +4 -1
  11. package/dist/expo-media.js +27 -5
  12. package/dist/expo-messenger.d.ts +38 -2
  13. package/dist/expo-messenger.js +56 -6
  14. package/dist/expo-outbox.js +22 -1
  15. package/dist/expo-screen.d.ts +1 -1
  16. package/dist/expo-screen.js +23 -14
  17. package/dist/expo-social.js +3 -1
  18. package/dist/expo.d.ts +1 -1
  19. package/dist/expo.js +1 -1
  20. package/dist/extras.js +9 -6
  21. package/dist/forward.js +6 -4
  22. package/dist/group-editor.js +7 -5
  23. package/dist/header-calls.js +7 -5
  24. package/dist/inbox-search.js +4 -2
  25. package/dist/inbox.js +9 -7
  26. package/dist/index.d.ts +6 -3
  27. package/dist/index.js +91 -61
  28. package/dist/localization.d.ts +24 -0
  29. package/dist/localization.js +12 -0
  30. package/dist/maps-connection.js +3 -1
  31. package/dist/media-ui.js +13 -7
  32. package/dist/message-menu.js +3 -1
  33. package/dist/native-outbox.d.ts +9 -1
  34. package/dist/native-outbox.js +7 -0
  35. package/dist/polls.js +18 -14
  36. package/dist/popup.js +6 -3
  37. package/dist/quick-send.js +4 -2
  38. package/dist/safety.js +9 -7
  39. package/dist/saved.js +5 -3
  40. package/dist/scheduled.js +17 -13
  41. package/dist/search.js +5 -3
  42. package/dist/social.js +7 -4
  43. package/dist/translation-settings.js +5 -3
  44. package/dist/voice-composer.js +9 -5
  45. package/package.json +9 -7
  46. package/setup.plugin.cjs +20 -0
@@ -1,4 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { RobonoLocaleProvider } from './localization.js';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import * as Haptics from 'expo-haptics';
3
5
  import { Vibration } from 'react-native';
4
6
  import { MessagingPopupHeader, messagingPopupBackdrop } from './popup.js';
@@ -68,6 +70,7 @@ async function acquireVoice(scope, audio) {
68
70
  return scope.alive && scope.epoch === epoch && !!scope.release;
69
71
  }
70
72
  function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
73
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
71
74
  const [playing, setPlaying] = useState(false), [position, setPosition] = useState(0), [error, setError] = useState('');
72
75
  const source = useMemo(() => file.stream ? { uri: file.uri, headers: file.stream.headers } : file.uri, [file]);
73
76
  const player = useAudioPlayer(source, { updateInterval: 100, downloadFirst: false, preferredForwardBufferDuration: 5, keepAudioSessionActive: true });
@@ -110,7 +113,7 @@ function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
110
113
  }
111
114
  catch { } await releaseVoice(scope); await scope.acquiring?.catch(() => { }); await releaseVoice(scope); };
112
115
  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); };
116
+ setError(e instanceof Error ? e.message : uiText("Playback failed.")); scope.epoch++; void releaseVoice(scope); };
114
117
  // Native file loading and audio-session preparation are independent. Start
115
118
  // both now; playback still requires both and an active app.
116
119
  const begin = () => {
@@ -217,7 +220,7 @@ function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
217
220
  }
218
221
  catch (e) {
219
222
  if (scope.alive)
220
- setError(e instanceof Error ? e.message : 'Playback failed.');
223
+ setError(e instanceof Error ? e.message : uiText("Playback failed."));
221
224
  await releaseVoice(scope);
222
225
  }
223
226
  finally {
@@ -230,9 +233,10 @@ function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
230
233
  if (draft)
231
234
  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
235
  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.'); }); } });
236
+ setError(uiText("Could not seek. Try again.")); }); } });
234
237
  }
235
238
  function VideoPlayer({ file, audio }) {
239
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
236
240
  const mediaReady = useContext(MediaReadiness), recover = useContext(StreamRecovery);
237
241
  const source = useMemo(() => file.stream ? { uri: file.uri, headers: file.stream.headers, contentType: 'progressive', useCaching: false } : file.uri, [file]);
238
242
  const player = useVideoPlayer(source, p => { p.loop = false; p.audioMixingMode = 'mixWithOthers'; if (file.stream)
@@ -271,7 +275,7 @@ function VideoPlayer({ file, audio }) {
271
275
  desired.current = false;
272
276
  player.pause();
273
277
  setReady(false);
274
- setError('Playback paused for a call. Close and reopen the video to continue.');
278
+ setError(uiText("Playback paused for a call. Close and reopen the video to continue."));
275
279
  void release?.().catch(() => { });
276
280
  release = undefined;
277
281
  } }, 100);
@@ -287,14 +291,14 @@ function VideoPlayer({ file, audio }) {
287
291
  });
288
292
  const listener = player.addListener('statusChange', s => { if (s.status === 'error') {
289
293
  if (!recoverStream())
290
- setError('This video could not be played on this phone.');
294
+ setError(uiText("This video could not be played on this phone."));
291
295
  }
292
296
  else
293
297
  begin(); });
294
298
  if (player.status === 'error')
295
299
  recoverStream();
296
300
  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);
301
+ setError(uiText("This video could not be loaded. Try again.")); }, 15000);
298
302
  const app = AppState.addEventListener('change', s => { if (s !== 'active') {
299
303
  desired.current = false;
300
304
  player.pause();
@@ -307,25 +311,27 @@ function VideoPlayer({ file, audio }) {
307
311
  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
312
  }
309
313
  function FileViewer({ file, kind, audio, design }) {
314
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
310
315
  const [error, setError] = useState('');
311
316
  if (kind === 'voice')
312
317
  return _jsx(VoicePlayer, { file: file, audio: audio, design: design });
313
318
  if (kind === 'video')
314
319
  return _jsx(VideoPlayer, { file: file, audio: audio, design: design });
315
320
  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: () => {
321
+ 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 })] });
322
+ return _jsxs(View, { children: [_jsx(Control, { label: uiText("File options"), icon: "open-in-new", color: design.colors.accent, onPress: () => {
318
323
  void (async () => { try {
319
324
  if (!await Sharing.isAvailableAsync())
320
325
  throw Error('No file-opening menu is available.');
321
326
  await Sharing.shareAsync(file.uri, { mimeType: file.mimeType, dialogTitle: file.fileName });
322
327
  }
323
328
  catch (e) {
324
- setError(e instanceof Error ? e.message : 'The file could not be opened.');
329
+ setError(e instanceof Error ? e.message : uiText("The file could not be opened."));
325
330
  } })();
326
331
  } }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: design.colors.foreground }, children: error })] });
327
332
  }
328
333
  export function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice = false, inlineRecorder = false, viewerActions, onDismiss }) {
334
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
329
335
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
330
336
  const c = appearance.colors;
331
337
  const mediaReady = useContext(MediaReadiness);
@@ -338,24 +344,27 @@ export function MessagingMediaOverlay({ controller, audio, appearance, inlineVoi
338
344
  return null;
339
345
  if ((typeof inlineVoice === 'function' ? inlineVoice() : inlineVoice) && state.kind === 'voice' && ['opening', 'viewing', 'failed'].includes(state.phase) && !review)
340
346
  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 {
347
+ 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)
348
+ 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
349
  if (!await Sharing.isAvailableAsync())
344
350
  throw Error('File opening is unavailable.');
345
351
  await Sharing.shareAsync(state.file.uri, { mimeType: state.file.mimeType, dialogTitle: state.file.fileName });
346
352
  mediaReady('file-opened', state.file.uri);
347
353
  }
348
354
  catch (e) {
349
- setFileError(e instanceof Error ? e.message : 'Could not open the file.');
355
+ setFileError(e instanceof Error ? e.message : uiText("Could not open the file."));
350
356
  } })(); }, 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
357
  // A native modal has its own window; measure its safe area inside that window.
352
358
  return _jsx(Modal, { transparent: true, visible: state.phase !== 'idle' && state.phase !== 'choosing', onDismiss: onDismiss, animationType: "fade", onRequestClose: () => { if (expanded)
353
359
  setExpanded(false);
354
360
  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() })] }) })) }) }) }) });
361
+ 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 ? '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' ? 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
362
  }
357
363
  /** Optional ready-made recording, preview, upload and download tools for both mobile platforms. */
358
- export function ExpoMessagingScreen({ storage, audioSession, renderConversation, preloadRecentMedia = true, ...props }) {
364
+ export function ExpoMessagingScreen(props) {
365
+ return _jsx(RobonoLocaleProvider, { value: props.interfaceLocale, children: _jsx(ExpoMessagingScreenContent, { ...props }) });
366
+ }
367
+ function ExpoMessagingScreenContent({ storage, audioSession, renderConversation, preloadRecentMedia = true, ...props }) {
359
368
  useEffect(() => { void props.client.reportUsage({ platform: Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'other', components: ['media-tools'] }).catch(() => { }); }, [props.client]);
360
369
  const [requestedMessageAction, setRequestedMessageAction] = useState();
361
370
  const afterViewer = useRef(undefined);
@@ -1,11 +1,13 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { useRobonoLocale } from './localization.js';
2
3
  import { Linking, Text, UIManager } from 'react-native';
3
4
  // Expo builds include this native module when the documented peer is installed.
4
5
  const available = !!UIManager.getViewManagerConfig('RNCWebView');
5
6
  const WebView = available ? require('react-native-webview').WebView : null;
6
7
  export function ExpoSocialPlayer({ html, origin, onError }) {
8
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
7
9
  if (!WebView)
8
- return _jsx(Text, { children: "Update the app to play this preview." });
10
+ return _jsx(Text, { children: uiText("Update the app to play this preview.") });
9
11
  const open = (url) => { try {
10
12
  const u = new URL(url);
11
13
  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,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRobonoLocale } from './localization.js';
2
3
  import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
3
4
  import { ActivityIndicator, Image, Linking, Platform, Pressable, ScrollView, Text, View } from 'react-native';
4
- import { currentTrackers, trackerFresh, trackerETA, weatherTemperature } from '@robono/messaging';
5
+ import { currentTrackers, trackerFresh, weatherTemperature } from '@robono/messaging';
5
6
  import { MessagingPopup, messagingPopupColors } from './popup.js';
6
7
  import { MessageIcon } from './icons.js';
7
8
  import WeatherArtwork from './visual/WeatherArtwork.js';
@@ -26,6 +27,7 @@ else
26
27
  await Linking.openURL(`https://www.google.com/maps/search/?api=1&query=${query}`); }
27
28
  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
29
  export const MessageExtra = forwardRef(function MessageExtra({ addressBubbleHeight = 44, extra, messageId, userId, extras, ink, accent, background, foreground, footer, renderAddress, Map, onCall, onLongPress }, ref) {
30
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
29
31
  const [mapHeight, setMapHeight] = useState(230), [pendingShare, setPendingShare] = useState(false);
30
32
  const resolution = useRef(null), shareIntent = useRef(false);
31
33
  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 +41,7 @@ export const MessageExtra = forwardRef(function MessageExtra({ addressBubbleHeig
39
41
  catch (e) {
40
42
  shareIntent.current = false;
41
43
  setPendingShare(false);
42
- setError(e instanceof Error ? e.message : 'Please try again.');
44
+ setError(e instanceof Error ? e.message : uiText("Please try again."));
43
45
  }
44
46
  finally {
45
47
  setBusy(false);
@@ -60,10 +62,11 @@ export const MessageExtra = forwardRef(function MessageExtra({ addressBubbleHeig
60
62
  }
61
63
  } await extras.startTracking(messageId); setPendingShare(false); };
62
64
  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) {
65
+ 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)) })] });
66
+ 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…');
67
+ 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(' · ') }) });
68
+ 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
69
  await extras.startTracking(messageId);
67
70
  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 }) })] });
71
+ } }), 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
72
  });
package/dist/forward.js CHANGED
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRobonoLocale } from './localization.js';
2
3
  import { messagingPopupColors } from './popup.js';
3
4
  import { useEffect, useRef, useState } from 'react';
4
5
  import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
5
6
  import { MessagingPopup } from './popup.js';
6
7
  /** Messenger's compact destination picker, retaining per-message retry identities. */
7
8
  export function MessagingForward({ messages, client, appearance, participants = {}, userId, onClose }) {
9
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
8
10
  const [page, setPage] = useState(null), [loading, setLoading] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState('');
9
11
  const live = useRef(true), inFlight = useRef(false), attempts = useRef(new Map()), done = useRef(new Set());
10
12
  const c = { ...appearance.colors, ...messagingPopupColors };
@@ -17,7 +19,7 @@ export function MessagingForward({ messages, client, appearance, participants =
17
19
  }
18
20
  catch (e) {
19
21
  if (live.current)
20
- setError(e instanceof Error ? e.message : 'Chats could not be loaded.');
22
+ setError(e instanceof Error ? e.message : uiText("Chats could not be loaded."));
21
23
  }
22
24
  finally {
23
25
  inFlight.current = false;
@@ -49,7 +51,7 @@ export function MessagingForward({ messages, client, appearance, participants =
49
51
  }
50
52
  catch (e) {
51
53
  if (live.current)
52
- setError(e instanceof Error ? e.message : 'The message could not be forwarded. Try again.');
54
+ setError(e instanceof Error ? e.message : uiText("The message could not be forwarded. Try again."));
53
55
  }
54
56
  finally {
55
57
  inFlight.current = false;
@@ -57,6 +59,6 @@ export function MessagingForward({ messages, client, appearance, participants =
57
59
  setBusy(false);
58
60
  }
59
61
  }
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" }) })] })] });
62
+ return _jsxs(MessagingPopup, { contentWidth: 640, title: uiText("Forward to"), foreground: c.foreground, closeLabel: uiText("Close forwarding"), closeDisabled: busy, background: c.background, onClose: () => { if (!busy)
63
+ 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
64
  }
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRobonoLocale } from './localization.js';
2
3
  import { useEffect, useMemo, useRef, useState } from 'react';
3
4
  import { ActivityIndicator, FlatList, Image, Pressable, Text, TextInput, View } from 'react-native';
4
5
  import { MessageIcon } from './icons.js';
5
6
  import { MessagingPopup, messagingPopupColors as c } from './popup.js';
6
7
  /** Messenger's name, group type and contact checklist; changes use member deltas. */
7
8
  export function MessagingGroupEditor({ client, inbox, userId, contacts, conversation, groups, accent, onClose, onSaved }) {
9
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
8
10
  const editing = !!conversation, conversationId = client.getSnapshot().conversationId;
9
11
  const [name, setName] = useState(conversation?.name ?? ''), [supergroup, setSupergroup] = useState(conversation?.supergroup ?? false);
10
12
  const [revision, setRevision] = useState(conversation?.revision ?? 0), [count, setCount] = useState(conversation?.memberCount ?? 0);
@@ -34,7 +36,7 @@ export function MessagingGroupEditor({ client, inbox, userId, contacts, conversa
34
36
  catch (e) {
35
37
  if (version === generation.current) {
36
38
  setReady(false);
37
- setError(e instanceof Error ? e.message : 'Group members could not be loaded.');
39
+ setError(e instanceof Error ? e.message : uiText("Group members could not be loaded."));
38
40
  }
39
41
  }
40
42
  finally {
@@ -49,7 +51,7 @@ export function MessagingGroupEditor({ client, inbox, userId, contacts, conversa
49
51
  const chosen = (id) => !removed.has(id) && (original.has(id) || added.has(id));
50
52
  const toggle = (id) => {
51
53
  if (!chosen(id) && membersCount >= max) {
52
- setError(`This group can have up to ${max.toLocaleString()} members.`);
54
+ setError(uiText("This group can have up to {value1} members.", { value1: max.toLocaleString(uiLocale) }));
53
55
  return;
54
56
  }
55
57
  if (original.has(id))
@@ -86,13 +88,13 @@ export function MessagingGroupEditor({ client, inbox, userId, contacts, conversa
86
88
  }
87
89
  }
88
90
  catch (e) {
89
- setError(e instanceof Error ? e.message : 'The group could not be saved.');
91
+ setError(e instanceof Error ? e.message : uiText("The group could not be saved."));
90
92
  }
91
93
  finally {
92
94
  setBusy(false);
93
95
  }
94
96
  }
95
97
  const locked = busy || loading || !ready, canSave = !locked && !!name.trim() && membersCount >= 2 && membersCount <= max;
96
- return _jsxs(MessagingPopup, { title: editing ? 'Edit group' : 'New group', background: c.background, closeDisabled: busy, onClose: () => { if (!busy)
97
- onClose(); }, form: true, children: [_jsx(FlatList, { data: people, keyExtractor: id => id, keyboardShouldPersistTaps: "handled", style: { flex: 1 }, contentContainerStyle: { paddingHorizontal: 8, paddingBottom: 16 }, initialNumToRender: 15, ListHeaderComponent: _jsxs(View, { style: { gap: 12, paddingTop: 12 }, children: [_jsx(TextInput, { accessibilityLabel: "Group name", placeholder: "Group name", placeholderTextColor: c.secondary, maxLength: 100, value: name, onChangeText: setName, editable: !locked, style: { color: c.foreground, fontSize: 21, minHeight: 48, paddingHorizontal: 4, borderBottomWidth: 1, borderBottomColor: '#ffffff30' } }), groups?.supergroupsEnabled && _jsx(View, { style: { flexDirection: 'row', gap: 20 }, children: [false, true].map(value => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: value ? 'Supergroup' : 'Standard group', accessibilityState: { checked: supergroup === value, disabled: locked }, disabled: locked, onPress: () => setSupergroup(value), style: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsx(MessageIcon, { name: supergroup === value ? 'radio-button-on' : 'radio-button-off', color: accent, size: 22 }), _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: value ? 'Supergroup' : 'Group' })] }, String(value))) }), _jsxs(Text, { style: { color: c.secondary, fontSize: 14 }, children: [membersCount.toLocaleString(), " / ", max.toLocaleString(), " members"] }), _jsx(Text, { style: { color: c.foreground, fontSize: 17 }, children: "You \u00B7 Group owner" }), _jsx(TextInput, { accessibilityLabel: "Search group contacts", placeholder: "Search contacts", placeholderTextColor: c.secondary, value: query, onChangeText: setQuery, style: { color: c.foreground, fontSize: 16, minHeight: 44, borderBottomWidth: 1, borderBottomColor: '#ffffff30' } })] }), renderItem: ({ item: id }) => _jsxs(Pressable, { accessibilityRole: "checkbox", accessibilityLabel: contacts[id]?.name ?? 'Member', accessibilityState: { checked: chosen(id), disabled: locked }, disabled: locked, onPress: () => toggle(id), style: { minHeight: 56, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [contacts[id]?.avatarUrl ? _jsx(Image, { source: { uri: contacts[id].avatarUrl }, style: { width: 36, height: 36, borderRadius: 18 } }) : _jsx(View, { style: { width: 36, height: 36, borderRadius: 18, backgroundColor: c.incoming, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 18 }, children: (contacts[id]?.name ?? 'Member').slice(0, 1) }) }), _jsx(Text, { style: { flex: 1, color: c.foreground, fontSize: 16 }, children: contacts[id]?.name ?? 'Member' }), _jsx(MessageIcon, { name: chosen(id) ? 'checkmark-circle' : 'ellipse-outline', color: chosen(id) ? accent : c.secondary, size: 24 })] }), ListFooterComponent: _jsxs(View, { style: { gap: 12, paddingTop: 12 }, children: [loading && _jsx(ActivityIndicator, { accessibilityLabel: "Loading group members", color: accent }), next && _jsx(Pressable, { accessibilityRole: "button", disabled: loading || busy, onPress: () => void load(true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: "Load more members" }) }), !people.length && !loading && _jsx(Text, { style: { color: c.secondary }, children: query ? 'No matching contacts.' : 'Your app has not supplied any contacts to invite.' }), _jsxs(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19 }, children: ["Only the group owner can change the name or members. ", groups?.historyForNewMembers === 'all' ? 'New members can see retained group history.' : 'New members see messages sent after they join.'] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, fontSize: 15 }, children: error })] }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: editing ? 'Save group' : 'Create group', disabled: !canSave, onPress: () => void save(), style: { minHeight: 48, margin: 8, borderRadius: 12, backgroundColor: accent, alignItems: 'center', justifyContent: 'center', opacity: canSave ? 1 : .4 }, children: busy ? _jsx(ActivityIndicator, { color: "#000" }) : _jsx(Text, { style: { color: '#000', fontSize: 17, fontWeight: '600' }, children: editing ? 'Save' : 'Create group' }) })] });
98
+ return _jsxs(MessagingPopup, { title: editing ? uiText("Edit group") : uiText("New group"), background: c.background, closeDisabled: busy, onClose: () => { if (!busy)
99
+ onClose(); }, form: true, children: [_jsx(FlatList, { data: people, keyExtractor: id => id, keyboardShouldPersistTaps: "handled", style: { flex: 1 }, contentContainerStyle: { paddingHorizontal: 8, paddingBottom: 16 }, initialNumToRender: 15, ListHeaderComponent: _jsxs(View, { style: { gap: 12, paddingTop: 12 }, children: [_jsx(TextInput, { accessibilityLabel: uiText("Group name"), placeholder: uiText("Group name"), placeholderTextColor: c.secondary, maxLength: 100, value: name, onChangeText: setName, editable: !locked, style: { color: c.foreground, fontSize: 21, minHeight: 48, paddingHorizontal: 4, borderBottomWidth: 1, borderBottomColor: '#ffffff30' } }), groups?.supergroupsEnabled && _jsx(View, { style: { flexDirection: 'row', gap: 20 }, children: [false, true].map(value => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: value ? uiText("Supergroup") : uiText("Standard group"), accessibilityState: { checked: supergroup === value, disabled: locked }, disabled: locked, onPress: () => setSupergroup(value), style: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsx(MessageIcon, { name: supergroup === value ? 'radio-button-on' : 'radio-button-off', color: accent, size: 22 }), _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: value ? uiText("Supergroup") : uiText("Group") })] }, String(value))) }), _jsx(Text, { style: { color: c.secondary, fontSize: 14 }, children: uiText('{count} / {limit} members', { count: membersCount, limit: max }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 17 }, children: uiText("You · Group owner") }), _jsx(TextInput, { accessibilityLabel: uiText("Search group contacts"), placeholder: uiText("Search contacts"), placeholderTextColor: c.secondary, value: query, onChangeText: setQuery, style: { color: c.foreground, fontSize: 16, minHeight: 44, borderBottomWidth: 1, borderBottomColor: '#ffffff30' } })] }), renderItem: ({ item: id }) => _jsxs(Pressable, { accessibilityRole: "checkbox", accessibilityLabel: contacts[id]?.name ?? uiText("Member"), accessibilityState: { checked: chosen(id), disabled: locked }, disabled: locked, onPress: () => toggle(id), style: { minHeight: 56, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [contacts[id]?.avatarUrl ? _jsx(Image, { source: { uri: contacts[id].avatarUrl }, style: { width: 36, height: 36, borderRadius: 18 } }) : _jsx(View, { style: { width: 36, height: 36, borderRadius: 18, backgroundColor: c.incoming, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 18 }, children: (contacts[id]?.name ?? 'Member').slice(0, 1) }) }), _jsx(Text, { style: { flex: 1, color: c.foreground, fontSize: 16 }, children: contacts[id]?.name ?? uiText("Member") }), _jsx(MessageIcon, { name: chosen(id) ? 'checkmark-circle' : 'ellipse-outline', color: chosen(id) ? accent : c.secondary, size: 24 })] }), ListFooterComponent: _jsxs(View, { style: { gap: 12, paddingTop: 12 }, children: [loading && _jsx(ActivityIndicator, { accessibilityLabel: uiText("Loading group members"), color: accent }), next && _jsx(Pressable, { accessibilityRole: "button", disabled: loading || busy, onPress: () => void load(true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: uiText("Load more members") }) }), !people.length && !loading && _jsx(Text, { style: { color: c.secondary }, children: query ? uiText("No matching contacts.") : uiText("Your app has not supplied any contacts to invite.") }), _jsxs(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19 }, children: [uiText("Only the group owner can change the name or members."), groups?.historyForNewMembers === 'all' ? uiText("New members can see retained group history.") : uiText("New members see messages sent after they join.")] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, fontSize: 15 }, children: error })] }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: editing ? uiText("Save group") : uiText("Create group"), disabled: !canSave, onPress: () => void save(), style: { minHeight: 48, margin: 8, borderRadius: 12, backgroundColor: accent, alignItems: 'center', justifyContent: 'center', opacity: canSave ? 1 : .4 }, children: busy ? _jsx(ActivityIndicator, { color: "#000" }) : _jsx(Text, { style: { color: '#000', fontSize: 17, fontWeight: '600' }, children: editing ? uiText("Save") : uiText("Create group") }) })] });
98
100
  }
@@ -1,10 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRobonoLocale } from './localization.js';
2
3
  import { useEffect, useRef, useState } from 'react';
3
4
  import { Keyboard, Linking, Platform, Pressable, Text, View } from 'react-native';
4
5
  import { MessageIcon } from './icons.js';
5
6
  import { MessagingPopup } from './popup.js';
6
7
  /** Matches Messenger: video is separate; the phone button chooses internet/carrier. */
7
8
  export function MessagingHeaderCalls({ actions, name, group, color }) {
9
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
8
10
  const [visible, setVisible] = useState(false), [error, setError] = useState('');
9
11
  const pending = useRef(null), latest = useRef(actions);
10
12
  latest.current = actions;
@@ -24,7 +26,7 @@ export function MessagingHeaderCalls({ actions, name, group, color }) {
24
26
  await current?.[kind]?.();
25
27
  }
26
28
  catch {
27
- setError('The call could not start. Please try again.');
29
+ setError(uiText("The call could not start. Please try again."));
28
30
  } };
29
31
  const dismissed = () => { const kind = pending.current; pending.current = null; if (kind)
30
32
  requestAnimationFrame(() => void invoke(kind)); };
@@ -37,10 +39,10 @@ export function MessagingHeaderCalls({ actions, name, group, color }) {
37
39
  } }; document.addEventListener('keydown', close, true); return () => document.removeEventListener('keydown', close, true); }, [visible]);
38
40
  useEffect(() => { pending.current = null; setVisible(false); setError(''); }, [name, group]);
39
41
  const choose = (kind) => { pending.current = kind; setVisible(false); };
40
- const button = (label, icon, press) => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label, onPress: press, style: { width: icon === 'video' ? 42 : 44, height: icon === 'video' ? 42 : 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: icon, size: 23, color: color }) });
41
- const row = (kind, label, detail) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: label, onPress: () => choose(kind), style: ({ pressed }) => ({ flexDirection: 'row', alignItems: 'center', gap: 16, minHeight: 80, padding: 12, borderRadius: 18, backgroundColor: pressed ? '#1D1D1D' : 'transparent' }), children: [_jsx(View, { style: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#1D1D1D', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: kind === 'phone' ? 'phone-portrait-outline' : 'phone', color: "#F7F7F7", size: 24 }) }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: { color: '#F7F7F7', fontSize: 18, fontWeight: '600' }, children: label }), _jsx(Text, { style: { color: '#A3A3A3', fontSize: 15 }, children: detail })] })] });
42
- return _jsxs(_Fragment, { children: [actions?.video && button(actions.videoLabel ?? 'Video call', 'video', () => void invoke('video')), (actions?.voice || validPhone) && button('Call ' + name, 'phone', () => { if (!validPhone) {
42
+ const button = (label, icon, press) => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText(label), onPress: press, style: { width: icon === 'video' ? 42 : 44, height: icon === 'video' ? 42 : 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: icon, size: 23, color: color }) });
43
+ const row = (kind, label, detail) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText(label), onPress: () => choose(kind), style: ({ pressed }) => ({ flexDirection: 'row', alignItems: 'center', gap: 16, minHeight: 80, padding: 12, borderRadius: 18, backgroundColor: pressed ? '#1D1D1D' : 'transparent' }), children: [_jsx(View, { style: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#1D1D1D', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: kind === 'phone' ? 'phone-portrait-outline' : 'phone', color: "#F7F7F7", size: 24 }) }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: { color: '#F7F7F7', fontSize: 18, fontWeight: '600' }, children: uiText(label) }), _jsx(Text, { style: { color: '#A3A3A3', fontSize: 15 }, children: uiText(detail) })] })] });
44
+ return _jsxs(_Fragment, { children: [actions?.video && button(actions.videoLabel ?? 'Video call', 'video', () => void invoke('video')), (actions?.voice || validPhone) && button(uiText('Call {name}', { name }), 'phone', () => { if (!validPhone) {
43
45
  void invoke('voice');
44
46
  return;
45
- } Keyboard.dismiss(); setVisible(true); }), _jsx(MessagingPopup, { visible: visible || !!error, compact: true, title: 'Call ' + name, background: "#111111", onDismiss: dismissed, onClose: () => { pending.current = null; setVisible(false); setError(''); }, children: error ? _jsx(Text, { accessibilityRole: "alert", style: { color: '#F7F7F7', fontSize: 16, paddingTop: 12 }, children: error }) : _jsxs(_Fragment, { children: [actions?.voice && row('voice', actions.voiceLabel ?? 'Internet call', 'Wi-Fi or mobile data'), validPhone && row('phone', 'Phone call', validPhone)] }) })] });
47
+ } Keyboard.dismiss(); setVisible(true); }), _jsx(MessagingPopup, { visible: visible || !!error, compact: true, title: uiText('Call {name}', { name }), background: "#111111", onDismiss: dismissed, onClose: () => { pending.current = null; setVisible(false); setError(''); }, children: error ? _jsx(Text, { accessibilityRole: "alert", style: { color: '#F7F7F7', fontSize: 16, paddingTop: 12 }, children: error }) : _jsxs(_Fragment, { children: [actions?.voice && row('voice', actions.voiceLabel ?? 'Internet call', 'Wi-Fi or mobile data'), validPhone && row('phone', 'Phone call', validPhone)] }) })] });
46
48
  }
@@ -1,8 +1,10 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRobonoLocale } from './localization.js';
2
3
  import { useEffect, useRef, useState } from 'react';
3
4
  import { ActivityIndicator, FlatList, Pressable, Text, View } from 'react-native';
4
5
  /** Main-screen search covers retained messages beyond the current inbox page. */
5
6
  export function InboxMessageSearch({ client, query, appearance, name, chats, onSelect }) {
7
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
6
8
  const c = appearance.colors, [rows, setRows] = useState([]), [next, setNext] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState('');
7
9
  const controller = useRef(null), generation = useRef(0);
8
10
  async function search(more = false) {
@@ -22,7 +24,7 @@ export function InboxMessageSearch({ client, query, appearance, name, chats, onS
22
24
  catch (e) {
23
25
  if (version === generation.current && !control.signal.aborted) {
24
26
  setRows([]);
25
- setError(e instanceof Error ? e.message : 'Messages could not be searched.');
27
+ setError(e instanceof Error ? e.message : uiText("Messages could not be searched."));
26
28
  }
27
29
  }
28
30
  finally {
@@ -38,5 +40,5 @@ export function InboxMessageSearch({ client, query, appearance, name, chats, onS
38
40
  const timer = setTimeout(() => void search(), 300);
39
41
  return () => { clearTimeout(timer); ++generation.current; controller.current?.abort(); };
40
42
  }, [client, query]);
41
- return _jsx(FlatList, { style: { flex: 1 }, data: rows, keyExtractor: row => row.message.conversationId + ':' + row.message.id, keyboardShouldPersistTaps: "handled", ListHeaderComponent: _jsxs(View, { children: [chats.length > 0 && _jsxs(_Fragment, { children: [_jsx(Text, { style: { paddingHorizontal: 16, paddingVertical: 8, color: c.secondary, fontSize: 14, fontWeight: '600' }, children: "Chats" }), chats.map(chat => _jsx(Pressable, { accessibilityRole: "button", onPress: () => onSelect(chat), style: { minHeight: 48, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: name(chat) }) }, chat.id))] }), _jsx(Text, { style: { paddingHorizontal: 16, paddingVertical: 12, color: c.secondary, fontSize: 14, fontWeight: '600' }, children: "Messages" })] }), ListEmptyComponent: !busy && !error ? _jsx(Text, { style: { padding: 16, color: c.secondary }, children: "No matching messages." }) : null, renderItem: ({ item }) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Message in ${name(item.conversation)}: ${item.message.text}`, onPress: () => onSelect(item.conversation, item.message.id), style: { paddingHorizontal: 16, paddingVertical: 12, gap: 5, borderBottomWidth: 1, borderBottomColor: c.foreground + '16' }, children: [_jsxs(View, { style: { flexDirection: 'row', gap: 8 }, children: [_jsx(Text, { numberOfLines: 1, style: { flex: 1, fontSize: 16, fontWeight: '600', color: c.foreground }, children: name(item.conversation) }), _jsx(Text, { style: { fontSize: 12, color: c.secondary }, children: new Date(item.message.createdAt).toLocaleDateString() })] }), _jsx(Text, { numberOfLines: 3, style: { fontSize: 15, lineHeight: 21, color: c.secondary }, children: item.message.text })] }), ListFooterComponent: _jsxs(View, { children: [busy && _jsx(ActivityIndicator, { accessibilityLabel: "Searching all messages", color: c.accent, style: { padding: 16 } }), !!error && _jsxs(_Fragment, { children: [_jsx(Text, { accessibilityRole: "alert", style: { padding: 16, color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void search(), style: { minHeight: 44, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Try again" }) })] }), next && !busy && !error && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void search(true), style: { minHeight: 48, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "More results" }) })] }) });
43
+ return _jsx(FlatList, { style: { flex: 1 }, data: rows, keyExtractor: row => row.message.conversationId + ':' + row.message.id, keyboardShouldPersistTaps: "handled", ListHeaderComponent: _jsxs(View, { children: [chats.length > 0 && _jsxs(_Fragment, { children: [_jsx(Text, { style: { paddingHorizontal: 16, paddingVertical: 8, color: c.secondary, fontSize: 14, fontWeight: '600' }, children: uiText("Chats") }), chats.map(chat => _jsx(Pressable, { accessibilityRole: "button", onPress: () => onSelect(chat), style: { minHeight: 48, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: name(chat) }) }, chat.id))] }), _jsx(Text, { style: { paddingHorizontal: 16, paddingVertical: 12, color: c.secondary, fontSize: 14, fontWeight: '600' }, children: uiText("Messages") })] }), ListEmptyComponent: !busy && !error ? _jsx(Text, { style: { padding: 16, color: c.secondary }, children: uiText("No matching messages.") }) : null, renderItem: ({ item }) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Message in {value1}: {value2}", { value1: name(item.conversation), value2: item.message.text ?? '' }), onPress: () => onSelect(item.conversation, item.message.id), style: { paddingHorizontal: 16, paddingVertical: 12, gap: 5, borderBottomWidth: 1, borderBottomColor: c.foreground + '16' }, children: [_jsxs(View, { style: { flexDirection: 'row', gap: 8 }, children: [_jsx(Text, { numberOfLines: 1, style: { flex: 1, fontSize: 16, fontWeight: '600', color: c.foreground }, children: name(item.conversation) }), _jsx(Text, { style: { fontSize: 12, color: c.secondary }, children: new Date(item.message.createdAt).toLocaleDateString(uiLocale) })] }), _jsx(Text, { numberOfLines: 3, style: { fontSize: 15, lineHeight: 21, color: c.secondary }, children: item.message.text })] }), ListFooterComponent: _jsxs(View, { children: [busy && _jsx(ActivityIndicator, { accessibilityLabel: uiText("Searching all messages"), color: c.accent, style: { padding: 16 } }), !!error && _jsxs(_Fragment, { children: [_jsx(Text, { accessibilityRole: "alert", style: { padding: 16, color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void search(), style: { minHeight: 44, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: uiText("Try again") }) })] }), next && !busy && !error && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void search(true), style: { minHeight: 48, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: uiText("More results") }) })] }) });
42
44
  }
package/dist/inbox.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRobonoLocale } from './localization.js';
2
3
  import { MessageMenuRow } from './message-menu.js';
3
4
  import { InboxMessageSearch } from './inbox-search.js';
4
5
  import { MessageIcon } from './icons.js';
@@ -8,6 +9,7 @@ import { defaultMessagingSettings } from '@robono/messaging';
8
9
  import { MessagingPopup, MessagingMenuSurface, MessagingPopupHeaderBoundary, messagingPopupColors, messagingPopupBackdrop } from './popup.js';
9
10
  /** Compact Messenger chat rows: avatar, name, last message, time and unread badge. */
10
11
  export function MessagingInbox({ client, userId, contacts = {}, appearance, active = true, onSelect, onDeleted, companyName, headerActions, searchText }) {
12
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
11
13
  const state = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot), design = appearance ?? state.appearance ?? defaultMessagingSettings().appearance, c = design.colors;
12
14
  useEffect(() => { const update = (value) => { if (active && (value === null || value === 'active'))
13
15
  client.start();
@@ -45,25 +47,25 @@ export function MessagingInbox({ client, userId, contacts = {}, appearance, acti
45
47
  setConfirm(false);
46
48
  }
47
49
  catch (e) {
48
- setError(e instanceof Error ? e.message : 'Chat options could not be saved.');
50
+ setError(e instanceof Error ? e.message : uiText("Chat options could not be saved."));
49
51
  }
50
52
  finally {
51
53
  setBusy(false);
52
54
  } }
53
55
  const preview = (row) => { const m = row.lastMessage; if (!m)
54
56
  return 'No messages yet'; const text = m.kind === 'text' ? m.text ?? '' : ({ voice: 'Voice message', image: 'Photo', video: 'Video', document: 'Document', poll: 'Poll' }[m.kind] ?? 'Message'); return (m.senderId === userId ? 'You: ' : '') + text; };
55
- return _jsxs(View, { style: { flex: 1, minHeight: 0, backgroundColor: c.background }, children: [_jsx(MessagingPopupHeaderBoundary, { children: _jsxs(View, { testID: "messaging-inbox-header", style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', minHeight: 64, paddingHorizontal: 16, paddingVertical: 8, gap: 8 }, children: [_jsx(Text, { accessibilityRole: "header", numberOfLines: 1, style: { flex: 1, color: c.foreground, fontSize: 20, fontWeight: '700' }, children: companyName ?? design.name }), headerActions] }) }), _jsxs(View, { style: { margin: 12, marginTop: 8, flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 10, paddingHorizontal: 10, backgroundColor: c.foreground + '0b' }, children: [_jsx(MessageIcon, { name: "search", size: 20, color: c.secondary }), _jsx(TextInput, { accessibilityLabel: "Search chats", placeholder: state.globalSearchAvailable ? "Search chats and messages" : "Search chats", placeholderTextColor: c.secondary, value: query, onChangeText: setQuery, maxLength: 200, autoCapitalize: "none", autoCorrect: false, style: { flex: 1, minHeight: 44, color: c.foreground, fontSize: 16 } }), !!query && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Clear chat search", onPress: () => setQuery(''), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 20, color: c.secondary }) })] }), state.error && _jsxs(View, { style: { padding: 12 }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: state.error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void client.refresh().catch(() => { }), children: _jsx(Text, { style: { color: c.accent, paddingVertical: 8 }, children: "Try again" }) })] }), query.trim() && state.globalSearchAvailable ? _jsx(InboxMessageSearch, { client: client, query: query.trim(), appearance: design, name: title, chats: state.conversations.filter(row => title(row).toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), onSelect: onSelect }, userId) : _jsx(FlatList, { data: state.conversations.filter(row => !query.trim() || [title(row), row.lastMessage?.text ?? '', searchText?.(row) ?? ''].join(' ').toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), keyExtractor: row => row.id, contentContainerStyle: { flexGrow: 1 }, ItemSeparatorComponent: () => _jsx(View, { style: { height: 1, backgroundColor: c.foreground + '12', marginLeft: 64 } }), ListEmptyComponent: _jsx(Text, { style: { padding: 20, textAlign: 'center', color: c.secondary }, children: state.loading ? 'Loading chats…' : state.error ? '' : query ? 'No matching chats.' : 'No chats yet' }), ListFooterComponent: state.hasMore ? _jsx(Pressable, { accessibilityRole: "button", disabled: state.loading, onPress: () => void client.loadMore().catch(() => { }), children: _jsx(Text, { style: { padding: 12, textAlign: 'center', color: c.accent }, children: state.loading ? 'Loading…' : 'More chats' }) }) : null, renderItem: ({ item }) => {
57
+ return _jsxs(View, { style: { flex: 1, minHeight: 0, backgroundColor: c.background }, children: [_jsx(MessagingPopupHeaderBoundary, { children: _jsxs(View, { testID: "messaging-inbox-header", style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', minHeight: 64, paddingHorizontal: 16, paddingVertical: 8, gap: 8 }, children: [_jsx(Text, { accessibilityRole: "header", numberOfLines: 1, style: { flex: 1, color: c.foreground, fontSize: 20, fontWeight: '700' }, children: companyName ?? design.name }), headerActions] }) }), _jsxs(View, { style: { margin: 12, marginTop: 8, flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 10, paddingHorizontal: 10, backgroundColor: c.foreground + '0b' }, children: [_jsx(MessageIcon, { name: "search", size: 20, color: c.secondary }), _jsx(TextInput, { accessibilityLabel: uiText("Search chats"), placeholder: state.globalSearchAvailable ? uiText("Search chats and messages") : uiText("Search chats"), placeholderTextColor: c.secondary, value: query, onChangeText: setQuery, maxLength: 200, autoCapitalize: "none", autoCorrect: false, style: { flex: 1, minHeight: 44, color: c.foreground, fontSize: 16 } }), !!query && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Clear chat search"), onPress: () => setQuery(''), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 20, color: c.secondary }) })] }), state.error && _jsxs(View, { style: { padding: 12 }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: state.error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void client.refresh().catch(() => { }), children: _jsx(Text, { style: { color: c.accent, paddingVertical: 8 }, children: uiText("Try again") }) })] }), query.trim() && state.globalSearchAvailable ? _jsx(InboxMessageSearch, { client: client, query: query.trim(), appearance: design, name: title, chats: state.conversations.filter(row => title(row).toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), onSelect: onSelect }, userId) : _jsx(FlatList, { data: state.conversations.filter(row => !query.trim() || [title(row), row.lastMessage?.text ?? '', searchText?.(row) ?? ''].join(' ').toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), keyExtractor: row => row.id, contentContainerStyle: { flexGrow: 1 }, ItemSeparatorComponent: () => _jsx(View, { style: { height: 1, backgroundColor: c.foreground + '12', marginLeft: 64 } }), ListEmptyComponent: _jsx(Text, { style: { padding: 20, textAlign: 'center', color: c.secondary }, children: state.loading ? uiText("Loading chats…") : state.error ? '' : query ? uiText("No matching chats.") : uiText("No chats yet") }), ListFooterComponent: state.hasMore ? _jsx(Pressable, { accessibilityRole: "button", disabled: state.loading, onPress: () => void client.loadMore().catch(() => { }), children: _jsx(Text, { style: { padding: 12, textAlign: 'center', color: c.accent }, children: state.loading ? uiText("Loading…") : uiText("More chats") }) }) : null, renderItem: ({ item }) => {
56
58
  const person = item.contactId ? contacts[item.contactId] : undefined, title = item.kind === 'group' ? item.name ?? 'Group' : person?.name ?? item.contactId ?? 'Chat';
57
59
  const date = item.lastMessage ? new Date(item.lastMessage.createdAt) : null;
58
60
  return _jsxs(Pressable, { ref: node => { if (node)
59
61
  rows.current.set(item.id, node);
60
62
  else
61
- rows.current.delete(item.id); }, accessibilityRole: "button", accessibilityLabel: `${title}${item.unreadCount ? `, ${item.unreadCount} unread` : ''}`, onPress: () => onSelect(item), onLongPress: () => openOptions(item), accessibilityActions: state.inboxActionsAvailable ? [{ name: 'showOptions', label: 'Chat options' }] : [], onAccessibilityAction: e => { if (e.nativeEvent.actionName === 'showOptions')
62
- openOptions(item); }, ...(Platform.OS === 'web' ? { onContextMenu: (e) => { e.preventDefault(); openOptions(item); } } : {}), style: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 10, minHeight: 68 }, children: [person?.avatarUrl ? _jsx(Image, { source: { uri: person.avatarUrl }, style: { width: 40, height: 40, borderRadius: 20 } }) : _jsx(View, { style: { width: 40, height: 40, borderRadius: 20, backgroundColor: c.accent + '20', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 18, fontWeight: '700' }, children: title.slice(0, 1).toUpperCase() }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsxs(Text, { numberOfLines: 1, style: { color: c.foreground, fontSize: 16, fontWeight: '700' }, children: [title, item.muted ? ' · Muted' : ''] }), _jsx(Text, { numberOfLines: 1, style: { color: c.secondary, marginTop: 3, fontSize: 14 }, children: preview(item) })] }), _jsxs(View, { style: { alignItems: 'flex-end', gap: 4 }, children: [_jsx(Text, { style: { color: item.unreadCount ? c.accent : c.secondary, fontSize: 12 }, children: date ? date.toDateString() === new Date().toDateString() ? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '' }), item.unreadCount > 0 && _jsx(View, { style: { minWidth: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: c.accent, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 5 }, children: _jsx(Text, { style: { color: c.accent, fontSize: 10, fontWeight: '900' }, children: item.unreadCount > 99 ? '99+' : item.unreadCount }) })] })] });
63
+ rows.current.delete(item.id); }, accessibilityRole: "button", accessibilityLabel: `${title}${item.unreadCount ? `, ${item.unreadCount} unread` : ''}`, onPress: () => onSelect(item), onLongPress: () => openOptions(item), accessibilityActions: state.inboxActionsAvailable ? [{ name: 'showOptions', label: uiText("Chat options") }] : [], onAccessibilityAction: e => { if (e.nativeEvent.actionName === 'showOptions')
64
+ openOptions(item); }, ...(Platform.OS === 'web' ? { onContextMenu: (e) => { e.preventDefault(); openOptions(item); } } : {}), style: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 10, minHeight: 68 }, children: [person?.avatarUrl ? _jsx(Image, { source: { uri: person.avatarUrl }, style: { width: 40, height: 40, borderRadius: 20 } }) : _jsx(View, { style: { width: 40, height: 40, borderRadius: 20, backgroundColor: c.accent + '20', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 18, fontWeight: '700' }, children: title.slice(0, 1).toUpperCase() }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsxs(Text, { numberOfLines: 1, style: { color: c.foreground, fontSize: 16, fontWeight: '700' }, children: [title, item.muted ? uiText(" · Muted") : ''] }), _jsx(Text, { numberOfLines: 1, style: { color: c.secondary, marginTop: 3, fontSize: 14 }, children: preview(item) })] }), _jsxs(View, { style: { alignItems: 'flex-end', gap: 4 }, children: [_jsx(Text, { style: { color: item.unreadCount ? c.accent : c.secondary, fontSize: 12 }, children: date ? date.toDateString() === new Date().toDateString() ? date.toLocaleTimeString(uiLocale, { hour: 'numeric', minute: '2-digit' }) : date.toLocaleDateString(uiLocale, { month: 'short', day: 'numeric' }) : '' }), item.unreadCount > 0 && _jsx(View, { style: { minWidth: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: c.accent, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 5 }, children: _jsx(Text, { style: { color: c.accent, fontSize: 10, fontWeight: '900' }, children: item.unreadCount > 99 ? '99+' : item.unreadCount }) })] })] });
63
65
  } }), selected && _jsxs(_Fragment, { children: [_jsx(MessagingMenuSurface, { visible: menuVisible, onDismiss: dismissed, onClose: () => { if (!busy)
64
66
  setSelected(null); }, children: rect => {
65
67
  const width = Math.min(218, rect.width - 24), top = Math.max(12, Math.min(anchor.y - rect.top, rect.height - 124));
66
- return _jsxs(View, { style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close chat options", onPress: () => { if (!busy)
67
- setSelected(null); }, style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsx(View, { testID: "messaging-inbox-dropdown", style: { position: 'absolute', left: Math.max(12, Math.min(anchor.x - rect.left, rect.width - width - 12)), top, width, maxHeight: Math.max(44, rect.height - top - 12), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden' }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", children: [_jsx(MessageMenuRow, { icon: selected.muted ? 'notifications-outline' : 'notifications-off-outline', label: selected.muted ? 'Unmute' : 'Mute', disabled: busy, onPress: () => void act(false) }), _jsx(MessageMenuRow, { icon: "trash-outline", label: "Delete chat", danger: true, divider: true, disabled: busy, onPress: showConfirmation }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#ffffff', padding: 12 }, children: error })] }) })] });
68
- } }), confirm && _jsxs(MessagingPopup, { visible: confirmVisible, onDismiss: dismissed, compact: true, title: "Delete chat with " + title(selected) + "?", foreground: c.foreground, closeLabel: "Close delete chat", closeDisabled: busy, background: c.background, onClose: returnToMenu, children: [_jsx(Text, { style: { color: messagingPopupColors.secondary, marginTop: 8 }, children: "This removes the chat and its history from your view. Other people keep their copy. New messages can bring the chat back." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground, paddingVertical: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: returnToMenu, 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: "Cancel" }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Confirm delete chat", disabled: busy, onPress: () => void act(true), 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" }) })] })] })] })] });
68
+ return _jsxs(View, { style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: uiText("Close chat options"), onPress: () => { if (!busy)
69
+ setSelected(null); }, style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsx(View, { testID: "messaging-inbox-dropdown", style: { position: 'absolute', left: Math.max(12, Math.min(anchor.x - rect.left, rect.width - width - 12)), top, width, maxHeight: Math.max(44, rect.height - top - 12), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden' }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", children: [_jsx(MessageMenuRow, { icon: selected.muted ? 'notifications-outline' : 'notifications-off-outline', label: selected.muted ? uiText("Unmute") : uiText("Mute"), disabled: busy, onPress: () => void act(false) }), _jsx(MessageMenuRow, { icon: "trash-outline", label: uiText("Delete chat"), danger: true, divider: true, disabled: busy, onPress: showConfirmation }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#ffffff', padding: 12 }, children: error })] }) })] });
70
+ } }), confirm && _jsxs(MessagingPopup, { visible: confirmVisible, onDismiss: dismissed, compact: true, title: uiText('Delete chat with {name}?', { name: title(selected) }), foreground: c.foreground, closeLabel: uiText("Close delete chat"), closeDisabled: busy, background: c.background, onClose: returnToMenu, children: [_jsx(Text, { style: { color: messagingPopupColors.secondary, marginTop: 8 }, children: uiText("This removes the chat and its history from your view. Other people keep their copy. New messages can bring the chat back.") }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground, paddingVertical: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: returnToMenu, 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: uiText("Cancel") }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Confirm delete chat"), disabled: busy, onPress: () => void act(true), 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") }) })] })] })] })] });
69
71
  }