@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
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+ import { type RobonoLocale } from '@robono/messaging';
3
+ /** Wrap multiple individual components once, or pass interfaceLocale to a supplied screen. */
4
+ export declare function RobonoLocaleProvider({ value, children }: {
5
+ value?: RobonoLocale;
6
+ children: React.ReactNode;
7
+ }): React.JSX.Element;
8
+ export declare function useRobonoLocale(): {
9
+ locale: string;
10
+ direction: "ltr" | "rtl";
11
+ configuration: {
12
+ locale: string;
13
+ direction: "ltr" | "rtl";
14
+ messages?: Readonly<Record<string, string>>;
15
+ plurals?: Readonly<Record<string, Partial<Record<Intl.LDMLPluralRule, string>>>>;
16
+ };
17
+ text: (source: string, values?: import("@robono/messaging").RobonoTextValues) => string;
18
+ count: (value: number, singular: string, plural: string, values?: import("@robono/messaging").RobonoTextValues) => string;
19
+ number: (value: number, options?: Intl.NumberFormatOptions) => string;
20
+ date: (value: Date | number, options?: Intl.DateTimeFormatOptions) => string;
21
+ };
22
+ export declare function localizeMessagingAppearance<T extends {
23
+ labels: Record<string, string>;
24
+ }>(appearance: T, text: (source: string) => string): T;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { createContext, useContext, useMemo } from 'react';
3
+ import { createRobonoLocalizer } from '@robono/messaging';
4
+ const defaultLocale = createRobonoLocalizer();
5
+ const LocaleContext = createContext(defaultLocale);
6
+ /** Wrap multiple individual components once, or pass interfaceLocale to a supplied screen. */
7
+ export function RobonoLocaleProvider({ value, children }) {
8
+ const parent = useContext(LocaleContext), locale = useMemo(() => value ? createRobonoLocalizer(value) : parent, [value, parent]);
9
+ return _jsx(LocaleContext.Provider, { value: locale, children: children });
10
+ }
11
+ export function useRobonoLocale() { return useContext(LocaleContext); }
12
+ export function localizeMessagingAppearance(appearance, text) { return { ...appearance, labels: Object.fromEntries(Object.entries(appearance.labels).map(([key, value]) => [key, text(value)])) }; }
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRobonoLocale } from './localization.js';
2
3
  import { useCallback, useEffect, useRef, useState } from 'react';
3
4
  import { AppState, Platform, Pressable, Text, View } from 'react-native';
4
5
  /** Recover settings after reconnect/foreground without capturing GPS or loading a map. */
@@ -38,5 +39,6 @@ export function useMessengerMaps(workspace) {
38
39
  return { available, error, retry: refresh };
39
40
  }
