@robono/messaging-react-native 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +92 -37
  3. package/SBOM.spdx.json +577 -217
  4. package/dist/account.js +7 -4
  5. package/dist/attachment-menu.js +5 -2
  6. package/dist/call-message.js +8 -5
  7. package/dist/calling.js +6 -2
  8. package/dist/conversation-details.d.ts +1 -0
  9. package/dist/conversation-details.js +11 -7
  10. package/dist/custom-message.d.ts +7 -0
  11. package/dist/custom-message.js +53 -0
  12. package/dist/customization.d.ts +49 -0
  13. package/dist/customization.js +1 -0
  14. package/dist/expo-connected-calling.js +7 -2
  15. package/dist/expo-extras.js +4 -1
  16. package/dist/expo-media.js +27 -5
  17. package/dist/expo-messenger.d.ts +41 -2
  18. package/dist/expo-messenger.js +86 -10
  19. package/dist/expo-outbox.js +22 -1
  20. package/dist/expo-screen.d.ts +1 -1
  21. package/dist/expo-screen.js +42 -31
  22. package/dist/expo-social.js +5 -2
  23. package/dist/expo.d.ts +1 -1
  24. package/dist/expo.js +1 -1
  25. package/dist/extras.js +11 -7
  26. package/dist/forward.js +8 -5
  27. package/dist/group-editor.js +9 -6
  28. package/dist/header-calls.js +9 -6
  29. package/dist/icons.js +2 -1
  30. package/dist/inbox-search.d.ts +3 -1
  31. package/dist/inbox-search.js +33 -10
  32. package/dist/inbox-view.d.ts +21 -0
  33. package/dist/inbox-view.js +32 -0
  34. package/dist/inbox.d.ts +6 -1
  35. package/dist/inbox.js +27 -18
  36. package/dist/index.d.ts +16 -6
  37. package/dist/index.js +122 -81
  38. package/dist/localization.d.ts +24 -0
  39. package/dist/localization.js +12 -0
  40. package/dist/maps-connection.js +5 -2
  41. package/dist/media-ui.js +15 -8
  42. package/dist/message-menu.js +5 -2
  43. package/dist/native-outbox.d.ts +10 -2
  44. package/dist/native-outbox.js +7 -0
  45. package/dist/polls.js +21 -16
  46. package/dist/popup.js +8 -4
  47. package/dist/public-rooms.d.ts +10 -0
  48. package/dist/public-rooms.js +78 -0
  49. package/dist/quick-send.js +6 -3
  50. package/dist/room-moderation.d.ts +22 -0
  51. package/dist/room-moderation.js +87 -0
  52. package/dist/safety.js +11 -8
  53. package/dist/saved.js +7 -4
  54. package/dist/scheduled.js +19 -14
  55. package/dist/search.js +7 -4
  56. package/dist/social.js +9 -5
  57. package/dist/translation-settings.js +7 -4
  58. package/dist/typography.d.ts +12 -0
  59. package/dist/typography.js +31 -0
  60. package/dist/voice-composer.js +11 -6
  61. package/package.json +10 -8
  62. package/setup.plugin.cjs +20 -0
@@ -1,18 +1,21 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Text } from './typography.js';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import { MessagingPopupHeader, messagingPopupBackdrop, MessagingMenuSurface } from './popup.js';
3
5
  import { useEffect, useRef, useState } from 'react';
4
- import { Platform, Pressable, ScrollView, Text, View, useWindowDimensions } from 'react-native';
6
+ import { Platform, Pressable, ScrollView, View, useWindowDimensions } from 'react-native';
5
7
  import { quickMessageOptions, validQuickMessage } from '@robono/messaging';
6
8
  import { MessageIcon, chatSurface } from './icons.js';
7
9
  /** Messenger's empty-composer shortcut and anchored picker. Selecting never sends. */
8
10
  export function QuickSendButton({ choice, empty, enabled, disabled, editing, sendAvailable, appearance, onChoose, onSend, onQuickSend, onSchedule }) {
11
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
9
12
  const [anchor, setAnchor] = useState(null), held = useRef(false), button = useRef(null), viewport = useWindowDimensions();
10
13
  const emoji = validQuickMessage(choice) ? choice : '👍', quick = empty && enabled && !editing, unavailable = disabled || (!quick && !sendAvailable), c = appearance.colors;
11
14
  useEffect(() => { if (!quick || disabled)
12
15
  setAnchor(null); }, [quick, disabled]);
13
16
  const show = () => { held.current = true; button.current?.measureInWindow((x, y, width) => setAnchor({ x: x + width, y })); };
14
17
  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)
18
+ 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
19
  show();
17
20
  else if (event.nativeEvent.actionName === 'schedule' && !unavailable)
18
21
  onSchedule?.(); }, disabled: unavailable, delayLongPress: 350, onPressIn: () => { held.current = false; }, onLongPress: quick ? show : onSchedule ? () => { held.current = true; onSchedule(); } : undefined, onPress: () => { if (held.current) {
@@ -34,5 +37,5 @@ export function QuickSendButton({ choice, empty, enabled, disabled, editing, sen
34
37
  held.current = true;
35
38
  onSchedule?.();
36
39
  }
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)) })] })] }) })] });
40
+ } } } : {}), 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
41
  }