40
41
  export function MessengerMapsNotice({ error, retry }) {
41
- return error ? _jsxs(View, { style: { backgroundColor: '#171717', paddingHorizontal: 16, paddingVertical: 8, flexDirection: 'row', gap: 12, alignItems: 'center' }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: '#FAFAFA', flex: 1 }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Retry Maps", onPress: () => void retry(), style: { padding: 8 }, children: _jsx(Text, { style: { color: '#FF8A2C' }, children: "Retry Maps" }) })] }) : null;
42
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
43
+ return error ? _jsxs(View, { style: { backgroundColor: '#171717', paddingHorizontal: 16, paddingVertical: 8, flexDirection: 'row', gap: 12, alignItems: 'center' }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: '#FAFAFA', flex: 1 }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Retry Maps"), onPress: () => void retry(), style: { padding: 8 }, children: _jsx(Text, { style: { color: '#FF8A2C' }, children: uiText("Retry Maps") }) })] }) : null;
42
44
  }
package/dist/media-ui.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRobonoLocale } from './localization.js';
2
3
  import { messengerVoiceWaveform } from './voice-waveform.js';
3
4
  import { createPhotoGesture } from './photo-gesture.js';
4
5
  import { useRef, useContext, useState, useEffect } from 'react';
@@ -7,6 +8,7 @@ import { MediaReadiness } from './media-readiness.js';
7
8
  import { MessageIcon, duration, fileSize } from './icons.js';
8
9
  /** Same chat controls on web, iOS and Android. Audio engines supply state, not a different UI. */
9
10
  export function VoicePlayback({ ink, accent, waveform, showWaveform = true, footer, onLongPress, mine = false, heard = false, onPlayed, background, playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek }) {
11
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
10
12
  const width = useRef(1), held = useRef(false), [played, setPlayed] = useState(false), progress = durationMs ? Math.max(0, Math.min(1, positionMs / durationMs)) : 0;
11
13
  useEffect(() => { if (playing && positionMs > 0) {
12
14
  setPlayed(true);
@@ -14,18 +16,21 @@ export function VoicePlayback({ ink, accent, waveform, showWaveform = true, foot
14
16
  } }, [playing, positionMs, onPlayed]);
15
17
  const bars = showWaveform ? messengerVoiceWaveform(waveform, durationMs) : null, fill = mine ? ink : accent;
16
18
  const hold = () => { held.current = true; onLongPress?.(); };
17
- return _jsxs(View, { testID: "voice-message-player", style: { width: 220, maxWidth: '100%', minHeight: 44, borderRadius: 24, borderWidth: 1, borderColor: mine ? '#ffffff24' : '#2A2A2A', backgroundColor: background ?? (mine ? '#1F5F7A' : '#1D1D1D'), paddingLeft: 5, paddingRight: 8, paddingVertical: 3 }, children: [_jsxs(View, { style: { flexDirection: 'row', direction: 'ltr', alignItems: 'center', gap: 8 }, children: [_jsx(Pressable, { testID: "voice-play-control", accessibilityRole: "button", accessibilityLabel: playing ? 'Pause voice message' : 'Play voice message', accessibilityState: { busy: loading }, disabled: loading, hitSlop: 6, delayLongPress: 360, onPressIn: () => { held.current = false; }, onPress: () => { if (!held.current)
18
- onToggle(); }, onLongPress: hold, style: { width: 36, height: 36, borderRadius: 18, paddingLeft: playing ? 0 : 2, backgroundColor: mine ? '#ffffff29' : heard || played ? accent : '#2e9d63', alignItems: 'center', justifyContent: 'center' }, children: loading ? _jsx(ActivityIndicator, { size: "small", color: ink }) : _jsx(MessageIcon, { name: playing ? 'pause' : 'play', color: ink, size: 20 }) }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 7 }, children: [_jsx(Pressable, { onLongPress: hold, accessibilityRole: "adjustable", accessibilityLabel: showWaveform ? 'Voice waveform' : 'Voice position', accessibilityValue: { min: 0, max: Math.ceil(durationMs / 1000), now: Math.floor(positionMs / 1000) }, accessibilityActions: [{ name: 'increment', label: 'Forward five seconds' }, { name: 'decrement', label: 'Back five seconds' }], onAccessibilityAction: e => onSeek?.(Math.max(0, Math.min(durationMs, positionMs + (e.nativeEvent.actionName === 'increment' ? 5000 : -5000)))), onLayout: e => { width.current = e.nativeEvent.layout.width; }, onPressIn: () => { held.current = false; }, onPress: e => { if (held.current)
19
+ return _jsxs(View, { testID: "voice-message-player", style: { width: 220, maxWidth: '100%', minHeight: 44, borderRadius: 24, borderWidth: 1, borderColor: mine ? '#ffffff24' : '#2A2A2A', backgroundColor: background ?? (mine ? '#1F5F7A' : '#1D1D1D'), paddingLeft: 5, paddingRight: 8, paddingVertical: 3 }, children: [_jsxs(View, { style: { flexDirection: 'row', direction: 'ltr', alignItems: 'center', gap: 8 }, children: [_jsx(Pressable, { testID: "voice-play-control", accessibilityRole: "button", accessibilityLabel: playing ? uiText("Pause voice message") : uiText("Play voice message"), accessibilityState: { busy: loading }, disabled: loading, hitSlop: 6, delayLongPress: 360, onPressIn: () => { held.current = false; }, onPress: () => { if (!held.current)
20
+ onToggle(); }, onLongPress: hold, style: { width: 36, height: 36, borderRadius: 18, paddingLeft: playing ? 0 : 2, backgroundColor: mine ? '#ffffff29' : heard || played ? accent : '#2e9d63', alignItems: 'center', justifyContent: 'center' }, children: loading ? _jsx(ActivityIndicator, { size: "small", color: ink }) : _jsx(MessageIcon, { name: playing ? 'pause' : 'play', color: ink, size: 20 }) }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 7 }, children: [_jsx(Pressable, { onLongPress: hold, accessibilityRole: "adjustable", accessibilityLabel: showWaveform ? uiText("Voice waveform") : uiText("Voice position"), accessibilityValue: { min: 0, max: Math.ceil(durationMs / 1000), now: Math.floor(positionMs / 1000) }, accessibilityActions: [{ name: 'increment', label: uiText("Forward five seconds") }, { name: 'decrement', label: uiText("Back five seconds") }], onAccessibilityAction: e => onSeek?.(Math.max(0, Math.min(durationMs, positionMs + (e.nativeEvent.actionName === 'increment' ? 5000 : -5000)))), onLayout: e => { width.current = e.nativeEvent.layout.width; }, onPressIn: () => { held.current = false; }, onPress: e => { if (held.current)
19
21
  return; const bounds = Platform.OS === 'web' ? e.currentTarget.getBoundingClientRect() : null; const x = bounds ? e.nativeEvent.clientX - bounds.left : e.nativeEvent.locationX; const total = bounds?.width ?? width.current; if (total > 0 && Number.isFinite(x))
20
22
  onSeek?.(Math.max(0, Math.min(1, x / total)) * durationMs); }, style: { height: bars ? 24 : 9, transform: [{ translateY: 4 }], flexDirection: 'row', alignItems: 'center', gap: 1 }, children: bars ? bars.map((sample, i) => _jsx(View, { pointerEvents: "none", style: { flex: 1, flexBasis: 0, borderRadius: 2, overflow: 'hidden', backgroundColor: ink + (mine ? '33' : '38'), height: 2 + sample * 18 }, children: _jsx(View, { style: { height: '100%', backgroundColor: fill, width: `${Math.max(0, Math.min(1, progress * bars.length - i)) * 100}%` } }) }, i)) : _jsx(View, { pointerEvents: "none", style: { flex: 1, height: 9, borderRadius: 5, overflow: 'hidden', backgroundColor: ink + '33' }, children: _jsx(View, { style: { height: 9, borderRadius: 5, backgroundColor: fill, width: `${progress * 100}%` } }) }) }), _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 4, paddingRight: footer ? 6 : 0 }, children: [_jsx(Text, { testID: "voice-duration", style: { color: mine ? ink : '#A3A3A3', fontSize: 11, fontWeight: '800', fontVariant: ['tabular-nums'] }, children: duration(playing || positionMs > 0 ? Math.max(0, durationMs - positionMs) : durationMs) }), footer && _jsx(View, { testID: "voice-message-actions", style: { flexShrink: 1, maxWidth: 112, alignItems: 'flex-end' }, children: footer })] })] })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: ink, fontSize: 12, marginTop: 6 }, children: error })] });
21
23
  }
22
24
  export function MediaPlaybackControls({ playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek, onFullscreen, fullscreen = false }) {
25
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
23
26
  const width = useRef(1);
24
- return _jsxs(View, { style: { paddingHorizontal: 18, paddingVertical: 10, gap: 6, backgroundColor: '#111316' }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: playing ? 'Pause video' : 'Play video', disabled: loading, onPress: onToggle, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: loading ? _jsx(ActivityIndicator, { color: "#fff" }) : _jsx(MessageIcon, { name: playing ? 'pause' : 'play', color: "#fff" }) }), _jsx(Text, { style: { color: '#ddd', fontSize: 12, fontVariant: ['tabular-nums'] }, children: duration(positionMs) }), _jsx(Pressable, { accessibilityRole: "adjustable", accessibilityLabel: "Video position", accessibilityValue: { min: 0, max: Math.ceil(durationMs / 1000), now: Math.floor(positionMs / 1000) }, accessibilityActions: [{ name: 'increment' }, { name: 'decrement' }], onAccessibilityAction: e => onSeek?.(Math.max(0, Math.min(durationMs, positionMs + (e.nativeEvent.actionName === 'increment' ? 5000 : -5000)))), onLayout: e => { width.current = e.nativeEvent.layout.width; }, onPress: e => { const bounds = Platform.OS === "web" ? e.currentTarget.getBoundingClientRect() : null; const x = bounds ? e.nativeEvent.clientX - bounds.left : e.nativeEvent.locationX, total = bounds?.width ?? width.current; if (total > 0 && Number.isFinite(x))
25
- onSeek?.(Math.max(0, Math.min(1, x / total)) * durationMs); }, style: { flex: 1, height: 44, justifyContent: 'center' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#ffffff44' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#fff', width: `${durationMs ? Math.min(100, positionMs / durationMs * 100) : 0}%` } }) }) }), _jsx(Text, { style: { color: '#bbb', fontSize: 12, fontVariant: ['tabular-nums'] }, children: duration(durationMs) }), onFullscreen && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: fullscreen ? "Exit full screen" : "Full screen video", disabled: loading, onPress: onFullscreen, style: { width: 44, height: 44, alignItems: "center", justifyContent: "center" }, children: _jsx(MessageIcon, { name: fullscreen ? "close" : "open-in-new", color: "#fff", size: 22 }) })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', fontSize: 13 }, children: error })] });
27
+ return _jsxs(View, { style: { paddingHorizontal: 18, paddingVertical: 10, gap: 6, backgroundColor: '#111316' }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: playing ? uiText("Pause video") : uiText("Play video"), disabled: loading, onPress: onToggle, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: loading ? _jsx(ActivityIndicator, { color: "#fff" }) : _jsx(MessageIcon, { name: playing ? 'pause' : 'play', color: "#fff" }) }), _jsx(Text, { style: { color: '#ddd', fontSize: 12, fontVariant: ['tabular-nums'] }, children: duration(positionMs) }), _jsx(Pressable, { accessibilityRole: "adjustable", accessibilityLabel: uiText("Video position"), accessibilityValue: { min: 0, max: Math.ceil(durationMs / 1000), now: Math.floor(positionMs / 1000) }, accessibilityActions: [{ name: 'increment' }, { name: 'decrement' }], onAccessibilityAction: e => onSeek?.(Math.max(0, Math.min(durationMs, positionMs + (e.nativeEvent.actionName === 'increment' ? 5000 : -5000)))), onLayout: e => { width.current = e.nativeEvent.layout.width; }, onPress: e => { const bounds = Platform.OS === "web" ? e.currentTarget.getBoundingClientRect() : null; const x = bounds ? e.nativeEvent.clientX - bounds.left : e.nativeEvent.locationX, total = bounds?.width ?? width.current; if (total > 0 && Number.isFinite(x))
28
+ onSeek?.(Math.max(0, Math.min(1, x / total)) * durationMs); }, style: { flex: 1, height: 44, justifyContent: 'center' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#ffffff44' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#fff', width: `${durationMs ? Math.min(100, positionMs / durationMs * 100) : 0}%` } }) }) }), _jsx(Text, { style: { color: '#bbb', fontSize: 12, fontVariant: ['tabular-nums'] }, children: duration(durationMs) }), onFullscreen && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: fullscreen ? uiText("Exit full screen") : uiText("Full screen video"), disabled: loading, onPress: onFullscreen, style: { width: 44, height: 44, alignItems: "center", justifyContent: "center" }, children: _jsx(MessageIcon, { name: fullscreen ? "close" : "open-in-new", color: "#fff", size: 22 }) })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', fontSize: 13 }, children: error })] });
26
29
  }
27
30
  /** A full-size photo surface. The same tap-to-hide-controls, pinch and pan handling runs in the preview and native app. */
28
- export function ZoomablePhoto({ uri, label = 'Photo', onTap, expanded = false }) {
31
+ export function ZoomablePhoto({ uri, label, onTap, expanded = false }) {
32
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
33
+ label ??= uiText('Photo');
29
34
  const mediaReady = useContext(MediaReadiness);
30
35
  const [transform, setTransform] = useState({ zoom: 1, x: 0, y: 0 }), [error, setError] = useState(false);
31
36
  const [viewport, setViewport] = useState({ width: 1, height: 1 }), [imageSize, setImageSize] = useState({ width: 1, height: 1 });
@@ -96,13 +101,14 @@ export function ZoomablePhoto({ uri, label = 'Photo', onTap, expanded = false })
96
101
  } }, () => { }); return () => { active = false; }; }, [uri, controller]);
97
102
  useEffect(() => { controller.reset(); }, [expanded, controller]);
98
103
  useEffect(() => { controller.layout(stageWidth, stageHeight); }, [controller, stageWidth, stageHeight]);
99
- return _jsxs(View, { style: { flex: 1, minHeight: 0, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }, onLayout: e => setViewport(e.nativeEvent.layout), children: [_jsx(View, { testID: "photo-image-stage", style: { width: stageWidth, height: stageHeight, transform: rotated ? [{ rotate: '90deg' }] : [] }, children: _jsx(View, { ref: webRef, ...(Platform.OS === 'web' ? {} : pan.panHandlers), testID: "photo-gesture-surface", accessible: true, accessibilityRole: "button", accessibilityLabel: "Toggle photo controls", onAccessibilityTap: () => tap.current?.(), ...(Platform.OS === 'web' ? { tabIndex: 0 } : {}), style: { flex: 1, overflow: 'hidden', ...(Platform.OS === 'web' ? { touchAction: 'none', userSelect: 'none' } : {}) }, children: _jsx(Image, { testID: "photo-zoom-image", accessibilityLabel: label, source: { uri }, resizeMode: "contain", onLoad: () => mediaReady('image-loaded', uri), onError: () => setError(true), style: { width: '100%', height: '100%', transform: [{ translateX: transform.x }, { translateY: transform.y }, { scale: transform.zoom }] } }) }) }), error && _jsx(Text, { accessibilityRole: "alert", style: { position: 'absolute', top: '50%', alignSelf: 'center', color: '#fff' }, children: "This photo could not be opened." })] });
104
+ return _jsxs(View, { style: { flex: 1, minHeight: 0, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }, onLayout: e => setViewport(e.nativeEvent.layout), children: [_jsx(View, { testID: "photo-image-stage", style: { width: stageWidth, height: stageHeight, transform: rotated ? [{ rotate: '90deg' }] : [] }, children: _jsx(View, { ref: webRef, ...(Platform.OS === 'web' ? {} : pan.panHandlers), testID: "photo-gesture-surface", accessible: true, accessibilityRole: "button", accessibilityLabel: uiText("Toggle photo controls"), onAccessibilityTap: () => tap.current?.(), ...(Platform.OS === 'web' ? { tabIndex: 0 } : {}), style: { flex: 1, overflow: 'hidden', ...(Platform.OS === 'web' ? { touchAction: 'none', userSelect: 'none' } : {}) }, children: _jsx(Image, { testID: "photo-zoom-image", accessibilityLabel: label, source: { uri }, resizeMode: "contain", onLoad: () => mediaReady('image-loaded', uri), onError: () => setError(true), style: { width: '100%', height: '100%', transform: [{ translateX: transform.x }, { translateY: transform.y }, { scale: transform.zoom }] } }) }) }), error && _jsx(Text, { accessibilityRole: "alert", style: { position: 'absolute', top: '50%', alignSelf: 'center', color: '#fff' }, children: uiText("This photo could not be opened.") })] });
100
105
  }
101
106
  export function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview, onDownload, onShare, onForward, onDelete, downloading = false, expanded, onExpandedChange }) {
107
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
102
108
  const [localExpanded, setLocalExpanded] = useState(false);
103
109
  const photoExpanded = kind === 'image' && (expanded ?? localExpanded), controls = !photoExpanded;
104
110
  const setExpanded = (value) => { setLocalExpanded(value); onExpandedChange?.(value); };
105
111
  useEffect(() => { setLocalExpanded(false); }, [uri]);
106
112
  const visual = kind === 'image' || kind === 'video';
107
- return _jsxs(View, { testID: "messenger-media-viewer", accessibilityViewIsModal: true, style: { flex: 1, minHeight: 0, backgroundColor: controls ? 'rgba(0,0,0,0.94)' : '#000000' }, children: [_jsxs(View, { style: { flex: 1, minHeight: 0, justifyContent: 'center', paddingHorizontal: visual && controls ? 12 : 0 }, children: [controls && _jsxs(View, { testID: "media-action-toolbar", style: { position: 'absolute', top: 12, left: 12, right: 12, zIndex: 3, minHeight: 52, borderRadius: 26, borderWidth: 1, borderColor: 'rgba(255,255,255,0.12)', backgroundColor: 'rgba(18,18,18,0.92)', paddingLeft: 8, paddingRight: 4, flexDirection: 'row', alignItems: 'center', gap: 2 }, children: [[[onDownload, 'Download', 'download-outline', downloading, false], [onDelete, 'Delete', 'trash-outline', false, true], [onForward, 'Forward', 'arrow-redo-outline', false, false], [onShare, 'Share', 'share-social-outline', false, false]].filter(([action, label]) => !!action || (visual && !reviewAction && label !== 'Download')).map(([action, label, icon, busy, destructive]) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `${label} media`, accessibilityState: { busy }, disabled: !action || busy || downloading, onPress: action, style: { opacity: !action ? .5 : 1, flex: 1, minWidth: 44, minHeight: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', gap: 1, paddingHorizontal: 2 }, children: [busy ? _jsx(ActivityIndicator, { size: "small", color: "#e8843a" }) : _jsx(MessageIcon, { name: icon, size: 20, color: !action ? '#A3A3A3' : destructive ? '#EF4444' : '#F7F7F7' }), _jsx(Text, { numberOfLines: 1, style: { color: !action ? '#A3A3A3' : destructive ? '#EF4444' : '#F7F7F7', fontSize: 10, fontWeight: '800' }, children: label })] }, label)), _jsx(View, { style: { flex: (!visual || !!reviewAction) && !onDownload && !onDelete && !onForward && !onShare ? 1 : 0, minWidth: 0 }, children: (!visual || !!reviewAction) && !onDownload && !onDelete && !onForward && !onShare && _jsx(Text, { numberOfLines: 1, style: { color: '#fff', fontSize: 14, paddingLeft: 8 }, children: title }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close media", onPress: onClose, style: { width: 44, height: 44, borderRadius: 22, backgroundColor: 'rgba(255,255,255,0.08)', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 24, color: "#fff" }) })] }), loading ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }, children: [_jsx(ActivityIndicator, { color: "#fff" }), _jsx(Text, { style: { color: '#c1c7ce' }, children: "Opening\u2026" })] }) : kind === 'image' && uri ? _jsx(View, { style: { height: controls ? '82%' : '100%', width: '100%' }, children: _jsx(ZoomablePhoto, { uri: uri, label: fileName, expanded: photoExpanded, onTap: () => setExpanded(!photoExpanded) }) }) : kind === 'document' ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 20 }, children: [_jsx(View, { style: { width: 88, height: 106, borderRadius: 16, backgroundColor: '#ffffff12', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "document", color: "#fff", size: 40 }) }), _jsx(Text, { style: { color: '#fff', fontSize: 19, fontWeight: '600', textAlign: 'center' }, children: fileName }), _jsxs(Text, { style: { color: '#9ba3ad', fontSize: 13 }, children: [fileName?.split('.').pop()?.toUpperCase(), " \u00B7 ", fileSize(byteSize)] }), onFileAction && _jsx(Pressable, { accessibilityRole: "button", onPress: onFileAction, style: { borderRadius: 24, paddingHorizontal: 28, paddingVertical: 14, backgroundColor: '#fff' }, children: _jsx(Text, { style: { color: '#15191e', fontWeight: '600' }, children: "Open file" }) })] }) : kind === 'video' ? _jsx(View, { testID: "media-video-stage", style: { height: '72%', width: '100%' }, children: children }) : children] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', padding: 16 }, children: error }), controls && reviewAction && onReview && _jsx(View, { style: { padding: 16, borderTopWidth: 1, borderTopColor: '#ffffff15' }, children: _jsx(Pressable, { accessibilityRole: "button", onPress: onReview, style: { minHeight: 48, borderRadius: 24, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontWeight: '600', color: '#121619' }, children: reviewAction }) }) })] });
113
+ return _jsxs(View, { testID: "messenger-media-viewer", accessibilityViewIsModal: true, style: { flex: 1, minHeight: 0, backgroundColor: controls ? 'rgba(0,0,0,0.94)' : '#000000' }, children: [_jsxs(View, { style: { flex: 1, minHeight: 0, justifyContent: 'center', paddingHorizontal: visual && controls ? 12 : 0 }, children: [controls && _jsxs(View, { testID: "media-action-toolbar", style: { position: 'absolute', top: 12, left: 12, right: 12, zIndex: 3, minHeight: 52, borderRadius: 26, borderWidth: 1, borderColor: 'rgba(255,255,255,0.12)', backgroundColor: 'rgba(18,18,18,0.92)', paddingLeft: 8, paddingRight: 4, flexDirection: 'row', alignItems: 'center', gap: 2 }, children: [[[onDownload, 'Download', 'download-outline', downloading, false], [onDelete, 'Delete', 'trash-outline', false, true], [onForward, 'Forward', 'arrow-redo-outline', false, false], [onShare, 'Share', 'share-social-outline', false, false]].filter(([action, label]) => !!action || (visual && !reviewAction && label !== 'Download')).map(([action, label, icon, busy, destructive]) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("{value1} media", { value1: label }), accessibilityState: { busy }, disabled: !action || busy || downloading, onPress: action, style: { opacity: !action ? .5 : 1, flex: 1, minWidth: 44, minHeight: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', gap: 1, paddingHorizontal: 2 }, children: [busy ? _jsx(ActivityIndicator, { size: "small", color: "#e8843a" }) : _jsx(MessageIcon, { name: icon, size: 20, color: !action ? '#A3A3A3' : destructive ? '#EF4444' : '#F7F7F7' }), _jsx(Text, { numberOfLines: 1, style: { color: !action ? '#A3A3A3' : destructive ? '#EF4444' : '#F7F7F7', fontSize: 10, fontWeight: '800' }, children: label })] }, label)), _jsx(View, { style: { flex: (!visual || !!reviewAction) && !onDownload && !onDelete && !onForward && !onShare ? 1 : 0, minWidth: 0 }, children: (!visual || !!reviewAction) && !onDownload && !onDelete && !onForward && !onShare && _jsx(Text, { numberOfLines: 1, style: { color: '#fff', fontSize: 14, paddingLeft: 8 }, children: title }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Close media"), onPress: onClose, style: { width: 44, height: 44, borderRadius: 22, backgroundColor: 'rgba(255,255,255,0.08)', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 24, color: "#fff" }) })] }), loading ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }, children: [_jsx(ActivityIndicator, { color: "#fff" }), _jsx(Text, { style: { color: '#c1c7ce' }, children: uiText("Opening…") })] }) : kind === 'image' && uri ? _jsx(View, { style: { height: controls ? '82%' : '100%', width: '100%' }, children: _jsx(ZoomablePhoto, { uri: uri, label: fileName, expanded: photoExpanded, onTap: () => setExpanded(!photoExpanded) }) }) : kind === 'document' ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 20 }, children: [_jsx(View, { style: { width: 88, height: 106, borderRadius: 16, backgroundColor: '#ffffff12', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "document", color: "#fff", size: 40 }) }), _jsx(Text, { style: { color: '#fff', fontSize: 19, fontWeight: '600', textAlign: 'center' }, children: fileName }), _jsxs(Text, { style: { color: '#9ba3ad', fontSize: 13 }, children: [fileName?.split('.').pop()?.toUpperCase(), " \u00B7 ", fileSize(byteSize)] }), onFileAction && _jsx(Pressable, { accessibilityRole: "button", onPress: onFileAction, style: { borderRadius: 24, paddingHorizontal: 28, paddingVertical: 14, backgroundColor: '#fff' }, children: _jsx(Text, { style: { color: '#15191e', fontWeight: '600' }, children: uiText("Open file") }) })] }) : kind === 'video' ? _jsx(View, { testID: "media-video-stage", style: { height: '72%', width: '100%' }, children: children }) : children] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', padding: 16 }, children: error }), controls && reviewAction && onReview && _jsx(View, { style: { padding: 16, borderTopWidth: 1, borderTopColor: '#ffffff15' }, children: _jsx(Pressable, { accessibilityRole: "button", onPress: onReview, style: { minHeight: 48, borderRadius: 24, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontWeight: '600', color: '#121619' }, children: reviewAction }) }) })] });
108
114
  }
@@ -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 { messagingPopupBackdrop, MessagingMenuSurface } from './popup.js';
3
4
  import { useEffect } from 'react';
4
5
  import { Platform, Pressable, ScrollView, Text, View } from 'react-native';
@@ -14,6 +15,7 @@ export function MessageMenuRow({ icon, label, onPress, disabled, danger, divider
14
15
  return _jsxs(_Fragment, { children: [divider && _jsx(View, { style: { height: 1, marginHorizontal: 12, marginVertical: 4, backgroundColor: '#ffffff22' } }), _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: label, disabled: disabled, onPress: onPress, style: ({ pressed }) => ({ minHeight: 43, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 8, opacity: disabled ? .5 : 1, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: icon, color: danger ? '#F87171' : '#f5f5f5', size: 22 }), _jsx(Text, { style: { flex: 1, color: danger ? '#F87171' : '#f5f5f5', fontSize: 15, fontWeight: '700' }, children: label })] })] });
15
16
  }
16
17
  export function MessageMenu({ visible, position, screenHeight, mine, reactions, children, onClose, onDismiss }) {
18
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
17
19
  useEffect(() => { if (!visible || Platform.OS !== 'web')
18
20
  return; const key = (e) => { if (e.key === 'Escape') {
19
21
  e.stopPropagation();
@@ -23,6 +25,6 @@ export function MessageMenu({ visible, position, screenHeight, mine, reactions,
23
25
  const menuWidth = Math.min(300, rect.width - 24), y = Math.max(12, Math.min(position.y - rect.top, rect.height - 12));
24
26
  const local = { ...position, x: Math.max(12, Math.min(position.x - rect.left, rect.width - menuWidth - 12)), y, available: Math.max(44, (position.above ? y : rect.height - y) - 24) };
25
27
  const card = _jsx(View, { testID: "message-action-card", style: { width: 218, maxWidth: '100%', maxHeight: Math.min(310, Math.max(43, local.available - (reactions ? 58 : 0))), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006', alignSelf: mine ? 'flex-end' : 'flex-start' }, children: _jsx(ScrollView, { showsVerticalScrollIndicator: false, bounces: false, keyboardShouldPersistTaps: "handled", children: children }) });
26
- return _jsxs(View, { style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close message options", onPress: onClose, style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsxs(View, { testID: "message-context-menu", style: { position: 'absolute', left: local.x, ...(position.above ? { bottom: rect.height - local.y + 12 } : { top: local.y + 12 }), width: menuWidth, maxWidth: '94%', gap: 8 }, children: [position.above ? card : reactions, position.above ? reactions : card] })] });
28
+ return _jsxs(View, { style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: uiText("Close message options"), onPress: onClose, style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsxs(View, { testID: "message-context-menu", style: { position: 'absolute', left: local.x, ...(position.above ? { bottom: rect.height - local.y + 12 } : { top: local.y + 12 }), width: menuWidth, maxWidth: '94%', gap: 8 }, children: [position.above ? card : reactions, position.above ? reactions : card] })] });
27
29
  } });
28
30
  }
@@ -1,6 +1,7 @@
1
- import type { TranslationPreference, MessageContent, MessageMedia, MessagingOutbox, SavedOutgoingMessage } from '@robono/messaging';
1
+ import type { SavedMessageAction, TranslationPreference, MessageContent, MessageMedia, MessagingOutbox, SavedOutgoingMessage } from '@robono/messaging';
2
2
  import type { LocalMessagingMedia } from './media-controller.js';
3
3
  export interface NativeDraftFiles {
4
+ conversations?(scope: string): Promise<string[]>;
4
5
  read(scope: string, conversation: string): Promise<NativeDraftState>;
5
6
  write(scope: string, conversation: string, state: NativeDraftState): Promise<void>;
6
7
  copy(scope: string, conversation: string, id: string, source: LocalMessagingMedia): Promise<string>;
@@ -9,6 +10,8 @@ export interface NativeDraftFiles {
9
10
  clear(scope: string, conversation?: string): Promise<void>;
10
11
  }
11
12
  export interface NativeDraftState {
13
+ actions?: SavedMessageAction[];
14
+ conversationId?: string;
12
15
  translationPreference?: TranslationPreference;
13
16
  quickMessage?: string;
14
17
  draft: string;
@@ -28,9 +31,14 @@ export declare class NativeMessagingOutbox implements MessagingOutbox {
28
31
  private scope;
29
32
  constructor(endpoint: string, userId: string, files: NativeDraftFiles, namespace?: string);
30
33
  private serial;
34
+ conversations(): Promise<string[]>;
35
+ actions(cid: string): Promise<SavedMessageAction[]>;
36
+ putAction(cid: string, action: SavedMessageAction): Promise<void>;
37
+ removeAction(cid: string, id: string): Promise<void>;
31
38
  load(cid: string): Promise<{
32
39
  draft: string;
33
40
  messages: {
41
+ retry?: import("@robono/messaging").OutgoingRetry;
34
42
  clientMessageId: string;
35
43
  createdAt: string;
36
44
  content: MessageContent;
@@ -12,6 +12,13 @@ export class NativeMessagingOutbox {
12
12
  }
13
13
  serial(work) { const task = (queues.get(this.scope) ?? Promise.resolve()).catch(() => { }).then(work); queues.set(this.scope, task); void task.finally(() => { if (queues.get(this.scope) === task)
14
14
  queues.delete(this.scope); }).catch(() => { }); return task; }
15
+ conversations() { return this.serial(() => this.files.conversations?.(this.scope) ?? Promise.resolve([])); }
16
+ actions(cid) { return this.serial(async () => (await this.files.read(this.scope, cid)).actions ?? []); }
17
+ putAction(cid, action) { return this.serial(async () => { const state = await this.files.read(this.scope, cid); const actions = state.actions ?? [], index = actions.findIndex(a => a.id === action.id); if (index < 0)
18
+ actions.push(action);
19
+ else
20
+ actions[index] = action; state.actions = actions; await this.files.write(this.scope, cid, state); }); }
21
+ removeAction(cid, id) { return this.serial(async () => { const state = await this.files.read(this.scope, cid); state.actions = (state.actions ?? []).filter(a => a.id !== id); await this.files.write(this.scope, cid, state); }); }
15
22
  load(cid) { return this.serial(async () => { const saved = await this.files.read(this.scope, cid), used = new Set(saved.messages.map(m => m.fileId)); return { ...(saved.translationPreference ? { translationPreference: saved.translationPreference } : {}), ...(saved.quickMessage ? { quickMessage: saved.quickMessage } : {}), draft: saved.draft, messages: saved.messages.map(({ fileId, ...message }) => message), attachments: Object.entries(saved.files).filter(([id]) => !used.has(id)).map(([, f]) => ({ kind: f.kind, media: f.media })) }; }); }
16
23
  put(cid, message) { return this.serial(async () => { const state = await this.files.read(this.scope, cid), previous = state.messages.find(m => m.clientMessageId === message.clientMessageId); state.messages = state.messages.filter(m => m.clientMessageId !== message.clientMessageId); state.messages.push({ ...message, fileId: message.needsPreparation ? message.content.media?.assetId : previous?.fileId }); await this.files.write(this.scope, cid, state); }); }
17
24
  remove(cid, id) { return this.serial(async () => { const state = await this.files.read(this.scope, cid), fileId = state.messages.find(m => m.clientMessageId === id)?.fileId; state.messages = state.messages.filter(m => m.clientMessageId !== id); if (fileId && !state.messages.some(m => m.fileId === fileId)) {
package/dist/polls.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 { messagingPopupColors } from './popup.js';
3
4
  import { ExtraPopup } from './extras.js';
4
5
  import { MessageIcon } from './icons.js';
@@ -10,6 +11,7 @@ const PollArea = View;
10
11
  import { MessagingPopup as PollModal } from './popup.js';
11
12
  /** Optional reusable poll content. Votes are submitted as a complete selection, not a toggle request. */
12
13
  export function PollMessage({ message, userId, ink, accent, enabled, onVote, onClose, resolveVoterName, background = '#101010', foreground = '#fff', renderDetails, detailsOpen, onDetailsOpenChange, onLongPress }) {
14
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
13
15
  const [busy, setBusy] = useState(false), [error, setError] = useState(''), [localShowVotes, setLocalShowVotes] = useState(false);
14
16
  const showVotes = detailsOpen ?? localShowVotes;
15
17
  const setShowVotes = (open) => { setLocalShowVotes(open); onDetailsOpenChange?.(open); };
@@ -20,7 +22,7 @@ export function PollMessage({ message, userId, ink, accent, enabled, onVote, onC
20
22
  const queue = useRef(null);
21
23
  if (!queue.current)
22
24
  queue.current = createPollVoteQueue(choices => current.current.onVote(choices), choices => { if (choices)
23
- selection.current = choices; setPending(choices); }, () => setError('The poll could not be updated. Try again.'));
25
+ selection.current = choices; setPending(choices); }, () => setError(uiText("The poll could not be updated. Try again.")));
24
26
  const actionBusy = useRef(false);
25
27
  const poll = message.poll;
26
28
  if (!poll)
@@ -35,26 +37,27 @@ export function PollMessage({ message, userId, ink, accent, enabled, onVote, onC
35
37
  await work();
36
38
  }
37
39
  catch {
38
- setError('The poll could not be updated. Try again.');
40
+ setError(uiText("The poll could not be updated. Try again."));
39
41
  }
40
42
  finally {
41
43
  actionBusy.current = false;
42
44
  setBusy(false);
43
45
  } }
44
- const details = _jsxs(View, { style: { gap: 14 }, children: [_jsxs(View, { style: { gap: 4 }, children: [_jsx(Text, { style: { fontSize: 17, lineHeight: 23, fontWeight: '600', color: messagingPopupColors.foreground }, children: poll.question }), _jsxs(Text, { style: { fontSize: 12, lineHeight: 17, color: messagingPopupColors.foreground, opacity: .65 }, children: [votes.length, " ", votes.length === 1 ? 'person voted' : 'people voted', " \u00B7 ", closed ? 'Closed' : poll.multiple ? 'Multiple answers' : 'One answer per person'] })] }), _jsx(View, { style: { gap: 14 }, children: poll.options.map((option, index) => {
46
+ const details = _jsxs(View, { style: { gap: 14 }, children: [_jsxs(View, { style: { gap: 4 }, children: [_jsx(Text, { style: { fontSize: 17, lineHeight: 23, fontWeight: '600', color: messagingPopupColors.foreground }, children: poll.question }), _jsxs(Text, { style: { fontSize: 12, lineHeight: 17, color: messagingPopupColors.foreground, opacity: .65 }, children: [votes.length, " ", votes.length === 1 ? uiText("person voted") : uiText("people voted"), " \u00B7 ", closed ? uiText("Closed") : poll.multiple ? uiText("Multiple answers") : uiText("One answer per person")] })] }), _jsx(View, { style: { gap: 14 }, children: poll.options.map((option, index) => {
45
47
  const people = votes.filter(v => v.options.includes(index));
46
48
  const names = people.map(v => v.userId === userId ? 'You' : resolveVoterName?.(v.userId) || 'Chat member').join(', ');
47
49
  return _jsxs(View, { style: { gap: 5 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'baseline', gap: 12 }, children: [_jsx(Text, { style: { flex: 1, color: messagingPopupColors.foreground, fontSize: 15, lineHeight: 21, fontWeight: '600' }, children: option }), _jsx(Text, { style: { color: messagingPopupColors.foreground, opacity: .65, fontSize: 13 }, children: people.length })] }), _jsxs(View, { style: { height: 4, borderRadius: 2, overflow: 'hidden' }, children: [_jsx(View, { style: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, backgroundColor: foreground, opacity: .12 } }), _jsx(View, { style: { height: 4, width: `${votes.length ? people.length / votes.length * 100 : 0}%`, backgroundColor: accent } })] }), !!names && _jsx(Text, { style: { color: messagingPopupColors.foreground, opacity: .7, fontSize: 13, lineHeight: 18 }, children: names })] }, index);
48
- }) }), message.senderId === userId && !closed && enabled && _jsx(Pressable, { accessibilityRole: "button", disabled: busy || pending !== null, onPress: () => void act(onClose), style: { minHeight: 36, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 14 }, children: "End poll" }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground, fontSize: 13, lineHeight: 18 }, children: error })] });
49
- return _jsxs(_Fragment, { children: [_jsxs(Pressable, { accessible: false, onLongPress: onLongPress, onPress: event => { event.stopPropagation(); setShowVotes(true); }, accessibilityLabel: `Poll: ${poll.question}. Open votes`, style: { width: 270, maxWidth: '100%', gap: 0, padding: 0 }, children: [_jsxs(Text, { accessibilityRole: "button", onLongPress: onLongPress, onPress: () => setShowVotes(true), style: { fontSize: 17, lineHeight: 23, fontWeight: '600', color: ink }, accessibilityHint: closed ? 'Poll closed' : poll.multiple ? 'Choose one or more answers' : 'Choose one answer', children: [poll.question, closed ? ' · Closed' : ''] }), poll.options.map((label, index) => {
50
+ }) }), message.senderId === userId && !closed && enabled && _jsx(Pressable, { accessibilityRole: "button", disabled: busy || pending !== null, onPress: () => void act(onClose), style: { minHeight: 36, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 14 }, children: uiText("End poll") }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground, fontSize: 13, lineHeight: 18 }, children: error })] });
51
+ return _jsxs(_Fragment, { children: [_jsxs(Pressable, { accessible: false, onLongPress: onLongPress, onPress: event => { event.stopPropagation(); setShowVotes(true); }, accessibilityLabel: uiText("Poll: {value1}. Open votes", { value1: poll.question }), style: { width: 270, maxWidth: '100%', gap: 0, padding: 0 }, children: [_jsxs(Text, { accessibilityRole: "button", onLongPress: onLongPress, onPress: () => setShowVotes(true), style: { fontSize: 17, lineHeight: 23, fontWeight: '600', color: ink }, accessibilityHint: closed ? uiText("Poll closed") : poll.multiple ? uiText("Choose one or more answers") : uiText("Choose one answer"), children: [poll.question, closed ? uiText(" · Closed") : ''] }), poll.options.map((label, index) => {
50
52
  const count = votes.filter(v => v.options.includes(index)).length, checked = selected.includes(index);
51
53
  return _jsxs(View, { style: { minHeight: 36, paddingVertical: 3, gap: 2 }, children: [_jsxs(View, { style: { flexDirection: 'row', gap: 10, alignItems: 'center' }, children: [_jsx(Pressable, { accessibilityRole: poll.multiple ? 'checkbox' : 'radio', accessibilityLabel: `${label}, ${count} ${count === 1 ? 'vote' : 'votes'}`, "aria-checked": checked, accessibilityState: { checked, disabled: !enabled || closed || busy, busy: pending !== null }, hitSlop: 4, disabled: !enabled || closed || busy, onPress: event => { event.stopPropagation(); if (!enabled || closed || busy || actionBusy.current)
52
54
  return; const previous = selection.current; setError(''); void queue.current(previous.includes(index) ? previous.filter(x => x !== index) : poll.multiple ? [...previous, index] : [index]); }, style: { width: 20, height: 20, borderRadius: poll.multiple ? 4 : 10, borderWidth: 2, borderColor: checked ? accent : ink, alignItems: 'center', justifyContent: 'center' }, children: checked && _jsx(View, { style: { width: 10, height: 10, borderRadius: poll.multiple ? 2 : 5, backgroundColor: accent } }) }), _jsx(Text, { style: { flex: 1, color: ink, fontSize: 16 }, children: label }), _jsx(Text, { style: { color: ink, fontSize: 13 }, children: count })] }), _jsx(View, { pointerEvents: "none", style: { height: 3, marginLeft: 30, backgroundColor: ink + '20', borderRadius: 2, overflow: 'hidden' }, children: _jsx(View, { style: { height: 3, width: `${votes.length ? count / votes.length * 100 : 0}%`, backgroundColor: accent } }) })] }, index);
53
- }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: ink }, children: error })] }), showVotes && (renderDetails ? renderDetails(details, () => setShowVotes(false)) : _jsx(ExtraPopup, { title: "Votes", background: background, foreground: foreground, onClose: () => setShowVotes(false), children: _jsx(ScrollView, { style: { flexShrink: 1 }, children: details }) }))] });
55
+ }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: ink }, children: error })] }), showVotes && (renderDetails ? renderDetails(details, () => setShowVotes(false)) : _jsx(ExtraPopup, { title: uiText("Votes"), background: background, foreground: foreground, onClose: () => setShowVotes(false), children: _jsx(ScrollView, { style: { flexShrink: 1 }, children: details }) }))] });
54
56
  }
55
57
  /** Recipient-only decision. Keep the attempted answer on an uncertain failure so
56
58
  * retry cannot silently switch an answer the server may already have accepted. */
57
59
  function YesNoQuestion({ message, userId, ink, accent, enabled, onVote, onClose, resolveVoterName, renderDetails, detailsOpen, onDetailsOpenChange, onLongPress, background = '#111111', foreground = '#fff' }) {
60
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
58
61
  const [attempt, setAttempt] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState(''), [localOpen, setLocalOpen] = useState(false);
59
62
  const inFlight = useRef(false), firstChoice = useRef(null);
60
63
  const poll = message.poll, answer = message.pollState?.votes.find(v => v.userId !== message.senderId)?.options[0];
@@ -76,7 +79,7 @@ function YesNoQuestion({ message, userId, ink, accent, enabled, onVote, onClose,
76
79
  await onVote([index]);
77
80
  }
78
81
  catch {
79
- setError('Answer not confirmed. Tap to retry.');
82
+ setError(uiText("Answer not confirmed. Tap to retry."));
80
83
  }
81
84
  finally {
82
85
  inFlight.current = false;
@@ -84,12 +87,13 @@ function YesNoQuestion({ message, userId, ink, accent, enabled, onVote, onClose,
84
87
  }
85
88
  }
86
89
  const respondent = message.pollState?.votes.find(v => v.userId !== message.senderId)?.userId;
87
- const details = _jsxs(View, { style: { gap: 12 }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 17, lineHeight: 23, fontWeight: '600' }, children: poll.question }), _jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 15, lineHeight: 21 }, children: answer !== undefined ? `${respondent === userId ? 'You' : resolveVoterName?.(respondent) || 'Recipient'} answered ${poll.options[answer]}.` : closed ? 'Closed without an answer.' : attempt !== null ? 'Confirming your answer…' : 'Waiting for an answer.' }), mine && !closed && enabled && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { if (inFlight.current)
88
- return; inFlight.current = true; void onClose().catch(() => setError('Could not close the question. Try again.')).finally(() => { inFlight.current = false; }); }, style: { minHeight: 36, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: "End question" }) })] });
89
- return _jsxs(_Fragment, { children: [_jsxs(Pressable, { accessible: false, onLongPress: onLongPress, onPress: e => { e.stopPropagation(); setOpen(true); }, testID: "yes-no-question", style: { maxWidth: 270, minWidth: 160 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 }, children: [_jsx(View, { style: { paddingTop: 3 }, children: _jsx(MessageIcon, { name: "poll", color: accent, size: 16 }) }), _jsx(Text, { accessibilityRole: "button", onLongPress: onLongPress, onPress: () => setOpen(true), style: { flexShrink: 1, fontSize: 16, lineHeight: 22, fontWeight: '600', color: ink }, children: poll.question })] }), _jsx(View, { style: { flexDirection: 'row', gap: 20, marginTop: 2 }, children: poll.options.map((label, index) => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: label, accessibilityState: { checked: chosen === index, disabled: !enabled || mine || closed || attempt !== null, busy }, disabled: !enabled || mine || closed || attempt !== null, onPress: e => { e.stopPropagation(); void choose(index); }, hitSlop: 4, style: { flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 32 }, children: [_jsx(View, { style: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: chosen === index ? accent : ink, alignItems: 'center', justifyContent: 'center', opacity: chosen === undefined && mine ? .5 : 1 }, children: chosen === index && _jsx(View, { style: { width: 10, height: 10, borderRadius: 5, backgroundColor: accent } }) }), _jsx(Text, { style: { color: ink, fontSize: 16, fontWeight: chosen === index ? '600' : '400' }, children: label })] }, index)) }), !!error && !closed && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Retry answer", disabled: busy || closed || attempt === null, onPress: e => { e.stopPropagation(); if (attempt !== null)
90
- void choose(attempt); }, children: _jsx(Text, { accessibilityRole: "alert", style: { color: ink, fontSize: 13 }, children: error }) })] }), open && (renderDetails ? renderDetails(details, () => setOpen(false)) : _jsx(ExtraPopup, { title: "Answer", background: background, foreground: foreground, onClose: () => setOpen(false), children: _jsx(ScrollView, { style: { flexShrink: 1 }, children: details }) }))] });
90
+ const details = _jsxs(View, { style: { gap: 12 }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 17, lineHeight: 23, fontWeight: '600' }, children: poll.question }), _jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 15, lineHeight: 21 }, children: answer !== undefined ? uiText("{value1} answered {value2}.", { value1: respondent === userId ? 'You' : resolveVoterName?.(respondent) || 'Recipient', value2: poll.options[answer] }) : closed ? uiText("Closed without an answer.") : attempt !== null ? uiText("Confirming your answer…") : uiText("Waiting for an answer.") }), mine && !closed && enabled && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { if (inFlight.current)
91
+ return; inFlight.current = true; void onClose().catch(() => setError(uiText("Could not close the question. Try again."))).finally(() => { inFlight.current = false; }); }, style: { minHeight: 36, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: uiText("End question") }) })] });
92
+ return _jsxs(_Fragment, { children: [_jsxs(Pressable, { accessible: false, onLongPress: onLongPress, onPress: e => { e.stopPropagation(); setOpen(true); }, testID: "yes-no-question", style: { maxWidth: 270, minWidth: 160 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 }, children: [_jsx(View, { style: { paddingTop: 3 }, children: _jsx(MessageIcon, { name: "poll", color: accent, size: 16 }) }), _jsx(Text, { accessibilityRole: "button", onLongPress: onLongPress, onPress: () => setOpen(true), style: { flexShrink: 1, fontSize: 16, lineHeight: 22, fontWeight: '600', color: ink }, children: poll.question })] }), _jsx(View, { style: { flexDirection: 'row', gap: 20, marginTop: 2 }, children: poll.options.map((label, index) => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: label, accessibilityState: { checked: chosen === index, disabled: !enabled || mine || closed || attempt !== null, busy }, disabled: !enabled || mine || closed || attempt !== null, onPress: e => { e.stopPropagation(); void choose(index); }, hitSlop: 4, style: { flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 32 }, children: [_jsx(View, { style: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: chosen === index ? accent : ink, alignItems: 'center', justifyContent: 'center', opacity: chosen === undefined && mine ? .5 : 1 }, children: chosen === index && _jsx(View, { style: { width: 10, height: 10, borderRadius: 5, backgroundColor: accent } }) }), _jsx(Text, { style: { color: ink, fontSize: 16, fontWeight: chosen === index ? '600' : '400' }, children: label })] }, index)) }), !!error && !closed && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Retry answer"), disabled: busy || closed || attempt === null, onPress: e => { e.stopPropagation(); if (attempt !== null)
93
+ void choose(attempt); }, children: _jsx(Text, { accessibilityRole: "alert", style: { color: ink, fontSize: 13 }, children: error }) })] }), open && (renderDetails ? renderDetails(details, () => setOpen(false)) : _jsx(ExtraPopup, { title: uiText("Answer"), background: background, foreground: foreground, onClose: () => setOpen(false), children: _jsx(ScrollView, { style: { flexShrink: 1 }, children: details }) }))] });
91
94
  }