@@ -0,0 +1,22 @@
1
+ import React from 'react';
2
+ import type { MessagingClient } from '@robono/messaging';
3
+ /** Owners/moderators see current roles; the service decides whether each change is allowed. */
4
+ export declare function MessagingRoomModeration({ client, userId, participants, accent, onClose }: {
5
+ client: MessagingClient;
6
+ userId: string;
7
+ participants?: Record<string, {
8
+ name: string;
9
+ avatarUrl?: string;
10
+ }>;
11
+ accent: string;
12
+ onClose: () => void;
13
+ }): React.JSX.Element;
14
+ export declare function MessagingRoomRemoval({ client, message, accent, onClose }: {
15
+ client: MessagingClient;
16
+ message: {
17
+ id: string;
18
+ revision?: number;
19
+ };
20
+ accent: string;
21
+ onClose: () => void;
22
+ }): React.JSX.Element;
@@ -0,0 +1,87 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { ActivityIndicator, FlatList, Image, Pressable, View } from 'react-native';
4
+ import { Text, TextInput } from './typography.js';
5
+ import { useRobonoLocale } from './localization.js';
6
+ import { MessagingPopup, messagingPopupColors as c } from './popup.js';
7
+ /** Owners/moderators see current roles; the service decides whether each change is allowed. */
8
+ export function MessagingRoomModeration({ client, userId, participants = {}, accent, onClose }) {
9
+ const { text } = useRobonoLocale();
10
+ const [rows, setRows] = useState([]), [next, setNext] = useState(null), [loading, setLoading] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(''), [selected, setSelected] = useState(null), [confirm, setConfirm] = useState(null);
11
+ const generation = useRef(0), attempt = useRef(null), working = useRef(false);
12
+ const owner = client.getSnapshot().conversation?.roomRole === 'owner';
13
+ async function load(more = false) {
14
+ const gen = ++generation.current;
15
+ setLoading(true);
16
+ setError('');
17
+ try {
18
+ const page = await client.roomMembers(more && next ? next : undefined);
19
+ if (gen !== generation.current)
20
+ return;
21
+ setRows(current => [...new Map([...(more ? current : []), ...page.members].map(m => [m.userId, m])).values()]);
22
+ setNext(page.hasMore ? page.nextCursor : null);
23
+ }
24
+ catch (e) {
25
+ if (gen === generation.current)
26
+ setError(e instanceof Error ? e.message : text('Group members could not be loaded.'));
27
+ }
28
+ finally {
29
+ if (gen === generation.current)
30
+ setLoading(false);
31
+ }
32
+ }
33
+ useEffect(() => { void load(); return () => { ++generation.current; }; }, [client]);
34
+ async function apply() {
35
+ if (!confirm || working.current)
36
+ return;
37
+ working.current = true;
38
+ setBusy(true);
39
+ setError('');
40
+ const key = JSON.stringify(confirm);
41
+ if (attempt.current?.key !== key)
42
+ attempt.current = { key, id: client.createForwardId() };
43
+ try {
44
+ await client.moderateRoom({ ...confirm, requestId: attempt.current.id });
45
+ attempt.current = null;
46
+ setConfirm(null);
47
+ setSelected(null);
48
+ await load();
49
+ }
50
+ catch (e) {
51
+ setError(e instanceof Error ? e.message : text('This change could not be saved.'));
52
+ }
53
+ finally {
54
+ working.current = false;
55
+ setBusy(false);
56
+ }
57
+ }
58
+ const labels = { promote: 'Make moderator', demote: 'Remove moderator role', mute: 'Mute member', unmute: 'Unmute member', ban: 'Ban member', unban: 'Remove ban' };
59
+ return _jsxs(MessagingPopup, { title: text('Group moderation'), background: c.background, onClose: onClose, children: [confirm ? _jsxs(View, { style: { gap: 16, paddingVertical: 12 }, children: [_jsxs(Text, { style: { color: c.foreground, fontSize: 20, fontWeight: '600' }, children: [text(labels[confirm.action]), " \u00B7 ", participants[confirm.userId]?.name ?? confirm.userId] }), _jsx(Text, { style: { color: c.secondary, fontSize: 16, lineHeight: 23 }, children: text(confirm.action === 'ban' ? 'They will leave this group and cannot rejoin until the ban is removed.' : confirm.action === 'mute' ? 'They can read this group but cannot post or react.' : confirm.action === 'promote' ? 'They can mute and ban members. Only the owner can change moderator roles.' : confirm.action === 'unban' ? 'They can choose to join again. This does not automatically rejoin them.' : 'This changes their permissions in this group.') }), _jsxs(View, { style: { flexDirection: 'row', gap: 16 }, children: [_jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => void apply(), style: { minHeight: 48, justifyContent: 'center', paddingHorizontal: 16, borderRadius: 12, backgroundColor: confirm.action === 'ban' ? '#b72d34' : accent }, children: _jsx(Text, { style: { color: confirm.action === 'ban' ? '#fff' : '#000', fontWeight: '600' }, children: busy ? text('Saving…') : text(labels[confirm.action]) }) }), _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => setConfirm(null), style: { minHeight: 48, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: text('Back') }) })] })] }) : _jsx(FlatList, { data: rows, keyExtractor: m => m.userId, style: { flex: 1 }, contentContainerStyle: { paddingHorizontal: 8, paddingBottom: 12 }, renderItem: ({ item }) => {
60
+ const allowed = item.userId !== userId && item.role !== 'owner' && (owner || item.role !== 'moderator');
61
+ const actions = item.banned ? ['unban'] : ['mute', 'ban'];
62
+ if (!item.banned && item.muted)
63
+ actions[0] = 'unmute';
64
+ if (owner && !item.banned)
65
+ actions.unshift(item.role === 'moderator' ? 'demote' : 'promote');
66
+ return _jsxs(View, { style: { borderBottomWidth: 1, borderBottomColor: '#ffffff20' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: text('Manage {name}', { name: participants[item.userId]?.name ?? item.userId }), disabled: !allowed, onPress: () => setSelected(selected === item.userId ? null : item.userId), style: { minHeight: 64, flexDirection: 'row', alignItems: 'center', gap: 12 }, children: [participants[item.userId]?.avatarUrl && _jsx(Image, { source: { uri: participants[item.userId].avatarUrl }, style: { width: 36, height: 36, borderRadius: 18 } }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: { color: c.foreground, fontSize: 17 }, children: item.userId === userId ? text('You') : participants[item.userId]?.name ?? item.userId }), _jsx(Text, { style: { color: c.secondary, fontSize: 14 }, children: text(item.banned ? 'Banned' : item.role === 'owner' ? 'Owner' : item.role === 'moderator' ? 'Moderator' : item.muted ? 'Muted' : 'Member') })] }), allowed && _jsx(Text, { style: { color: accent, fontSize: 15 }, children: text('Manage') })] }), allowed && selected === item.userId && _jsx(View, { style: { gap: 4, paddingBottom: 12 }, children: actions.map(action => _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setError(''); setConfirm({ userId: item.userId, action }); }, style: { minHeight: 44, justifyContent: 'center', paddingHorizontal: 12, borderRadius: 10, backgroundColor: '#ffffff0c' }, children: _jsx(Text, { style: { color: action === 'ban' ? '#ff8282' : accent, fontSize: 16 }, children: text(labels[action]) }) }, action)) })] });
67
+ }, ListFooterComponent: _jsxs(View, { children: [loading && _jsx(ActivityIndicator, { color: accent, accessibilityLabel: text('Loading group members'), style: { padding: 16 } }), next && !loading && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void load(true), style: { minHeight: 48, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: text('Load more members') }) })] }) }), !!error && _jsxs(View, { style: { padding: 12, gap: 4 }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), !confirm && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void load(), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: text('Retry') }) })] })] });
68
+ }
69
+ export function MessagingRoomRemoval({ client, message, accent, onClose }) {
70
+ const { text } = useRobonoLocale();
71
+ const [reason, setReason] = useState(''), [busy, setBusy] = useState(false), [error, setError] = useState('');
72
+ const attempt = useRef(null), working = useRef(false);
73
+ async function remove() { if (working.current || !reason.trim())
74
+ return; working.current = true; setBusy(true); setError(''); if (attempt.current?.reason !== reason.trim())
75
+ attempt.current = { reason: reason.trim(), id: client.createForwardId() }; try {
76
+ await client.removeRoomMessage({ messageId: message.id, revision: message.revision ?? 0, reason: attempt.current.reason, requestId: attempt.current.id });
77
+ onClose();
78
+ }
79
+ catch (e) {
80
+ setError(e instanceof Error ? e.message : text('This message could not be removed.'));
81
+ }
82
+ finally {
83
+ working.current = false;
84
+ setBusy(false);
85
+ } }
86
+ return _jsx(MessagingPopup, { compact: true, title: text('Remove from group?'), background: c.background, closeDisabled: busy, onClose: onClose, children: _jsxs(View, { style: { gap: 12, paddingTop: 12 }, children: [_jsx(Text, { style: { color: c.secondary, fontSize: 16, lineHeight: 23 }, children: text('This removes the message for everyone in this group, even if it has been read. Your reason is kept in the moderation record.') }), _jsx(TextInput, { accessibilityLabel: text('Reason for removing message'), placeholder: text('Reason'), placeholderTextColor: c.secondary, multiline: true, maxLength: 1000, value: reason, onChangeText: setReason, editable: !busy, style: { color: c.foreground, fontSize: 16, minHeight: 80, padding: 12, borderWidth: 1, borderColor: c.secondary, borderRadius: 12 } }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: text('Remove message for group'), disabled: busy || !reason.trim(), onPress: () => void remove(), style: { minHeight: 48, borderRadius: 12, alignItems: 'center', justifyContent: 'center', backgroundColor: '#b72d34', opacity: busy || !reason.trim() ? .5 : 1 }, children: _jsx(Text, { style: { color: '#fff', fontSize: 16, fontWeight: '600' }, children: busy ? text('Removing…') : text('Remove message') }) })] }) });
87
+ }
package/dist/safety.js CHANGED
@@ -1,12 +1,15 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Text, TextInput } from './typography.js';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import { MessagingMenuSurface } from './popup.js';
3
5
  import { messagingPopupColors } from './popup.js';
4
6
  import { useEffect, useRef, useState } from 'react';
5
7
  import { MessagingPopup } from './popup.js';
6
8
  import { MessageIcon } from './icons.js';
7
- import { Platform, Pressable, ScrollView, Text, TextInput, View, useWindowDimensions } from 'react-native';
9
+ import { Platform, Pressable, ScrollView, View, useWindowDimensions } from 'react-native';
8
10
  /** Uses the authenticated messaging client's durable safety operations, or the host can keep its own UI. */
9
11
  export function MessagingSafety({ client, state, appearance, name, messageId, onClose, onSearch, onDetails, onPins, onScheduled, onSaved, children, menuAnchor }) {
12
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
10
13
  const [mode, setMode] = useState(messageId ? 'report' : 'menu');
11
14
  const [category, setCategory] = useState(''), [details, setDetails] = useState(''), [busy, setBusy] = useState(false), [error, setError] = useState('');
12
15
  const reportRequest = useRef(null), reportContent = useRef('');
@@ -24,14 +27,14 @@ export function MessagingSafety({ client, state, appearance, name, messageId, on
24
27
  const search = () => { afterClose.current = onSearch ?? null; setVisible(false); };
25
28
  const c = { ...appearance.colors, ...messagingPopupColors }, safety = state.conversation?.safety, blocked = safety?.blockedByMe;
26
29
  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 })] });