92
95
  export function PollComposer({ appearance, onSend, onCancel, retrySafe = false, yesNo = false }) {
96
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
93
97
  const [question, setQuestion] = useState(''), [options, setOptions] = useState(['', '']), [multiple, setMultiple] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(''), [submitted, setSubmitted] = useState(false);
94
98
  const c = { ...appearance.colors, ...messagingPopupColors };
95
99
  async function send() { if (busy || submitted)
@@ -106,7 +110,7 @@ export function PollComposer({ appearance, onSend, onCancel, retrySafe = false,
106
110
  catch {
107
111
  if (retrySafe)
108
112
  setSubmitted(false);
109
- setError(retrySafe ? 'The poll was not sent. Try again.' : 'The poll was not sent. Check the conversation for a retry.');
113
+ setError(retrySafe ? uiText("The poll was not sent. Try again.") : uiText("The poll was not sent. Check the conversation for a retry."));
110
114
  }
111
115
  finally {
112
116
  setBusy(false);
@@ -118,6 +122,6 @@ export function PollComposer({ appearance, onSend, onCancel, retrySafe = false,
118
122
  ready = true;
119
123
  }
120
124
  catch { }
121
- return _jsx(PollModal, { contentWidth: 640, form: true, title: yesNo ? 'Yes/No question' : 'New poll', foreground: c.foreground, closeDisabled: busy, background: c.background, onClose: () => { if (!busy)
122
- onCancel(); }, children: _jsxs(PollArea, { style: { flex: 1, minHeight: 0, backgroundColor: c.background }, children: [_jsx(View, { style: { flexShrink: 1 }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", keyboardDismissMode: "on-drag", contentContainerStyle: { padding: 0, paddingBottom: 12, gap: 8 }, children: [_jsx(Text, { style: { color: c.secondary, fontSize: 13, fontWeight: '600', marginTop: 4 }, children: "Question" }), _jsx(TextInput, { accessibilityLabel: "Poll question", placeholder: "Ask a question", placeholderTextColor: c.secondary, maxLength: 300, multiline: true, value: question, onChangeText: setQuestion, editable: !busy, style: inputStyle }), !yesNo && _jsxs(_Fragment, { children: [_jsx(Text, { style: { color: c.secondary, fontSize: 13, fontWeight: '600', marginTop: 12 }, children: "Answers" }), options.map((option, i) => _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsx(TextInput, { accessibilityLabel: `Option ${i + 1}`, placeholder: "Add an option", placeholderTextColor: c.secondary, maxLength: 100, value: option, onChangeText: v => setOptions(items => items.map((x, n) => n === i ? v : x)), editable: !busy, style: { ...inputStyle, flex: 1 } }), options.length > 2 && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: `Remove option ${i + 1}`, disabled: busy, onPress: () => setOptions(items => items.filter((_, n) => n !== i)), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.secondary, fontSize: 26 }, children: "\u00D7" }) })] }, i)), options.length < 12 && _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => setOptions(items => [...items, '']), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 16 }, children: "Add option" }) }), _jsxs(View, { style: { minHeight: 48, flexDirection: 'row', gap: 12, alignItems: 'center', marginTop: 8 }, children: [_jsx(Text, { style: { color: c.foreground, fontSize: 16, flex: 1 }, children: "Allow multiple answers" }), _jsx(Switch, { accessibilityLabel: "Allow multiple answers", disabled: busy, value: multiple, onValueChange: setMultiple, trackColor: { true: c.accent } })] }), _jsx(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19 }, children: "Everyone in the chat can see the votes." })] }), yesNo && _jsx(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19, marginTop: 8 }, children: "Their first Yes or No answer is final." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error })] }) }), _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 8 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Cancel", disabled: busy, onPress: onCancel, style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: c.secondary + '20', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16, fontWeight: '600' }, children: "Cancel" }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: yesNo ? "Send question" : "Send poll", disabled: busy || submitted || !ready, onPress: () => void send(), style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: c.accent, opacity: (busy || submitted || !ready) ? 0.45 : 1, alignItems: 'center', justifyContent: 'center' }, children: busy ? _jsx(ActivityIndicator, { color: "#fff" }) : _jsx(Text, { style: { color: '#fff', fontSize: 16, fontWeight: '600' }, children: "Send" }) })] })] }) });
125
+ return _jsx(PollModal, { contentWidth: 640, form: true, title: yesNo ? uiText("Yes/No question") : uiText("New poll"), foreground: c.foreground, closeDisabled: busy, background: c.background, onClose: () => { if (!busy)
126
+ onCancel(); }, children: _jsxs(PollArea, { style: { flex: 1, minHeight: 0, backgroundColor: c.background }, children: [_jsx(View, { style: { flexShrink: 1 }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", keyboardDismissMode: "on-drag", contentContainerStyle: { padding: 0, paddingBottom: 12, gap: 8 }, children: [_jsx(Text, { style: { color: c.secondary, fontSize: 13, fontWeight: '600', marginTop: 4 }, children: uiText("Question") }), _jsx(TextInput, { accessibilityLabel: uiText("Poll question"), placeholder: uiText("Ask a question"), placeholderTextColor: c.secondary, maxLength: 300, multiline: true, value: question, onChangeText: setQuestion, editable: !busy, style: inputStyle }), !yesNo && _jsxs(_Fragment, { children: [_jsx(Text, { style: { color: c.secondary, fontSize: 13, fontWeight: '600', marginTop: 12 }, children: uiText("Answers") }), options.map((option, i) => _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsx(TextInput, { accessibilityLabel: uiText("Option {value1}", { value1: i + 1 }), placeholder: uiText("Add an option"), placeholderTextColor: c.secondary, maxLength: 100, value: option, onChangeText: v => setOptions(items => items.map((x, n) => n === i ? v : x)), editable: !busy, style: { ...inputStyle, flex: 1 } }), options.length > 2 && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Remove option {value1}", { value1: i + 1 }), disabled: busy, onPress: () => setOptions(items => items.filter((_, n) => n !== i)), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.secondary, fontSize: 26 }, children: "\u00D7" }) })] }, i)), options.length < 12 && _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => setOptions(items => [...items, '']), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 16 }, children: uiText("Add option") }) }), _jsxs(View, { style: { minHeight: 48, flexDirection: 'row', gap: 12, alignItems: 'center', marginTop: 8 }, children: [_jsx(Text, { style: { color: c.foreground, fontSize: 16, flex: 1 }, children: uiText("Allow multiple answers") }), _jsx(Switch, { accessibilityLabel: uiText("Allow multiple answers"), disabled: busy, value: multiple, onValueChange: setMultiple, trackColor: { true: c.accent } })] }), _jsx(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19 }, children: uiText("Everyone in the chat can see the votes.") })] }), yesNo && _jsx(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19, marginTop: 8 }, children: uiText("Their first Yes or No answer is final.") }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error })] }) }), _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 8 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Cancel"), disabled: busy, onPress: onCancel, style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: c.secondary + '20', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16, fontWeight: '600' }, children: uiText("Cancel") }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: yesNo ? uiText("Send question") : uiText("Send poll"), disabled: busy || submitted || !ready, onPress: () => void send(), style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: c.accent, opacity: (busy || submitted || !ready) ? 0.45 : 1, alignItems: 'center', justifyContent: 'center' }, children: busy ? _jsx(ActivityIndicator, { color: "#fff" }) : _jsx(Text, { style: { color: '#fff', fontSize: 16, fontWeight: '600' }, children: uiText("Send") }) })] })] }) });
123
127
  }
package/dist/popup.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 { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
3
4
  import { Keyboard, KeyboardAvoidingView, Modal, Platform, Pressable, SafeAreaView, Text, View, useWindowDimensions } from 'react-native';
4
5
  import { MessageIcon } from './icons.js';
@@ -7,8 +8,9 @@ export const MessagingPopupSurface = createContext(null);
7
8
  export const messagingPopupBackdrop = '#000000d9';
8
9
  export const messagingPopupColors = { background: '#000000', foreground: '#ffffff', secondary: '#aaaaaa', incoming: '#202020' };
9
10
  /** One title/close row for forms, menus and expanded content. */
10
- export function MessagingPopupHeader({ title, foreground, onClose, closeLabel = 'Close ' + title.toLowerCase(), disabled = false, back = false }) {
11
- return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, borderBottomWidth: 1, borderBottomColor: '#ffffff18' }, children: [_jsx(Text, { accessibilityRole: "header", style: { flex: 1, color: foreground, fontSize: 17, lineHeight: 23, fontWeight: '600' }, children: title }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: closeLabel, disabled: disabled, accessibilityState: { disabled }, onPress: onClose, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', opacity: disabled ? 0.45 : 1 }, children: _jsx(MessageIcon, { name: back ? 'back' : 'close', color: foreground, size: 24 }) })] });
11
+ export function MessagingPopupHeader({ title, foreground, onClose, closeLabel, disabled = false, back = false }) {
12
+ const { text: uiText } = useRobonoLocale();
13
+ return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, borderBottomWidth: 1, borderBottomColor: '#ffffff18' }, children: [_jsx(Text, { accessibilityRole: "header", style: { flex: 1, color: foreground, fontSize: 17, lineHeight: 23, fontWeight: '600' }, children: title }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: closeLabel ?? uiText('Close {name}', { name: title }), disabled: disabled, accessibilityState: { disabled }, onPress: onClose, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', opacity: disabled ? 0.45 : 1 }, children: _jsx(MessageIcon, { name: back ? 'back' : 'close', color: foreground, size: 24 }) })] });
12
14
  }
13
15
  // Measure the host and its persistent header, including embedded native layouts.
14
16
  const PopupBounds = createContext(null);
@@ -21,12 +23,13 @@ export function MessagingPopupLayer({ children }) {
21
23
  return update ? null : _jsx(_Fragment, { children: children });
22
24
  }
23
25
  export function MessagingPopupHost({ children }) {
26
+ const { direction } = useRobonoLocale();
24
27
  const root = useRef(null), header = useRef(null), [revision, setRevision] = useState(0);
25
28
  const [layer, setLayer] = useState(null);
26
29
  const update = useCallback((owner, node) => setLayer(current => node ? { owner, node } : current?.owner === owner ? null : current), []);
27
30
  const changed = useCallback(() => setRevision(value => value + 1), []);
28
31
  const value = useMemo(() => ({ root, header, revision, changed }), [revision, changed]);
29
- return _jsx(PopupBounds.Provider, { value: value, children: _jsx(PopupLayer.Provider, { value: update, children: _jsxs(View, { ref: root, collapsable: false, onLayout: changed, style: { flex: 1, minHeight: 0 }, children: [children, layer?.node] }) }) });
32
+ return _jsx(PopupBounds.Provider, { value: value, children: _jsx(PopupLayer.Provider, { value: update, children: _jsxs(View, { ref: root, collapsable: false, onLayout: changed, style: { flex: 1, minHeight: 0, direction }, children: [children, layer?.node] }) }) });
30
33
  }
31
34
  export function MessagingPopupHeaderBoundary({ children }) {
32
35
  const bounds = useContext(PopupBounds);
@@ -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 { MessagingPopupHeader, messagingPopupBackdrop, MessagingMenuSurface } from './popup.js';
3
4
  import { useEffect, useRef, useState } from 'react';
4
5
  import { Platform, Pressable, ScrollView, Text, View, useWindowDimensions } from 'react-native';
@@ -6,13 +7,14 @@ import { quickMessageOptions, validQuickMessage } from '@robono/messaging';
6
7
  import { MessageIcon, chatSurface } from './icons.js';
7
8
  /** Messenger's empty-composer shortcut and anchored picker. Selecting never sends. */
8
9
  export function QuickSendButton({ choice, empty, enabled, disabled, editing, sendAvailable, appearance, onChoose, onSend, onQuickSend, onSchedule }) {
10
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
9
11
  const [anchor, setAnchor] = useState(null), held = useRef(false), button = useRef(null), viewport = useWindowDimensions();
10
12
  const emoji = validQuickMessage(choice) ? choice : '👍', quick = empty && enabled && !editing, unavailable = disabled || (!quick && !sendAvailable), c = appearance.colors;
11
13
  useEffect(() => { if (!quick || disabled)
12
14
  setAnchor(null); }, [quick, disabled]);
13
15
  const show = () => { held.current = true; button.current?.measureInWindow((x, y, width) => setAnchor({ x: x + width, y })); };
14
16
  const label = quickMessageOptions.find(option => option.emoji === emoji).label;
15
- return _jsxs(_Fragment, { children: [_jsx(Pressable, { ref: button, testID: "quick-send-button", accessibilityRole: "button", accessibilityLabel: quick ? 'Send ' + label : editing ? 'Save message' : appearance.labels.send, accessibilityHint: quick ? 'Hold to choose a different quick message.' : onSchedule ? 'Hold to schedule this message.' : undefined, accessibilityState: { disabled: unavailable, expanded: anchor !== null }, accessibilityActions: quick ? [{ name: 'choose', label: 'Choose quick message' }] : onSchedule ? [{ name: 'schedule', label: 'Send later' }] : undefined, onAccessibilityAction: event => { if (event.nativeEvent.actionName === 'choose' && quick && !unavailable)
17
+ return _jsxs(_Fragment, { children: [_jsx(Pressable, { ref: button, testID: "quick-send-button", accessibilityRole: "button", accessibilityLabel: quick ? uiText('Send {label}', { label: uiText(label) }) : editing ? uiText("Save message") : appearance.labels.send, accessibilityHint: quick ? uiText("Hold to choose a different quick message.") : onSchedule ? uiText("Hold to schedule this message.") : undefined, accessibilityState: { disabled: unavailable, expanded: anchor !== null }, accessibilityActions: quick ? [{ name: 'choose', label: uiText("Choose quick message") }] : onSchedule ? [{ name: 'schedule', label: uiText("Send later") }] : undefined, onAccessibilityAction: event => { if (event.nativeEvent.actionName === 'choose' && quick && !unavailable)
16
18
  show();
17
19
  else if (event.nativeEvent.actionName === 'schedule' && !unavailable)
18
20
  onSchedule?.(); }, disabled: unavailable, delayLongPress: 350, onPressIn: () => { held.current = false; }, onLongPress: quick ? show : onSchedule ? () => { held.current = true; onSchedule(); } : undefined, onPress: () => { if (held.current) {
@@ -34,5 +36,5 @@ export function QuickSendButton({ choice, empty, enabled, disabled, editing, sen
34
36
  held.current = true;
35
37
  onSchedule?.();
36
38
  }
37
- } } } : {}), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', opacity: unavailable ? .4 : 1 }, children: quick ? _jsx(Text, { style: { fontSize: 26, lineHeight: 32 }, children: emoji }) : _jsx(MessageIcon, { name: editing ? 'check' : 'send', size: 24, color: c.accent }) }), _jsx(MessagingMenuSurface, { visible: anchor !== null, onClose: () => setAnchor(null), children: rect => _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close quick messages", onPress: () => setAnchor(null), style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsxs(View, { style: { position: 'absolute', width: Math.min(304, rect.width - 24), maxHeight: Math.max(100, rect.height - 24), left: Math.max(12, Math.min((anchor?.x ?? rect.width) - rect.left - 304, rect.width - Math.min(304, rect.width - 24) - 12)), top: Math.max(12, Math.min((anchor?.y ?? rect.height) - rect.top - 282, rect.height - 312)), backgroundColor: chatSurface(c.background), borderRadius: 18, borderWidth: 1, borderColor: c.secondary + '40', padding: 8, boxShadow: '0 4px 18px #0005' }, children: [_jsx(MessagingPopupHeader, { title: "Quick message", foreground: c.foreground, closeLabel: "Close quick message picker", onClose: () => setAnchor(null) }), _jsx(ScrollView, { style: { maxHeight: Math.min(220, Math.max(44, rect.height - 90)) }, keyboardShouldPersistTaps: "handled", contentContainerStyle: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' }, children: quickMessageOptions.map(item => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: item.label, accessibilityState: { selected: emoji === item.emoji }, onPress: () => { held.current = false; setAnchor(null); onChoose(item.emoji); }, style: { width: 46, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: 10, backgroundColor: emoji === item.emoji ? c.accent + '20' : 'transparent' }, children: _jsx(Text, { style: { fontSize: 26, lineHeight: 32 }, children: item.emoji }) }, item.emoji)) })] })] }) })] });
39
+ } } } : {}), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', opacity: unavailable ? .4 : 1 }, children: quick ? _jsx(Text, { style: { fontSize: 26, lineHeight: 32 }, children: emoji }) : _jsx(MessageIcon, { name: editing ? 'check' : 'send', size: 24, color: c.accent }) }), _jsx(MessagingMenuSurface, { visible: anchor !== null, onClose: () => setAnchor(null), children: rect => _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: uiText("Close quick messages"), onPress: () => setAnchor(null), style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsxs(View, { style: { position: 'absolute', width: Math.min(304, rect.width - 24), maxHeight: Math.max(100, rect.height - 24), left: Math.max(12, Math.min((anchor?.x ?? rect.width) - rect.left - 304, rect.width - Math.min(304, rect.width - 24) - 12)), top: Math.max(12, Math.min((anchor?.y ?? rect.height) - rect.top - 282, rect.height - 312)), backgroundColor: chatSurface(c.background), borderRadius: 18, borderWidth: 1, borderColor: c.secondary + '40', padding: 8, boxShadow: '0 4px 18px #0005' }, children: [_jsx(MessagingPopupHeader, { title: uiText("Quick message"), foreground: c.foreground, closeLabel: uiText("Close quick message picker"), onClose: () => setAnchor(null) }), _jsx(ScrollView, { style: { maxHeight: Math.min(220, Math.max(44, rect.height - 90)) }, keyboardShouldPersistTaps: "handled", contentContainerStyle: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' }, children: quickMessageOptions.map(item => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText(item.label), accessibilityState: { selected: emoji === item.emoji }, onPress: () => { held.current = false; setAnchor(null); onChoose(item.emoji); }, style: { width: 46, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: 10, backgroundColor: emoji === item.emoji ? c.accent + '20' : 'transparent' }, children: _jsx(Text, { style: { fontSize: 26, lineHeight: 32 }, children: item.emoji }) }, item.emoji)) })] })] }) })] });
38
40
  }
package/dist/safety.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 { MessagingMenuSurface } from './popup.js';
3
4
  import { messagingPopupColors } from './popup.js';
4
5
  import { useEffect, useRef, useState } from 'react';
@@ -7,6 +8,7 @@ import { MessageIcon } from './icons.js';
7
8
  import { Platform, Pressable, ScrollView, Text, TextInput, View, useWindowDimensions } from 'react-native';
8
9
  /** Uses the authenticated messaging client's durable safety operations, or the host can keep its own UI. */
9
10
  export function MessagingSafety({ client, state, appearance, name, messageId, onClose, onSearch, onDetails, onPins, onScheduled, onSaved, children, menuAnchor }) {
11
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
10
12
  const [mode, setMode] = useState(messageId ? 'report' : 'menu');
11
13
  const [category, setCategory] = useState(''), [details, setDetails] = useState(''), [busy, setBusy] = useState(false), [error, setError] = useState('');
12
14
  const reportRequest = useRef(null), reportContent = useRef('');
@@ -24,14 +26,14 @@ export function MessagingSafety({ client, state, appearance, name, messageId, on
24
26
  const search = () => { afterClose.current = onSearch ?? null; setVisible(false); };
25
27
  const c = { ...appearance.colors, ...messagingPopupColors }, safety = state.conversation?.safety, blocked = safety?.blockedByMe;
26
28
  const primaryInk = (() => { const hex = c.accent.replace('#', ''); const expanded = hex.length === 3 ? hex.split('').map(x => x + x).join('') : hex; const value = parseInt(expanded, 16); return Number.isFinite(value) && ((value >> 16) * .299 + ((value >> 8) & 255) * .587 + (value & 255) * .114) > 155 ? '#111111' : '#ffffff'; })();
27
- const button = (label, press, disabled = false, kind = 'plain') => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label, accessibilityState: { disabled: busy || disabled }, disabled: busy || disabled, onPress: press, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, paddingVertical: 10, justifyContent: 'center', alignItems: kind === 'plain' ? 'flex-start' : 'center', borderRadius: 12, flex: kind === 'plain' ? undefined : 1, backgroundColor: kind === 'primary' ? c.accent : kind === 'danger' ? '#b72d34' : kind === 'cancel' ? c.incoming : 'transparent', opacity: (busy || disabled) ? 0.5 : pressed ? 0.7 : 1 }), children: _jsx(Text, { style: { color: kind === 'primary' ? primaryInk : kind === 'danger' ? '#fff' : c.foreground, fontSize: 16, fontWeight: kind === 'plain' ? '400' : '600' }, children: label }) });
28
- const menuRow = (label, icon, press, accessibilityLabel = label) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: accessibilityLabel, onPress: press, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: pressed ? '#ffffff0c' : 'transparent' }), children: [_jsx(MessageIcon, { name: icon, color: c.secondary, size: 22 }), _jsx(Text, { style: { flex: 1, color: c.foreground, fontSize: 15, fontWeight: '600' }, children: label })] });
29
+ const button = (label, press, disabled = false, kind = 'plain') => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText(label), accessibilityState: { disabled: busy || disabled }, disabled: busy || disabled, onPress: press, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, paddingVertical: 10, justifyContent: 'center', alignItems: kind === 'plain' ? 'flex-start' : 'center', borderRadius: 12, flex: kind === 'plain' ? undefined : 1, backgroundColor: kind === 'primary' ? c.accent : kind === 'danger' ? '#b72d34' : kind === 'cancel' ? c.incoming : 'transparent', opacity: (busy || disabled) ? 0.5 : pressed ? 0.7 : 1 }), children: _jsx(Text, { style: { color: kind === 'primary' ? primaryInk : kind === 'danger' ? '#fff' : c.foreground, fontSize: 16, fontWeight: kind === 'plain' ? '400' : '600' }, children: uiText(label) }) });
30
+ const menuRow = (label, icon, press, accessibilityLabel = label) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText(accessibilityLabel), onPress: press, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 12, backgroundColor: pressed ? '#ffffff0c' : 'transparent' }), children: [_jsx(MessageIcon, { name: icon, color: c.secondary, size: 22 }), _jsx(Text, { style: { flex: 1, color: c.foreground, fontSize: 15, fontWeight: '600' }, children: uiText(label) })] });
29
31
  async function block() { setBusy(true); setError(''); try {
30
32
  await client.setBlocked(!blocked);
31
33
  onClose();
32
34
  }
33
35
  catch {
34
- setError('The change was not saved. Try again.');
36
+ setError(uiText("The change was not saved. Try again."));
35
37
  }
36
38
  finally {
37
39
  setBusy(false);
@@ -53,7 +55,7 @@ export function MessagingSafety({ client, state, appearance, name, messageId, on
53
55
  setMode('sent');
54
56
  }
55
57
  catch {
56
- setError('The report could not be confirmed. Try again.');
58
+ setError(uiText("The report could not be confirmed. Try again."));
57
59
  }
58
60
  finally {
59
61
  setBusy(false);
@@ -62,8 +64,8 @@ export function MessagingSafety({ client, state, appearance, name, messageId, on
62
64
  const choose = (next) => { afterClose.current = () => { setMode(next); setVisible(true); }; setVisible(false); };
63
65
  if (mode === 'menu' && menuAnchor) {
64
66
  const count = Number(!!onSearch) + Number(!!onDetails) + Number(!!onPins) + Number(!!onScheduled) + Number(!!children) + Number(!!safety?.canBlock) + Number(!!safety?.canReport), height = count * 44 + 8;
65
- return _jsx(MessagingMenuSurface, { visible: visible, onDismiss: dismissed, onClose: onClose, children: rect => _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close contact options", onPress: onClose, style: { position: 'absolute', inset: 0 } }), _jsx(View, { testID: "messaging-contact-dropdown", style: { position: 'absolute', left: Math.max(12, Math.min(menuAnchor.x - rect.left, rect.width - Math.min(240, rect.width - 24) - 12)), top: Math.max(12, Math.min(menuAnchor.y - rect.top, rect.height - height - 12)), width: Math.min(240, rect.width - 24), maxHeight: rect.height - 24, borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006' }, children: _jsxs(ScrollView, { bounces: false, keyboardShouldPersistTaps: "handled", children: [onDetails && menuRow(state.conversation?.kind === 'group' ? 'Group details' : 'Contact details', 'person', () => { afterClose.current = onDetails; setVisible(false); }), onPins && menuRow('Pinned messages', 'bookmark', () => { afterClose.current = onPins; setVisible(false); }), onScheduled && menuRow('Scheduled messages', 'send', () => { afterClose.current = onScheduled; setVisible(false); }), onSearch && menuRow('Search this chat', 'search', search), children && menuRow('Translation', 'translate', () => choose('translation')), safety?.canBlock && menuRow(blocked ? 'Unblock contact' : 'Block contact', 'shield', () => choose('block')), safety?.canReport && menuRow('Report', 'flag-outline', () => choose('report'))] }) })] }) });
67
+ return _jsx(MessagingMenuSurface, { visible: visible, onDismiss: dismissed, onClose: onClose, children: rect => _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: uiText("Close contact options"), onPress: onClose, style: { position: 'absolute', inset: 0 } }), _jsx(View, { testID: "messaging-contact-dropdown", style: { position: 'absolute', left: Math.max(12, Math.min(menuAnchor.x - rect.left, rect.width - Math.min(240, rect.width - 24) - 12)), top: Math.max(12, Math.min(menuAnchor.y - rect.top, rect.height - height - 12)), width: Math.min(240, rect.width - 24), maxHeight: rect.height - 24, borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006' }, children: _jsxs(ScrollView, { bounces: false, keyboardShouldPersistTaps: "handled", children: [onDetails && menuRow(state.conversation?.kind === 'group' ? 'Group details' : 'Contact details', 'person', () => { afterClose.current = onDetails; setVisible(false); }), onPins && menuRow('Pinned messages', 'bookmark', () => { afterClose.current = onPins; setVisible(false); }), onScheduled && menuRow('Scheduled messages', 'send', () => { afterClose.current = onScheduled; setVisible(false); }), onSearch && menuRow('Search this chat', 'search', search), children && menuRow('Translation', 'translate', () => choose('translation')), safety?.canBlock && menuRow(blocked ? 'Unblock contact' : 'Block contact', 'shield', () => choose('block')), safety?.canReport && menuRow('Report', 'flag-outline', () => choose('report'))] }) })] }) });
66
68
  }
67
- return _jsx(MessagingPopup, { compact: mode === 'block' || mode === 'sent', withinChat: mode === 'translation', contentWidth: 640, visible: visible, onDismiss: dismissed, background: c.background, foreground: c.foreground, closeLabel: "Close contact options", closeDisabled: busy, title: mode === 'menu' ? name : mode === 'translation' ? 'Translation' : mode === 'block' ? `${blocked ? 'Unblock' : 'Block'} ${name}?` : mode === 'sent' ? 'Report sent' : messageId ? 'Report message' : 'Report conversation', onClose: () => { if (!busy)
68
- onClose(); }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", style: { ...(mode === 'block' || mode === 'sent' ? { flexGrow: 0, flexShrink: 1 } : { flex: 1 }), minHeight: 0 }, contentContainerStyle: { gap: 20, paddingBottom: 12 }, children: [mode === 'menu' && _jsxs(_Fragment, { children: [onSearch && _jsx(View, { style: { backgroundColor: '#111111', borderRadius: 12, borderWidth: 1, borderColor: '#ffffff14', overflow: 'hidden' }, children: menuRow('Search this chat', 'search', search) }), children, (safety?.canBlock || safety?.canReport) && _jsxs(View, { style: { backgroundColor: '#111111', borderRadius: 12, borderWidth: 1, borderColor: '#ffffff14', overflow: 'hidden' }, children: [safety?.canBlock && menuRow(blocked ? 'Unblock contact' : 'Block contact', 'shield', () => setMode('block')), safety?.canReport && menuRow('Report', 'flag-outline', () => setMode('report'))] })] }), mode === 'translation' && children, mode === 'block' && _jsx(_Fragment, { children: _jsx(Text, { style: { fontSize: 14, lineHeight: 21, color: c.secondary }, children: blocked ? 'You will be able to message and call each other again. Messages attempted while blocked will not be delivered.' : 'This stops direct messages and calls between you. Your existing chat stays. They will not receive a notification that you blocked them. Shared group messages are not hidden.' }) }), mode === 'report' && _jsxs(_Fragment, { children: [_jsxs(Text, { style: { fontSize: 14, lineHeight: 21, color: c.secondary }, children: [messageId ? 'This message and your reason will be shared with this app’s moderation team.' : 'Up to five recent messages from other people in this conversation and your reason will be shared with this app’s moderation team.', " Report details are kept for up to 30 days. Reporting does not block this contact."] }), ['Spam', 'Harassment', 'Inappropriate content', 'Other'].map(value => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: value, accessibilityState: { checked: category === value }, disabled: busy, onPress: () => setCategory(value), style: { minHeight: 40, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [_jsx(View, { style: { width: 18, height: 18, borderRadius: 9, borderWidth: 2, borderColor: c.accent, backgroundColor: category === value ? c.accent : 'transparent' } }), _jsx(Text, { style: { color: c.foreground, fontSize: 15 }, children: value })] }, value)), _jsx(TextInput, { accessibilityLabel: "Report details", placeholder: "Tell us what happened (optional)", placeholderTextColor: c.secondary, value: details, onChangeText: setDetails, multiline: true, maxLength: 1800, editable: !busy, style: { color: c.foreground, minHeight: 80, borderWidth: 1, borderColor: c.secondary + '60', borderRadius: 10, padding: 12, fontSize: 15 } })] }), mode === 'sent' && _jsx(Text, { style: { color: c.secondary, fontSize: 15, lineHeight: 21 }, children: "Your report was saved for review. The other person is not notified." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), mode !== 'menu' && mode !== 'translation' && _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 8 }, children: [(mode === 'block' || mode === 'report') && button('Cancel', onClose, false, 'cancel'), mode === 'block' && button(blocked ? 'Unblock' : 'Block', () => void block(), false, blocked ? 'primary' : 'danger'), mode === 'report' && button(busy ? 'Sending…' : 'Send report', () => void report(), !category && !details.trim(), 'primary'), mode === 'sent' && button('Done', onClose, false, 'primary')] })] }) });
69
+ return _jsx(MessagingPopup, { compact: mode === 'block' || mode === 'sent', withinChat: mode === 'translation', contentWidth: 640, visible: visible, onDismiss: dismissed, background: c.background, foreground: c.foreground, closeLabel: uiText("Close contact options"), closeDisabled: busy, title: mode === 'menu' ? name : mode === 'translation' ? uiText("Translation") : mode === 'block' ? blocked ? uiText('Unblock {name}?', { name }) : uiText('Block {name}?', { name }) : mode === 'sent' ? uiText("Report sent") : messageId ? uiText("Report message") : uiText("Report conversation"), onClose: () => { if (!busy)
70
+ onClose(); }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", style: { ...(mode === 'block' || mode === 'sent' ? { flexGrow: 0, flexShrink: 1 } : { flex: 1 }), minHeight: 0 }, contentContainerStyle: { gap: 20, paddingBottom: 12 }, children: [mode === 'menu' && _jsxs(_Fragment, { children: [onSearch && _jsx(View, { style: { backgroundColor: '#111111', borderRadius: 12, borderWidth: 1, borderColor: '#ffffff14', overflow: 'hidden' }, children: menuRow('Search this chat', 'search', search) }), children, (safety?.canBlock || safety?.canReport) && _jsxs(View, { style: { backgroundColor: '#111111', borderRadius: 12, borderWidth: 1, borderColor: '#ffffff14', overflow: 'hidden' }, children: [safety?.canBlock && menuRow(blocked ? 'Unblock contact' : 'Block contact', 'shield', () => setMode('block')), safety?.canReport && menuRow('Report', 'flag-outline', () => setMode('report'))] })] }), mode === 'translation' && children, mode === 'block' && _jsx(_Fragment, { children: _jsx(Text, { style: { fontSize: 14, lineHeight: 21, color: c.secondary }, children: blocked ? uiText("You will be able to message and call each other again. Messages attempted while blocked will not be delivered.") : uiText("This stops direct messages and calls between you. Your existing chat stays. They will not receive a notification that you blocked them. Shared group messages are not hidden.") }) }), mode === 'report' && _jsxs(_Fragment, { children: [_jsxs(Text, { style: { fontSize: 14, lineHeight: 21, color: c.secondary }, children: [messageId ? uiText("This message and your reason will be shared with this app’s moderation team.") : uiText("Up to five recent messages from other people in this conversation and your reason will be shared with this app’s moderation team."), uiText("Report details are kept for up to 30 days. Reporting does not block this contact.")] }), ['Spam', 'Harassment', 'Inappropriate content', 'Other'].map(value => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: uiText(value), accessibilityState: { checked: category === value }, disabled: busy, onPress: () => setCategory(value), style: { minHeight: 40, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [_jsx(View, { style: { width: 18, height: 18, borderRadius: 9, borderWidth: 2, borderColor: c.accent, backgroundColor: category === value ? c.accent : 'transparent' } }), _jsx(Text, { style: { color: c.foreground, fontSize: 15 }, children: uiText(value) })] }, value)), _jsx(TextInput, { accessibilityLabel: uiText("Report details"), placeholder: uiText("Tell us what happened (optional)"), placeholderTextColor: c.secondary, value: details, onChangeText: setDetails, multiline: true, maxLength: 1800, editable: !busy, style: { color: c.foreground, minHeight: 80, borderWidth: 1, borderColor: c.secondary + '60', borderRadius: 10, padding: 12, fontSize: 15 } })] }), mode === 'sent' && _jsx(Text, { style: { color: c.secondary, fontSize: 15, lineHeight: 21 }, children: uiText("Your report was saved for review. The other person is not notified.") }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), mode !== 'menu' && mode !== 'translation' && _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 8 }, children: [(mode === 'block' || mode === 'report') && button('Cancel', onClose, false, 'cancel'), mode === 'block' && button(blocked ? 'Unblock' : 'Block', () => void block(), false, blocked ? 'primary' : 'danger'), mode === 'report' && button(busy ? 'Sending…' : 'Send report', () => void report(), !category && !details.trim(), 'primary'), mode === 'sent' && button('Done', onClose, false, 'primary')] })] }) });
69
71
  }