30
+ 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) }) });
31
+ 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
32
  async function block() { setBusy(true); setError(''); try {
30
33
  await client.setBlocked(!blocked);
31
34
  onClose();
32
35
  }
33
36
  catch {
34
- setError('The change was not saved. Try again.');
37
+ setError(uiText("The change was not saved. Try again."));
35
38
  }
36
39
  finally {
37
40
  setBusy(false);
@@ -53,7 +56,7 @@ export function MessagingSafety({ client, state, appearance, name, messageId, on
53
56
  setMode('sent');
54
57
  }
55
58
  catch {
56
- setError('The report could not be confirmed. Try again.');
59
+ setError(uiText("The report could not be confirmed. Try again."));
57
60
  }
58
61
  finally {
59
62
  setBusy(false);
@@ -62,8 +65,8 @@ export function MessagingSafety({ client, state, appearance, name, messageId, on
62
65
  const choose = (next) => { afterClose.current = () => { setMode(next); setVisible(true); }; setVisible(false); };
63
66
  if (mode === 'menu' && menuAnchor) {
64
67
  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'))] }) })] }) });
68
+ 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
69
  }
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')] })] }) });
70
+ 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)
71
+ 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
72
  }
package/dist/saved.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Text } from './typography.js';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import { messagingPopupColors } from './popup.js';
3
5
  import { useEffect, useRef, useState } from 'react';
4
- import { Platform, Pressable, ScrollView, Text, View } from 'react-native';
6
+ import { Platform, Pressable, ScrollView, View } from 'react-native';
5
7
  import { MessagingPopup } from './popup.js';
6
8
  import { MessageIcon } from './icons.js';
7
9
  /** Same app-sized popup and media viewer as the conversation; no server-history copy. */
8
10
  export function SavedMessagesPopup({ records, appearance, onClose, onOpen, onRemove }) {
11
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
9
12
  const c = { ...appearance.colors, ...messagingPopupColors }, [limit, setLimit] = useState(30), [error, setError] = useState(''), [busy, setBusy] = useState(false), [detail, setDetail] = useState(null);
10
13
  const [visible, setVisible] = useState(true), pending = useRef(null), alive = useRef(true);
11
14
  useEffect(() => { alive.current = true; return () => { alive.current = false; pending.current = null; }; }, []);
@@ -29,7 +32,7 @@ export function SavedMessagesPopup({ records, appearance, onClose, onOpen, onRem
29
32
  }
30
33
  catch (e) {
31
34
  if (alive.current) {
32
- setError(e instanceof Error ? e.message : 'This saved attachment could not be opened.');
35
+ setError(e instanceof Error ? e.message : uiText("This saved attachment could not be opened."));
33
36
  setBusy(false);
34
37
  setVisible(true);
35
38
  }
@@ -42,10 +45,10 @@ export function SavedMessagesPopup({ records, appearance, onClose, onOpen, onRem
42
45
  return;
43
46
  } if (busy)
44
47
  return; setBusy(true); setError(''); pending.current = record; setVisible(false); }
45
- return _jsxs(MessagingPopup, { contentWidth: 640, visible: visible, onDismiss: () => void dismissed(), background: c.background, title: detail ? 'Saved message' : 'Saved', foreground: c.foreground, closeLabel: detail ? 'Back to Saved' : 'Close Saved', closeDisabled: busy, headerBack: !!detail, onClose: () => { if (!busy) {
48
+ return _jsxs(MessagingPopup, { contentWidth: 640, visible: visible, onDismiss: () => void dismissed(), background: c.background, title: detail ? uiText("Saved message") : uiText("Saved"), foreground: c.foreground, closeLabel: detail ? uiText("Back to Saved") : uiText("Close Saved"), closeDisabled: busy, headerBack: !!detail, onClose: () => { if (!busy) {
46
49
  if (detail)
47
50
  setDetail(null);
48
51
  else
49
52
  onClose();
50
- } }, children: [!detail && _jsx(Text, { style: { color: c.secondary, fontSize: 13, marginBottom: 12 }, children: "Saved from all chats" }), _jsxs(ScrollView, { style: { flex: 1, minHeight: 0 }, children: [!records.length && _jsx(Text, { style: { color: c.secondary, marginVertical: 16 }, children: "No saved messages yet." }), detail ? _jsxs(_Fragment, { children: [_jsxs(Text, { style: { color: c.secondary, fontSize: 12, marginBottom: 10 }, children: [detail.conversationTitle, " \u00B7 ", new Date(detail.message.createdAt).toLocaleDateString()] }), _jsx(Text, { selectable: true, style: { color: c.foreground, fontSize: 16, lineHeight: 23 }, children: detail.message.text })] }) : records.slice(0, limit).map(record => _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: c.secondary + '20' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Open saved message: ' + (record.message.text || record.message.media?.fileName || 'Attachment'), disabled: busy, onPress: () => void open(record), style: { flex: 1, paddingVertical: 14, paddingHorizontal: 8, gap: 4 }, children: [_jsxs(Text, { style: { color: c.secondary, fontSize: 12 }, children: [record.conversationTitle, " \u00B7 ", new Date(record.message.createdAt).toLocaleDateString()] }), _jsx(Text, { numberOfLines: 2, style: { color: c.foreground, fontSize: 15, lineHeight: 20 }, children: record.message.text || record.message.media?.fileName || 'Attachment' }), record.message.media && !!record.message.text && _jsx(Text, { numberOfLines: 1, style: { color: c.secondary, fontSize: 12 }, children: record.message.media.fileName || record.message.kind })] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Remove saved message: ' + (record.message.text || record.message.media?.fileName || 'Attachment'), disabled: busy, onPress: () => { setBusy(true); setError(''); void onRemove(record.key).catch(e => setError(e instanceof Error ? e.message : 'This saved message could not be removed.')).finally(() => setBusy(false)); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "trash", color: c.secondary, size: 20 }) })] }, record.key))] }), !detail && records.length > limit && _jsx(Pressable, { accessibilityRole: "button", onPress: () => setLimit(n => n + 30), style: { minHeight: 44, justifyContent: 'center', alignSelf: 'flex-end' }, children: _jsx(Text, { style: { color: c.accent }, children: "More saved messages" }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error })] });
53
+ } }, children: [!detail && _jsx(Text, { style: { color: c.secondary, fontSize: 13, marginBottom: 12 }, children: uiText("Saved from all chats") }), _jsxs(ScrollView, { style: { flex: 1, minHeight: 0 }, children: [!records.length && _jsx(Text, { style: { color: c.secondary, marginVertical: 16 }, children: uiText("No saved messages yet.") }), detail ? _jsxs(_Fragment, { children: [_jsxs(Text, { style: { color: c.secondary, fontSize: 12, marginBottom: 10 }, children: [detail.conversationTitle, " \u00B7 ", new Date(detail.message.createdAt).toLocaleDateString(uiLocale)] }), _jsx(Text, { selectable: true, style: { color: c.foreground, fontSize: 16, lineHeight: 23 }, children: detail.message.text })] }) : records.slice(0, limit).map(record => _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', borderBottomWidth: 1, borderBottomColor: c.secondary + '20' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Open saved message: ' + (record.message.text || record.message.media?.fileName || 'Attachment'), disabled: busy, onPress: () => void open(record), style: { flex: 1, paddingVertical: 14, paddingHorizontal: 8, gap: 4 }, children: [_jsxs(Text, { style: { color: c.secondary, fontSize: 12 }, children: [record.conversationTitle, " \u00B7 ", new Date(record.message.createdAt).toLocaleDateString(uiLocale)] }), _jsx(Text, { numberOfLines: 2, style: { color: c.foreground, fontSize: 15, lineHeight: 20 }, children: record.message.text || record.message.media?.fileName || uiText("Attachment") }), record.message.media && !!record.message.text && _jsx(Text, { numberOfLines: 1, style: { color: c.secondary, fontSize: 12 }, children: record.message.media.fileName || record.message.kind })] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Remove saved message: ' + (record.message.text || record.message.media?.fileName || 'Attachment'), disabled: busy, onPress: () => { setBusy(true); setError(''); void onRemove(record.key).catch(e => setError(e instanceof Error ? e.message : uiText("This saved message could not be removed."))).finally(() => setBusy(false)); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "trash", color: c.secondary, size: 20 }) })] }, record.key))] }), !detail && records.length > limit && _jsx(Pressable, { accessibilityRole: "button", onPress: () => setLimit(n => n + 30), style: { minHeight: 44, justifyContent: 'center', alignSelf: 'flex-end' }, children: _jsx(Text, { style: { color: c.accent }, children: uiText("More saved messages") }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error })] });
51
54
  }
package/dist/scheduled.js CHANGED
@@ -1,42 +1,47 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text, TextInput } from './typography.js';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import { useEffect, useRef, useState } from 'react';
3
- import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
5
+ import { Pressable, ScrollView, View } from 'react-native';
4
6
  import { MessagingPopup, messagingPopupColors as colors } from './popup.js';
5
7
  const pad = (n) => String(n).padStart(2, '0');
6
8
  /** Same bounded popup as the other Messenger tools; all dates are shown locally. */
7
- export function ScheduleTimePicker({ initial, retryAt, onSave, onClose, accent, busy = false, error = '', title = 'Send later' }) {
8
- const twelveHour = Intl.DateTimeFormat(undefined, { hour: 'numeric' }).resolvedOptions().hour12 === true;
9
+ export function ScheduleTimePicker({ initial, retryAt, onSave, onClose, accent, busy = false, error = '', title }) {
10
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
11
+ title ??= uiText('Send later');
12
+ const twelveHour = Intl.DateTimeFormat(uiLocale, { hour: 'numeric' }).resolvedOptions().hour12 === true;
9
13
  const [period, setPeriod] = useState(() => new Date(initial ?? Date.now() + 3600000).getHours() < 12 ? 'AM' : 'PM');
10
14
  const [date, setDate] = useState(() => new Date(initial ?? Date.now() + 3600000)), [hour, setHour] = useState(() => pad(twelveHour ? (date.getHours() % 12 || 12) : date.getHours())), [minute, setMinute] = useState(() => pad(date.getMinutes())), [localError, setError] = useState('');
11
15
  const [month, setMonth] = useState(() => new Date(date.getFullYear(), date.getMonth(), 1));
12
16
  const first = new Date(month.getFullYear(), month.getMonth(), 1), days = new Date(month.getFullYear(), month.getMonth() + 1, 0).getDate(), today = new Date();
13
17
  today.setHours(0, 0, 0, 0);
14
- const end = new Date(Date.now() + 30 * 86400000), zone = Intl.DateTimeFormat(undefined, { timeZoneName: 'long' }).formatToParts(date).find(p => p.type === 'timeZoneName')?.value ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
18
+ const end = new Date(Date.now() + 30 * 86400000), zone = Intl.DateTimeFormat(uiLocale, { timeZoneName: 'long' }).formatToParts(date).find(p => p.type === 'timeZoneName')?.value ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
15
19
  const choose = (value) => { setDate(value); setMonth(new Date(value.getFullYear(), value.getMonth(), 1)); setError(''); };
16
20
  const submit = () => {
17
21
  if (!/^\d{1,2}$/.test(hour) || !/^\d{1,2}$/.test(minute) || Number(hour) > (twelveHour ? 12 : 23) || twelveHour && Number(hour) < 1 || Number(minute) > 59) {
18
- setError(twelveHour ? 'Use hours 1–12 and minutes 00–59.' : 'Use hours 00–23 and minutes 00–59.');
22
+ setError(twelveHour ? uiText("Use hours 1–12 and minutes 00–59.") : uiText("Use hours 00–23 and minutes 00–59."));
19
23
  return;
20
24
  }
21
25
  const hours = twelveHour ? (Number(hour) % 12) + (period === 'PM' ? 12 : 0) : Number(hour);
22
26
  const value = new Date(date.getFullYear(), date.getMonth(), date.getDate(), hours, Number(minute));
23
27
  if (value.getHours() !== hours || value.getMinutes() !== Number(minute)) {
24
- setError('That time is skipped by the clock change. Choose another time.');
28
+ setError(uiText("That time is skipped by the clock change. Choose another time."));
25
29
  return;
26
30
  }
27
31
  if (value.getTime() < Date.now() + 30000 || value.getTime() > Date.now() + 30 * 86400000) {
28
- setError('Choose a time at least one minute ahead, within the next 30 days.');
32
+ setError(uiText("Choose a time at least one minute ahead, within the next 30 days."));
29
33
  return;
30
34
  }
31
35
  onSave(value.toISOString());
32
36
  };
33
37
  if (retryAt)
34
- return _jsx(MessagingPopup, { title: "Confirm scheduled message", onClose: () => { if (!busy)
35
- onClose(); }, closeDisabled: busy, background: colors.background, contentWidth: 420, children: _jsxs(View, { style: { padding: 12, gap: 16 }, children: [_jsx(Text, { style: { color: colors.foreground, fontSize: 17 }, children: "The last attempt could not be confirmed. Retry to check it without creating another message." }), _jsx(Text, { style: { color: colors.secondary, fontSize: 16 }, children: new Date(retryAt).toLocaleString() }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: colors.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Retry scheduling", disabled: busy, onPress: () => onSave(retryAt), style: { minHeight: 46, borderRadius: 12, justifyContent: 'center', alignItems: 'center', backgroundColor: accent, opacity: busy ? .5 : 1 }, children: _jsx(Text, { style: { fontSize: 16, color: '#111', fontWeight: '600' }, children: busy ? 'Checking…' : 'Retry scheduling' }) })] }) });
38
+ return _jsx(MessagingPopup, { title: uiText("Confirm scheduled message"), onClose: () => { if (!busy)
39
+ onClose(); }, closeDisabled: busy, background: colors.background, contentWidth: 420, children: _jsxs(View, { style: { padding: 12, gap: 16 }, children: [_jsx(Text, { style: { color: colors.foreground, fontSize: 17 }, children: uiText("The last attempt could not be confirmed. Retry to check it without creating another message.") }), _jsx(Text, { style: { color: colors.secondary, fontSize: 16 }, children: new Date(retryAt).toLocaleString(uiLocale) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: colors.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Retry scheduling"), disabled: busy, onPress: () => onSave(retryAt), style: { minHeight: 46, borderRadius: 12, justifyContent: 'center', alignItems: 'center', backgroundColor: accent, opacity: busy ? .5 : 1 }, children: _jsx(Text, { style: { fontSize: 16, color: '#111', fontWeight: '600' }, children: busy ? uiText("Checking…") : uiText("Retry scheduling") }) })] }) });
36
40
  return _jsxs(MessagingPopup, { title: title, onClose: () => { if (!busy)
37
- onClose(); }, closeDisabled: busy, background: colors.background, contentWidth: 420, children: [_jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", contentContainerStyle: { padding: 12, gap: 12 }, children: [_jsx(Text, { style: { color: colors.foreground, fontSize: 17 }, children: "Choose when this message should be sent." }), _jsxs(View, { style: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Previous month", disabled: busy || first <= today, onPress: () => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1)), style: { padding: 12 }, children: _jsx(Text, { style: { color: accent, fontSize: 24 }, children: "\u2039" }) }), _jsx(Text, { style: { color: colors.foreground, fontSize: 18, fontWeight: '600' }, children: month.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Next month", disabled: busy || new Date(month.getFullYear(), month.getMonth() + 1, 1) > end, onPress: () => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1)), style: { padding: 12 }, children: _jsx(Text, { style: { color: accent, fontSize: 24 }, children: "\u203A" }) })] }), _jsxs(View, { style: { flexDirection: 'row', flexWrap: 'wrap' }, children: [['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => _jsx(Text, { style: { width: '14.285%', textAlign: 'center', color: colors.secondary, paddingBottom: 8 }, children: day }, 'week' + i)), Array.from({ length: first.getDay() }, (_, i) => _jsx(View, { style: { width: '14.285%' } }, 'blank' + i)), Array.from({ length: days }, (_, i) => { const d = new Date(month.getFullYear(), month.getMonth(), i + 1), selected = d.toDateString() === date.toDateString(), disabled = busy || d < today || d > end; return _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: d.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }), accessibilityState: { selected, disabled }, disabled: disabled, onPress: () => choose(d), style: { width: '14.285%', height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: 22, backgroundColor: selected ? accent + '30' : 'transparent', opacity: disabled ? .3 : 1 }, children: _jsx(Text, { style: { fontSize: 17, color: selected ? accent : colors.foreground, fontWeight: selected ? '700' : '400' }, children: i + 1 }) }, i); })] }), _jsxs(View, { style: { flexDirection: 'row', gap: 12, alignItems: 'center' }, children: [_jsx(Text, { style: { color: colors.foreground, fontSize: 16 }, children: twelveHour ? 'Time' : 'Time (24-hour)' }), [[hour, setHour, 'Hour'], [minute, setMinute, 'Minute']].map(([value, set, label]) => _jsx(TextInput, { accessibilityLabel: label, value: value, onChangeText: (text) => { set(text.replace(/\D/g, '').slice(0, 2)); setError(''); }, editable: !busy, keyboardType: "number-pad", maxLength: 2, selectTextOnFocus: true, style: { borderWidth: 1, borderColor: colors.secondary, borderRadius: 10, width: 56, padding: 10, color: colors.foreground, fontSize: 20, textAlign: 'center' } }, label)), twelveHour && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: period + ', switch to ' + (period === 'AM' ? 'PM' : 'AM'), onPress: () => setPeriod(period === 'AM' ? 'PM' : 'AM'), disabled: busy, style: { minHeight: 44, justifyContent: 'center', padding: 4 }, children: _jsx(Text, { style: { color: accent, fontSize: 17, fontWeight: '600' }, children: period }) })] }), _jsxs(Text, { style: { color: colors.secondary, fontSize: 14 }, children: [zone, ". The app can be closed when it is time to send."] }), !!(error || localError) && _jsx(Text, { accessibilityRole: "alert", style: { color: colors.foreground }, children: error || localError })] }), _jsx(View, { style: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 8, gap: 8 }, children: _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: submit, style: { minHeight: 46, borderRadius: 12, justifyContent: 'center', alignItems: 'center', backgroundColor: accent, opacity: busy ? .5 : 1 }, children: _jsx(Text, { style: { fontSize: 16, color: '#111', fontWeight: '600' }, children: busy ? 'Saving…' : 'Schedule message' }) }) })] });
41
+ onClose(); }, closeDisabled: busy, background: colors.background, contentWidth: 420, children: [_jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", contentContainerStyle: { padding: 12, gap: 12 }, children: [_jsx(Text, { style: { color: colors.foreground, fontSize: 17 }, children: uiText("Choose when this message should be sent.") }), _jsxs(View, { style: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Previous month"), disabled: busy || first <= today, onPress: () => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1)), style: { padding: 12 }, children: _jsx(Text, { style: { color: accent, fontSize: 24 }, children: "\u2039" }) }), _jsx(Text, { style: { color: colors.foreground, fontSize: 18, fontWeight: '600' }, children: month.toLocaleDateString(uiLocale, { month: 'long', year: 'numeric' }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Next month"), disabled: busy || new Date(month.getFullYear(), month.getMonth() + 1, 1) > end, onPress: () => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1)), style: { padding: 12 }, children: _jsx(Text, { style: { color: accent, fontSize: 24 }, children: "\u203A" }) })] }), _jsxs(View, { style: { flexDirection: 'row', flexWrap: 'wrap' }, children: [Array.from({ length: 7 }, (_, i) => new Intl.DateTimeFormat(uiLocale, { weekday: 'narrow' }).format(new Date(2026, 0, 4 + i))).map((day, i) => _jsx(Text, { style: { width: '14.285%', textAlign: 'center', color: colors.secondary, paddingBottom: 8 }, children: day }, 'week' + i)), Array.from({ length: first.getDay() }, (_, i) => _jsx(View, { style: { width: '14.285%' } }, 'blank' + i)), Array.from({ length: days }, (_, i) => { const d = new Date(month.getFullYear(), month.getMonth(), i + 1), selected = d.toDateString() === date.toDateString(), disabled = busy || d < today || d > end; return _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: d.toLocaleDateString(uiLocale, { weekday: 'long', month: 'long', day: 'numeric' }), accessibilityState: { selected, disabled }, disabled: disabled, onPress: () => choose(d), style: { width: '14.285%', height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: 22, backgroundColor: selected ? accent + '30' : 'transparent', opacity: disabled ? .3 : 1 }, children: _jsx(Text, { style: { fontSize: 17, color: selected ? accent : colors.foreground, fontWeight: selected ? '700' : '400' }, children: i + 1 }) }, i); })] }), _jsxs(View, { style: { flexDirection: 'row', gap: 12, alignItems: 'center' }, children: [_jsx(Text, { style: { color: colors.foreground, fontSize: 16 }, children: twelveHour ? uiText("Time") : uiText("Time (24-hour)") }), [[hour, setHour, 'Hour'], [minute, setMinute, 'Minute']].map(([value, set, label]) => _jsx(TextInput, { accessibilityLabel: uiText(label), value: value, onChangeText: (text) => { set(text.replace(/\D/g, '').slice(0, 2)); setError(''); }, editable: !busy, keyboardType: "number-pad", maxLength: 2, selectTextOnFocus: true, style: { borderWidth: 1, borderColor: colors.secondary, borderRadius: 10, width: 56, padding: 10, color: colors.foreground, fontSize: 20, textAlign: 'center' } }, label)), twelveHour && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: period + ', switch to ' + (period === 'AM' ? 'PM' : 'AM'), onPress: () => setPeriod(period === 'AM' ? 'PM' : 'AM'), disabled: busy, style: { minHeight: 44, justifyContent: 'center', padding: 4 }, children: _jsx(Text, { style: { color: accent, fontSize: 17, fontWeight: '600' }, children: period }) })] }), _jsx(Text, { style: { color: colors.secondary, fontSize: 14 }, children: uiText('{zone}. The app can be closed when it is time to send.', { zone }) }), !!(error || localError) && _jsx(Text, { accessibilityRole: "alert", style: { color: colors.foreground }, children: error || localError })] }), _jsx(View, { style: { paddingHorizontal: 12, paddingTop: 8, paddingBottom: 8, gap: 8 }, children: _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: submit, style: { minHeight: 46, borderRadius: 12, justifyContent: 'center', alignItems: 'center', backgroundColor: accent, opacity: busy ? .5 : 1 }, children: _jsx(Text, { style: { fontSize: 16, color: '#111', fontWeight: '600' }, children: busy ? uiText("Saving…") : uiText("Schedule message") }) }) })] });
38
42
  }
39
43
  export function ScheduledMessages({ client, accent, onClose }) {
44
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
40
45
  const [items, setItems] = useState([]), [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [error, setError] = useState(''), [editing, setEditing] = useState(null);
41
46
  const active = useRef(true);
42
47
  const load = async () => { try {
@@ -48,7 +53,7 @@ export function ScheduledMessages({ client, accent, onClose }) {
48
53
  }
49
54
  catch (e) {
50
55
  if (active.current)
51
- setError(e instanceof Error ? e.message : 'Scheduled messages could not load.');
56
+ setError(e instanceof Error ? e.message : uiText("Scheduled messages could not load."));
52
57
  }
53
58
  finally {
54
59
  if (active.current)
@@ -63,13 +68,13 @@ export function ScheduledMessages({ client, accent, onClose }) {
63
68
  }
64
69
  catch (e) {
65
70
  if (active.current)
66
- setError(e instanceof Error ? e.message : 'This message could not be changed.');
71
+ setError(e instanceof Error ? e.message : uiText("This message could not be changed."));
67
72
  }
68
73
  finally {
69
74
  if (active.current)
70
75
  setBusy(false);
71
76
  } };
72
77
  if (editing)
73
- return _jsx(ScheduleTimePicker, { initial: editing.sendAt, title: "Reschedule message", accent: accent, busy: busy, error: error, onSave: time => void change(editing, time), onClose: () => setEditing(null) });
74
- return _jsx(MessagingPopup, { title: "Scheduled messages", background: colors.background, onClose: onClose, contentWidth: 640, children: _jsxs(ScrollView, { contentContainerStyle: { padding: 12, gap: 12 }, children: [_jsx(Text, { style: { color: colors.secondary, fontSize: 15 }, children: "Only you can see these until they are sent." }), loading && _jsx(Text, { style: { color: colors.foreground }, children: "Loading\u2026" }), !loading && !items.length && !error && _jsx(Text, { style: { color: colors.foreground, fontSize: 17 }, children: "No scheduled messages. Hold Send after writing a message to choose a time." }), !!error && _jsx(Pressable, { onPress: () => void load(), accessibilityRole: "button", accessibilityLabel: "Retry scheduled messages", children: _jsxs(Text, { accessibilityRole: "alert", style: { color: colors.foreground }, children: [error, " Tap to refresh."] }) }), items.map(item => _jsxs(View, { style: { borderRadius: 12, backgroundColor: '#202020', padding: 12, gap: 8 }, children: [_jsxs(Text, { style: { color: colors.foreground, fontWeight: '600', fontSize: 16 }, children: [new Date(item.sendAt).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }), " \u00B7 ", item.state === 'pending' ? 'Scheduled' : item.state === 'failed' ? 'Not sent' : item.state === 'sent' ? 'Sent' : 'Cancelled'] }), item.content && _jsx(Text, { numberOfLines: 4, style: { color: colors.foreground, fontSize: 16 }, children: item.content.text || item.content.media?.fileName || item.content.poll?.question || item.content.kind }), item.state === 'failed' && _jsxs(Text, { style: { color: colors.secondary }, children: ["Not sent. ", item.errorCode === 'schedule_expired' ? 'The sending window passed.' : item.errorCode?.includes('screening') ? 'The app’s safety check did not allow delivery.' : item.errorCode?.includes('billing') || item.errorCode?.includes('spending') ? 'The app’s spending limit or billing stopped delivery.' : 'Access, message rules or service availability changed.'] }), item.state === 'pending' && _jsxs(View, { style: { flexDirection: 'row', gap: 12 }, children: [_jsx(Pressable, { disabled: busy, onPress: () => setEditing(item), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 16 }, children: "Reschedule" }) }), _jsx(Pressable, { disabled: busy, onPress: () => void change(item), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 16 }, children: "Cancel message" }) })] })] }, item.id))] }) });
78
+ return _jsx(ScheduleTimePicker, { initial: editing.sendAt, title: uiText("Reschedule message"), accent: accent, busy: busy, error: error, onSave: time => void change(editing, time), onClose: () => setEditing(null) });
79
+ return _jsx(MessagingPopup, { title: uiText("Scheduled messages"), background: colors.background, onClose: onClose, contentWidth: 640, children: _jsxs(ScrollView, { contentContainerStyle: { padding: 12, gap: 12 }, children: [_jsx(Text, { style: { color: colors.secondary, fontSize: 15 }, children: uiText("Only you can see these until they are sent.") }), loading && _jsx(Text, { style: { color: colors.foreground }, children: uiText("Loading…") }), !loading && !items.length && !error && _jsx(Text, { style: { color: colors.foreground, fontSize: 17 }, children: uiText("No scheduled messages. Hold Send after writing a message to choose a time.") }), !!error && _jsx(Pressable, { onPress: () => void load(), accessibilityRole: "button", accessibilityLabel: uiText("Retry scheduled messages"), children: _jsxs(Text, { accessibilityRole: "alert", style: { color: colors.foreground }, children: [error, ' ', uiText("Tap to refresh.")] }) }), items.map(item => _jsxs(View, { style: { borderRadius: 12, backgroundColor: '#202020', padding: 12, gap: 8 }, children: [_jsxs(Text, { style: { color: colors.foreground, fontWeight: '600', fontSize: 16 }, children: [new Date(item.sendAt).toLocaleString(uiLocale, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }), " \u00B7 ", item.state === 'pending' ? uiText("Scheduled") : item.state === 'failed' ? uiText("Not sent") : item.state === 'sent' ? uiText("Sent") : uiText("Cancelled")] }), item.content && _jsx(Text, { numberOfLines: 4, style: { color: colors.foreground, fontSize: 16 }, children: item.content.text || item.content.media?.fileName || item.content.poll?.question || item.content.kind }), item.state === 'failed' && _jsxs(Text, { style: { color: colors.secondary }, children: [uiText("Not sent."), ' ', item.errorCode === 'schedule_expired' ? uiText("The sending window passed.") : item.errorCode?.includes('screening') ? uiText("The app’s safety check did not allow delivery.") : item.errorCode?.includes('billing') || item.errorCode?.includes('spending') ? uiText("The app’s spending limit or billing stopped delivery.") : uiText("Access, message rules or service availability changed.")] }), item.state === 'pending' && _jsxs(View, { style: { flexDirection: 'row', gap: 12 }, children: [_jsx(Pressable, { disabled: busy, onPress: () => setEditing(item), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 16 }, children: uiText("Reschedule") }) }), _jsx(Pressable, { disabled: busy, onPress: () => void change(item), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 16 }, children: uiText("Cancel message") }) })] })] }, item.id))] }) });
75
80
  }
package/dist/search.js CHANGED
@@ -1,9 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text, TextInput } from './typography.js';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import { useEffect, useRef, useState } from 'react';
3
- import { ActivityIndicator, Pressable, Text, TextInput, View } from 'react-native';
5
+ import { ActivityIndicator, Pressable, View } from 'react-native';
4
6
  import { MessageIcon } from './icons.js';
5
7
  /** Messenger's in-conversation search: keep the chat visible and move between matches. */
6
8
  export function MessagingSearch({ client, appearance, onClose }) {
9
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
7
10
  const c = appearance.colors, [query, setQuery] = useState(''), [messages, setMessages] = useState([]), [index, setIndex] = useState(0), [next, setNext] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState('');
8
11
  const request = useRef(null), generation = useRef(0), timer = useRef(undefined);
9
12
  useEffect(() => () => { generation.current++; request.current?.abort(); clearTimeout(timer.current); }, []);
@@ -12,7 +15,7 @@ export function MessagingSearch({ client, appearance, onClose }) {
12
15
  }
13
16
  catch (e) {
14
17
  if (id === generation.current)
15
- setError(e instanceof Error ? e.message : 'This message is unavailable.');
18
+ setError(e instanceof Error ? e.message : uiText("This message is unavailable."));
16
19
  } }
17
20
  async function search(more = false) {
18
21
  clearTimeout(timer.current);
@@ -39,7 +42,7 @@ export function MessagingSearch({ client, appearance, onClose }) {
39
42
  }
40
43
  catch (e) {
41
44
  if (id === generation.current && !control.signal.aborted)
42
- setError(e instanceof Error ? e.message : 'Search could not be completed. Try again.');
45
+ setError(e instanceof Error ? e.message : uiText("Search could not be completed. Try again."));
43
46
  }
44
47
  finally {
45
48
  if (id === generation.current)
@@ -55,5 +58,5 @@ export function MessagingSearch({ client, appearance, onClose }) {
55
58
  return;
56
59
  } const target = (index + direction + messages.length) % messages.length; setIndex(target); await focus(messages[target], generation.current); }
57
60
  const nav = (label, icon, direction) => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label, disabled: busy || !messages.length, onPress: () => void move(direction), style: { width: 36, height: 44, alignItems: 'center', justifyContent: 'center', opacity: (busy || !messages.length) ? 0.4 : 1 }, children: _jsx(MessageIcon, { name: icon, size: 20, color: c.foreground }) });
58
- return _jsxs(View, { testID: "conversation-search", style: { backgroundColor: c.background, borderBottomWidth: 1, borderBottomColor: c.foreground + '20' }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', paddingLeft: 10, gap: 4 }, children: [_jsx(MessageIcon, { name: "search", size: 18, color: c.secondary }), _jsx(TextInput, { accessibilityLabel: "Search this chat", placeholder: "Search this chat", value: query, onChangeText: change, onSubmitEditing: () => void search(), autoFocus: true, autoCapitalize: "none", autoCorrect: false, maxLength: 200, returnKeyType: "search", placeholderTextColor: c.secondary, style: { flex: 1, minWidth: 0, minHeight: 44, color: c.foreground, fontSize: 15 } }), busy ? _jsx(ActivityIndicator, { accessibilityLabel: "Searching messages", color: c.accent }) : !!query.trim() && _jsx(Text, { accessibilityLiveRegion: "polite", style: { color: c.secondary, fontSize: 12 }, children: messages.length ? `${index + 1}/${messages.length}${next ? '+' : ''}` : '0' }), nav('Previous search result', 'chevron-up', -1), nav('Next search result', 'chevron-down', 1), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close search", onPress: onClose, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 20, color: c.foreground }) })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, paddingHorizontal: 12, paddingBottom: 8, fontSize: 13 }, children: error })] });
61
+ return _jsxs(View, { testID: "conversation-search", style: { backgroundColor: c.background, borderBottomWidth: 1, borderBottomColor: c.foreground + '20' }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', paddingLeft: 10, gap: 4 }, children: [_jsx(MessageIcon, { name: "search", size: 18, color: c.secondary }), _jsx(TextInput, { accessibilityLabel: uiText("Search this chat"), placeholder: uiText("Search this chat"), value: query, onChangeText: change, onSubmitEditing: () => void search(), autoFocus: true, autoCapitalize: "none", autoCorrect: false, maxLength: 200, returnKeyType: "search", placeholderTextColor: c.secondary, style: { flex: 1, minWidth: 0, minHeight: 44, color: c.foreground, fontSize: 15 } }), busy ? _jsx(ActivityIndicator, { accessibilityLabel: uiText("Searching messages"), color: c.accent }) : !!query.trim() && _jsx(Text, { accessibilityLiveRegion: "polite", style: { color: c.secondary, fontSize: 12 }, children: messages.length ? `${index + 1}/${messages.length}${next ? '+' : ''}` : '0' }), nav('Previous search result', 'chevron-up', -1), nav('Next search result', 'chevron-down', 1), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Close search"), onPress: onClose, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 20, color: c.foreground }) })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, paddingHorizontal: 12, paddingBottom: 8, fontSize: 13 }, children: error })] });
59
62
  }
package/dist/social.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from './typography.js';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import { forwardRef, useImperativeHandle, useEffect, useState } from 'react';
3
- import { ActivityIndicator, AppState, Linking, Pressable, Text, View } from 'react-native';
5
+ import { ActivityIndicator, AppState, Linking, Pressable, View } from 'react-native';
4
6
  import { socialShare } from '@robono/messaging';
5
7
  import { MessagingPopup } from './popup.js';
6
8
  import { MessageIcon } from './icons.js';
7
9
  import { messageLinks } from './message-links.js';
8
10
  export const SocialMessagePreview = forwardRef(function SocialMessagePreview({ message, client, Player, origin, accent, foreground, background, onLongPress, renderMessage }, ref) {
11
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
9
12
  const share = messageLinks(message.text ?? '').map(link => link.url ? socialShare(link.url, origin) : null).find(Boolean);
10
13
  const [opened, setOpened] = useState(null);
11
14
  useImperativeHandle(ref, () => ({ open: () => { if (share && !message.deleted)
@@ -15,17 +18,18 @@ export const SocialMessagePreview = forwardRef(function SocialMessagePreview({ m
15
18
  setOpened(null); }); return () => sub.remove(); }, []);
16
19
  if (!share || message.deleted)
17
20
  return null;
18
- const action = _jsx(Pressable, { testID: "social-preview-action", hitSlop: 6, accessibilityRole: "button", accessibilityLabel: `Play ${share.provider} preview`, onLongPress: onLongPress, onPress: event => { event.stopPropagation(); setOpened({ client, message }); }, style: ({ pressed }) => ({ flexShrink: 0, width: 32, height: 32, borderRadius: 16, borderWidth: 1, borderColor: accent + '70', backgroundColor: accent + (pressed ? '30' : '0d'), flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }), children: _jsx(MessageIcon, { name: "play", color: accent, size: 16 }) });
21
+ const action = _jsx(Pressable, { testID: "social-preview-action", hitSlop: 6, accessibilityRole: "button", accessibilityLabel: uiText("Play {value1} preview", { value1: share.provider }), onLongPress: onLongPress, onPress: event => { event.stopPropagation(); setOpened({ client, message }); }, style: ({ pressed }) => ({ flexShrink: 0, width: 32, height: 32, borderRadius: 16, borderWidth: 1, borderColor: accent + '70', backgroundColor: accent + (pressed ? '30' : '0d'), flexDirection: 'row', alignItems: 'center', justifyContent: 'center' }), children: _jsx(MessageIcon, { name: "play", color: accent, size: 16 }) });
19
22
  return _jsxs(_Fragment, { children: [renderMessage ? renderMessage(action) : action, open && _jsx(SocialPreviewPopup, { message: message, client: client, Player: Player, origin: origin, provider: share.provider, url: share.url, accent: accent, foreground: foreground, background: background, onClose: () => setOpened(null) }, message.id)] });
20
23
  });
21
24
  function SocialPreviewPopup({ message, client, Player, origin, provider, url, accent, foreground, background, onClose }) {
25
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
22
26
  const [preview, setPreview] = useState(null), [busy, setBusy] = useState(true), [error, setError] = useState('');
23
27
  useEffect(() => { let active = true; void client.socialPreview(message.id, origin).then(result => { if (active) {
24
28
  setPreview(result);
25
29
  if (!result.html)
26
- setError('This post cannot be played here. Open the original below.');
30
+ setError(uiText("This post cannot be played here. Open the original below."));
27
31
  } }).catch(() => { if (active)
28
- setError('This preview could not be loaded. Open the original below.'); }).finally(() => { if (active)
32
+ setError(uiText("This preview could not be loaded. Open the original below.")); }).finally(() => { if (active)
29
33
  setBusy(false); }); return () => { active = false; }; }, [client, message.id, origin]);
30
- return _jsxs(MessagingPopup, { expanded: true, bottomPadding: 4, background: "#000000", title: provider, foreground: "#ffffff", closeLabel: "Close social preview", onClose: onClose, children: [busy && _jsx(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center' }, children: _jsx(ActivityIndicator, { accessibilityLabel: "Loading social preview", color: accent }) }), !!preview?.html && _jsx(View, { testID: "social-preview-player", style: { flex: 1, minHeight: 0, overflow: 'hidden', borderRadius: 10 }, children: _jsx(Player, { html: preview.html, frame: preview.frame, origin: origin, provider: provider, onClose: onClose, onError: () => setError('This preview is unavailable. Open the original below.') }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#ffffff', fontSize: 13, paddingVertical: 8 }, children: error }), _jsx(Pressable, { accessibilityRole: "link", accessibilityLabel: `Open ${provider}`, onPress: () => { void Linking.openURL(url).catch(() => setError('This link could not be opened.')); }, style: { minHeight: 44, justifyContent: 'center' }, children: _jsxs(Text, { style: { color: accent, fontWeight: '600' }, children: ["Open ", provider] }) })] });
34
+ return _jsxs(MessagingPopup, { expanded: true, bottomPadding: 4, background: "#000000", title: provider, foreground: "#ffffff", closeLabel: uiText("Close social preview"), onClose: onClose, children: [busy && _jsx(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center' }, children: _jsx(ActivityIndicator, { accessibilityLabel: uiText("Loading social preview"), color: accent }) }), !!preview?.html && _jsx(View, { testID: "social-preview-player", style: { flex: 1, minHeight: 0, overflow: 'hidden', borderRadius: 10 }, children: _jsx(Player, { html: preview.html, frame: preview.frame, origin: origin, provider: provider, onClose: onClose, onError: () => setError(uiText("This preview is unavailable. Open the original below.")) }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#ffffff', fontSize: 13, paddingVertical: 8 }, children: error }), _jsx(Pressable, { accessibilityRole: "link", accessibilityLabel: uiText("Open {value1}", { value1: provider }), onPress: () => { void Linking.openURL(url).catch(() => setError(uiText("This link could not be opened."))); }, style: { minHeight: 44, justifyContent: 'center' }, children: _jsxs(Text, { style: { color: accent, fontWeight: '600' }, children: [uiText("Open"), provider] }) })] });
31
35
  }
@@ -1,16 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from './typography.js';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import { MessageIcon } from './icons.js';
3
5
  import { messagingPopupColors } from './popup.js';
4
6
  import { useState } from 'react';
5
- import { Pressable, ScrollView, Text, View } from 'react-native';
7
+ import { Pressable, ScrollView, View } from 'react-native';
6
8
  import { normalizeLanguageCode, supportedLanguages, supportedLanguageLabel } from '@robono/messaging';
7
9
  /** The choice belongs to the conversation, never to an individual message. */
8
10
  export function TranslationSettings({ client, appearance, preference, language, sourceLanguage, voiceAvailable, onSaved }) {
11
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
9
12
  const [value, setValue] = useState(() => preference ?? { mode: 'off', language: normalizeLanguageCode(language ?? Intl.DateTimeFormat().resolvedOptions().locale) ?? 'en', sourceLanguage: normalizeLanguageCode(sourceLanguage) });
10
13
  const [choosing, setChoosing] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState('');
11
14
  const c = { ...appearance.colors, ...messagingPopupColors };
12
- return _jsxs(View, { style: { gap: 8 }, children: [_jsx(Text, { style: { fontSize: 13, fontWeight: '600', color: c.secondary, paddingHorizontal: 4 }, children: "Translation" }), _jsxs(View, { style: { backgroundColor: '#111111', borderRadius: 12, borderWidth: 1, borderColor: '#ffffff14', padding: 12, gap: 4 }, children: [_jsx(View, { accessibilityRole: "radiogroup", style: { flexDirection: 'row', borderWidth: 1, borderColor: '#ffffff14', borderRadius: 9, overflow: 'hidden', padding: 3, gap: 3 }, children: ['off', 'text', 'voice'].map(mode => _jsx(Pressable, { accessibilityRole: "radio", accessibilityLabel: 'Translation ' + mode, accessibilityState: { checked: value.mode === mode, disabled: busy || mode === 'voice' && !voiceAvailable }, disabled: busy || mode === 'voice' && !voiceAvailable, onPress: () => setValue(v => ({ ...v, mode })), style: { flex: 1, minHeight: 36, borderRadius: 6, alignItems: 'center', justifyContent: 'center', backgroundColor: value.mode === mode ? c.accent + '25' : 'transparent', opacity: mode === 'voice' && !voiceAvailable ? .4 : 1 }, children: _jsx(Text, { style: { color: value.mode === mode ? c.accent : c.secondary, fontSize: 14, fontWeight: '600' }, children: mode === 'off' ? 'Off' : mode === 'text' ? 'Text' : 'Voice' }) }, mode)) }), ['sourceLanguage', 'language'].map(field => _jsxs(View, { children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: field === 'language' ? 'Translation language' : 'Incoming language', disabled: busy, onPress: () => setChoosing(choosing === field ? null : field), style: { minHeight: 48, flexDirection: 'row', alignItems: 'center', gap: 10, borderBottomWidth: field === 'sourceLanguage' ? 1 : 0, borderBottomColor: '#ffffff14' }, children: [_jsx(Text, { style: { flex: 1, color: c.secondary, fontSize: 14 }, children: field === 'language' ? 'Translate into' : 'Messages in' }), _jsx(Text, { style: { color: c.accent, fontSize: 15, flexShrink: 1, textAlign: 'right' }, children: value[field] ? supportedLanguageLabel(value[field]) : 'Choose language' }), _jsx(MessageIcon, { name: choosing === field ? 'chevron-up' : 'chevron-down', color: c.secondary, size: 16 })] }), choosing === field && _jsx(ScrollView, { style: { maxHeight: 180 }, keyboardShouldPersistTaps: "handled", children: supportedLanguages.map(item => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Choose ' + item.name, onPress: () => { setValue(v => ({ ...v, [field]: item.code })); setChoosing(null); }, style: { minHeight: 40, paddingHorizontal: 8, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground }, children: item.name }) }, item.code)) })] }, field)), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Save translation preference", disabled: busy, onPress: () => { if (value.mode !== 'off' && !value.sourceLanguage) {
13
- setError('Choose the incoming language once for this conversation.');
15
+ return _jsxs(View, { style: { gap: 8 }, children: [_jsx(Text, { style: { fontSize: 13, fontWeight: '600', color: c.secondary, paddingHorizontal: 4 }, children: uiText("Translation") }), _jsxs(View, { style: { backgroundColor: '#111111', borderRadius: 12, borderWidth: 1, borderColor: '#ffffff14', padding: 12, gap: 4 }, children: [_jsx(View, { accessibilityRole: "radiogroup", style: { flexDirection: 'row', borderWidth: 1, borderColor: '#ffffff14', borderRadius: 9, overflow: 'hidden', padding: 3, gap: 3 }, children: ['off', 'text', 'voice'].map(mode => _jsx(Pressable, { accessibilityRole: "radio", accessibilityLabel: uiText('Translation: {mode}', { mode: mode === 'off' ? uiText('Off') : mode === 'text' ? uiText('Text') : uiText('Voice') }), accessibilityState: { checked: value.mode === mode, disabled: busy || mode === 'voice' && !voiceAvailable }, disabled: busy || mode === 'voice' && !voiceAvailable, onPress: () => setValue(v => ({ ...v, mode })), style: { flex: 1, minHeight: 36, borderRadius: 6, alignItems: 'center', justifyContent: 'center', backgroundColor: value.mode === mode ? c.accent + '25' : 'transparent', opacity: mode === 'voice' && !voiceAvailable ? .4 : 1 }, children: _jsx(Text, { style: { color: value.mode === mode ? c.accent : c.secondary, fontSize: 14, fontWeight: '600' }, children: mode === 'off' ? uiText("Off") : mode === 'text' ? uiText("Text") : uiText("Voice") }) }, mode)) }), ['sourceLanguage', 'language'].map(field => _jsxs(View, { children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: field === 'language' ? uiText("Translation language") : uiText("Incoming language"), disabled: busy, onPress: () => setChoosing(choosing === field ? null : field), style: { minHeight: 48, flexDirection: 'row', alignItems: 'center', gap: 10, borderBottomWidth: field === 'sourceLanguage' ? 1 : 0, borderBottomColor: '#ffffff14' }, children: [_jsx(Text, { style: { flex: 1, color: c.secondary, fontSize: 14 }, children: field === 'language' ? uiText("Translate into") : uiText("Messages in") }), _jsx(Text, { style: { color: c.accent, fontSize: 15, flexShrink: 1, textAlign: 'right' }, children: value[field] ? supportedLanguageLabel(value[field]) : uiText("Choose language") }), _jsx(MessageIcon, { name: choosing === field ? 'chevron-up' : 'chevron-down', color: c.secondary, size: 16 })] }), choosing === field && _jsx(ScrollView, { style: { maxHeight: 180 }, keyboardShouldPersistTaps: "handled", children: supportedLanguages.map(item => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText('Choose {language}', { language: item.name }), onPress: () => { setValue(v => ({ ...v, [field]: item.code })); setChoosing(null); }, style: { minHeight: 40, paddingHorizontal: 8, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground }, children: item.name }) }, item.code)) })] }, field)), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Save translation preference"), disabled: busy, onPress: () => { if (value.mode !== 'off' && !value.sourceLanguage) {
16
+ setError(uiText("Choose the incoming language once for this conversation."));
14
17
  return;
15
- } setError(''); setBusy(true); void client.setTranslationPreference(value).then(onSaved).catch(() => setError('The translation preference could not be saved. Try again.')).finally(() => setBusy(false)); }, style: { minHeight: 40, minWidth: 96, paddingHorizontal: 18, marginTop: 8, alignSelf: 'flex-end', alignItems: 'center', justifyContent: 'center', borderRadius: 9, backgroundColor: c.accent, opacity: busy ? 0.5 : 1 }, children: _jsx(Text, { style: { color: '#111111', fontSize: 14, fontWeight: '600' }, children: busy ? 'Saving…' : 'Save' }) })] })] });
18
+ } setError(''); setBusy(true); void client.setTranslationPreference(value).then(onSaved).catch(() => setError(uiText("The translation preference could not be saved. Try again."))).finally(() => setBusy(false)); }, style: { minHeight: 40, minWidth: 96, paddingHorizontal: 18, marginTop: 8, alignSelf: 'flex-end', alignItems: 'center', justifyContent: 'center', borderRadius: 9, backgroundColor: c.accent, opacity: busy ? 0.5 : 1 }, children: _jsx(Text, { style: { color: '#111111', fontSize: 14, fontWeight: '600' }, children: busy ? uiText("Saving…") : uiText("Save") }) })] })] });
16
19
  }
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import { Text as NativeText, TextInput as NativeTextInput, type TextProps, type TextInputProps } from 'react-native';
3
+ import type { MessagingAppearance } from '@robono/messaging';
4
+ /** Local to this Messenger. Does not change host-app text or device accessibility scaling. */
5
+ export declare function MessagingTypography({ appearance, children }: {
6
+ appearance: MessagingAppearance;
7
+ children: React.ReactNode;
8
+ }): React.JSX.Element;
9
+ export type Text = NativeText;
10
+ export declare const Text: React.ForwardRefExoticComponent<TextProps & React.RefAttributes<NativeText>>;
11
+ export type TextInput = NativeTextInput;
12
+ export declare const TextInput: React.ForwardRefExoticComponent<TextInputProps & React.RefAttributes<NativeTextInput>>;