@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,10 +1,13 @@
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, useMemo, useRef, useState } from 'react';
3
- import { ActivityIndicator, FlatList, Image, Pressable, Text, TextInput, View } from 'react-native';
5
+ import { ActivityIndicator, FlatList, Image, Pressable, View } from 'react-native';
4
6
  import { MessageIcon } from './icons.js';
5
7
  import { MessagingPopup, messagingPopupColors as c } from './popup.js';
6
8
  /** Messenger's name, group type and contact checklist; changes use member deltas. */
7
9
  export function MessagingGroupEditor({ client, inbox, userId, contacts, conversation, groups, accent, onClose, onSaved }) {
10
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
8
11
  const editing = !!conversation, conversationId = client.getSnapshot().conversationId;
9
12
  const [name, setName] = useState(conversation?.name ?? ''), [supergroup, setSupergroup] = useState(conversation?.supergroup ?? false);
10
13
  const [revision, setRevision] = useState(conversation?.revision ?? 0), [count, setCount] = useState(conversation?.memberCount ?? 0);
@@ -34,7 +37,7 @@ export function MessagingGroupEditor({ client, inbox, userId, contacts, conversa
34
37
  catch (e) {
35
38
  if (version === generation.current) {
36
39
  setReady(false);
37
- setError(e instanceof Error ? e.message : 'Group members could not be loaded.');
40
+ setError(e instanceof Error ? e.message : uiText("Group members could not be loaded."));
38
41
  }
39
42
  }
40
43
  finally {
@@ -49,7 +52,7 @@ export function MessagingGroupEditor({ client, inbox, userId, contacts, conversa
49
52
  const chosen = (id) => !removed.has(id) && (original.has(id) || added.has(id));
50
53
  const toggle = (id) => {
51
54
  if (!chosen(id) && membersCount >= max) {
52
- setError(`This group can have up to ${max.toLocaleString()} members.`);
55
+ setError(uiText("This group can have up to {value1} members.", { value1: max.toLocaleString(uiLocale) }));
53
56
  return;
54
57
  }
55
58
  if (original.has(id))
@@ -86,13 +89,13 @@ export function MessagingGroupEditor({ client, inbox, userId, contacts, conversa
86
89
  }
87
90
  }
88
91
  catch (e) {
89
- setError(e instanceof Error ? e.message : 'The group could not be saved.');
92
+ setError(e instanceof Error ? e.message : uiText("The group could not be saved."));
90
93
  }
91
94
  finally {
92
95
  setBusy(false);
93
96
  }
94
97
  }
95
98
  const locked = busy || loading || !ready, canSave = !locked && !!name.trim() && membersCount >= 2 && membersCount <= max;
96
- return _jsxs(MessagingPopup, { title: editing ? 'Edit group' : 'New group', background: c.background, closeDisabled: busy, onClose: () => { if (!busy)
97
- onClose(); }, form: true, children: [_jsx(FlatList, { data: people, keyExtractor: id => id, keyboardShouldPersistTaps: "handled", style: { flex: 1 }, contentContainerStyle: { paddingHorizontal: 8, paddingBottom: 16 }, initialNumToRender: 15, ListHeaderComponent: _jsxs(View, { style: { gap: 12, paddingTop: 12 }, children: [_jsx(TextInput, { accessibilityLabel: "Group name", placeholder: "Group name", placeholderTextColor: c.secondary, maxLength: 100, value: name, onChangeText: setName, editable: !locked, style: { color: c.foreground, fontSize: 21, minHeight: 48, paddingHorizontal: 4, borderBottomWidth: 1, borderBottomColor: '#ffffff30' } }), groups?.supergroupsEnabled && _jsx(View, { style: { flexDirection: 'row', gap: 20 }, children: [false, true].map(value => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: value ? 'Supergroup' : 'Standard group', accessibilityState: { checked: supergroup === value, disabled: locked }, disabled: locked, onPress: () => setSupergroup(value), style: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsx(MessageIcon, { name: supergroup === value ? 'radio-button-on' : 'radio-button-off', color: accent, size: 22 }), _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: value ? 'Supergroup' : 'Group' })] }, String(value))) }), _jsxs(Text, { style: { color: c.secondary, fontSize: 14 }, children: [membersCount.toLocaleString(), " / ", max.toLocaleString(), " members"] }), _jsx(Text, { style: { color: c.foreground, fontSize: 17 }, children: "You \u00B7 Group owner" }), _jsx(TextInput, { accessibilityLabel: "Search group contacts", placeholder: "Search contacts", placeholderTextColor: c.secondary, value: query, onChangeText: setQuery, style: { color: c.foreground, fontSize: 16, minHeight: 44, borderBottomWidth: 1, borderBottomColor: '#ffffff30' } })] }), renderItem: ({ item: id }) => _jsxs(Pressable, { accessibilityRole: "checkbox", accessibilityLabel: contacts[id]?.name ?? 'Member', accessibilityState: { checked: chosen(id), disabled: locked }, disabled: locked, onPress: () => toggle(id), style: { minHeight: 56, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [contacts[id]?.avatarUrl ? _jsx(Image, { source: { uri: contacts[id].avatarUrl }, style: { width: 36, height: 36, borderRadius: 18 } }) : _jsx(View, { style: { width: 36, height: 36, borderRadius: 18, backgroundColor: c.incoming, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 18 }, children: (contacts[id]?.name ?? 'Member').slice(0, 1) }) }), _jsx(Text, { style: { flex: 1, color: c.foreground, fontSize: 16 }, children: contacts[id]?.name ?? 'Member' }), _jsx(MessageIcon, { name: chosen(id) ? 'checkmark-circle' : 'ellipse-outline', color: chosen(id) ? accent : c.secondary, size: 24 })] }), ListFooterComponent: _jsxs(View, { style: { gap: 12, paddingTop: 12 }, children: [loading && _jsx(ActivityIndicator, { accessibilityLabel: "Loading group members", color: accent }), next && _jsx(Pressable, { accessibilityRole: "button", disabled: loading || busy, onPress: () => void load(true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: "Load more members" }) }), !people.length && !loading && _jsx(Text, { style: { color: c.secondary }, children: query ? 'No matching contacts.' : 'Your app has not supplied any contacts to invite.' }), _jsxs(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19 }, children: ["Only the group owner can change the name or members. ", groups?.historyForNewMembers === 'all' ? 'New members can see retained group history.' : 'New members see messages sent after they join.'] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, fontSize: 15 }, children: error })] }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: editing ? 'Save group' : 'Create group', disabled: !canSave, onPress: () => void save(), style: { minHeight: 48, margin: 8, borderRadius: 12, backgroundColor: accent, alignItems: 'center', justifyContent: 'center', opacity: canSave ? 1 : .4 }, children: busy ? _jsx(ActivityIndicator, { color: "#000" }) : _jsx(Text, { style: { color: '#000', fontSize: 17, fontWeight: '600' }, children: editing ? 'Save' : 'Create group' }) })] });
99
+ return _jsxs(MessagingPopup, { title: editing ? uiText("Edit group") : uiText("New group"), background: c.background, closeDisabled: busy, onClose: () => { if (!busy)
100
+ onClose(); }, form: true, children: [_jsx(FlatList, { data: people, keyExtractor: id => id, keyboardShouldPersistTaps: "handled", style: { flex: 1 }, contentContainerStyle: { paddingHorizontal: 8, paddingBottom: 16 }, initialNumToRender: 15, ListHeaderComponent: _jsxs(View, { style: { gap: 12, paddingTop: 12 }, children: [_jsx(TextInput, { accessibilityLabel: uiText("Group name"), placeholder: uiText("Group name"), placeholderTextColor: c.secondary, maxLength: 100, value: name, onChangeText: setName, editable: !locked, style: { color: c.foreground, fontSize: 21, minHeight: 48, paddingHorizontal: 4, borderBottomWidth: 1, borderBottomColor: '#ffffff30' } }), groups?.supergroupsEnabled && _jsx(View, { style: { flexDirection: 'row', gap: 20 }, children: [false, true].map(value => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: value ? uiText("Supergroup") : uiText("Standard group"), accessibilityState: { checked: supergroup === value, disabled: locked }, disabled: locked, onPress: () => setSupergroup(value), style: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsx(MessageIcon, { name: supergroup === value ? 'radio-button-on' : 'radio-button-off', color: accent, size: 22 }), _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: value ? uiText("Supergroup") : uiText("Group") })] }, String(value))) }), _jsx(Text, { style: { color: c.secondary, fontSize: 14 }, children: uiText('{count} / {limit} members', { count: membersCount, limit: max }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 17 }, children: uiText("You · Group owner") }), _jsx(TextInput, { accessibilityLabel: uiText("Search group contacts"), placeholder: uiText("Search contacts"), placeholderTextColor: c.secondary, value: query, onChangeText: setQuery, style: { color: c.foreground, fontSize: 16, minHeight: 44, borderBottomWidth: 1, borderBottomColor: '#ffffff30' } })] }), renderItem: ({ item: id }) => _jsxs(Pressable, { accessibilityRole: "checkbox", accessibilityLabel: contacts[id]?.name ?? uiText("Member"), accessibilityState: { checked: chosen(id), disabled: locked }, disabled: locked, onPress: () => toggle(id), style: { minHeight: 56, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [contacts[id]?.avatarUrl ? _jsx(Image, { source: { uri: contacts[id].avatarUrl }, style: { width: 36, height: 36, borderRadius: 18 } }) : _jsx(View, { style: { width: 36, height: 36, borderRadius: 18, backgroundColor: c.incoming, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 18 }, children: (contacts[id]?.name ?? 'Member').slice(0, 1) }) }), _jsx(Text, { style: { flex: 1, color: c.foreground, fontSize: 16 }, children: contacts[id]?.name ?? uiText("Member") }), _jsx(MessageIcon, { name: chosen(id) ? 'checkmark-circle' : 'ellipse-outline', color: chosen(id) ? accent : c.secondary, size: 24 })] }), ListFooterComponent: _jsxs(View, { style: { gap: 12, paddingTop: 12 }, children: [loading && _jsx(ActivityIndicator, { accessibilityLabel: uiText("Loading group members"), color: accent }), next && _jsx(Pressable, { accessibilityRole: "button", disabled: loading || busy, onPress: () => void load(true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: uiText("Load more members") }) }), !people.length && !loading && _jsx(Text, { style: { color: c.secondary }, children: query ? uiText("No matching contacts.") : uiText("Your app has not supplied any contacts to invite.") }), _jsxs(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19 }, children: [uiText("Only the group owner can change the name or members."), groups?.historyForNewMembers === 'all' ? uiText("New members can see retained group history.") : uiText("New members see messages sent after they join.")] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, fontSize: 15 }, children: error })] }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: editing ? uiText("Save group") : uiText("Create group"), disabled: !canSave, onPress: () => void save(), style: { minHeight: 48, margin: 8, borderRadius: 12, backgroundColor: accent, alignItems: 'center', justifyContent: 'center', opacity: canSave ? 1 : .4 }, children: busy ? _jsx(ActivityIndicator, { color: "#000" }) : _jsx(Text, { style: { color: '#000', fontSize: 17, fontWeight: '600' }, children: editing ? uiText("Save") : uiText("Create group") }) })] });
98
101
  }
@@ -1,10 +1,13 @@
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 { useEffect, useRef, useState } from 'react';
3
- import { Keyboard, Linking, Platform, Pressable, Text, View } from 'react-native';
5
+ import { Keyboard, Linking, Platform, Pressable, View } from 'react-native';
4
6
  import { MessageIcon } from './icons.js';
5
7
  import { MessagingPopup } from './popup.js';
6
8
  /** Matches Messenger: video is separate; the phone button chooses internet/carrier. */
7
9
  export function MessagingHeaderCalls({ actions, name, group, color }) {
10
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
8
11
  const [visible, setVisible] = useState(false), [error, setError] = useState('');
9
12
  const pending = useRef(null), latest = useRef(actions);
10
13
  latest.current = actions;
@@ -24,7 +27,7 @@ export function MessagingHeaderCalls({ actions, name, group, color }) {
24
27
  await current?.[kind]?.();
25
28
  }
26
29
  catch {
27
- setError('The call could not start. Please try again.');
30
+ setError(uiText("The call could not start. Please try again."));
28
31
  } };
29
32
  const dismissed = () => { const kind = pending.current; pending.current = null; if (kind)
30
33
  requestAnimationFrame(() => void invoke(kind)); };
@@ -37,10 +40,10 @@ export function MessagingHeaderCalls({ actions, name, group, color }) {
37
40
  } }; document.addEventListener('keydown', close, true); return () => document.removeEventListener('keydown', close, true); }, [visible]);
38
41
  useEffect(() => { pending.current = null; setVisible(false); setError(''); }, [name, group]);
39
42
  const choose = (kind) => { pending.current = kind; setVisible(false); };
40
- const button = (label, icon, press) => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label, onPress: press, style: { width: icon === 'video' ? 42 : 44, height: icon === 'video' ? 42 : 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: icon, size: 23, color: color }) });
41
- const row = (kind, label, detail) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: label, onPress: () => choose(kind), style: ({ pressed }) => ({ flexDirection: 'row', alignItems: 'center', gap: 16, minHeight: 80, padding: 12, borderRadius: 18, backgroundColor: pressed ? '#1D1D1D' : 'transparent' }), children: [_jsx(View, { style: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#1D1D1D', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: kind === 'phone' ? 'phone-portrait-outline' : 'phone', color: "#F7F7F7", size: 24 }) }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: { color: '#F7F7F7', fontSize: 18, fontWeight: '600' }, children: label }), _jsx(Text, { style: { color: '#A3A3A3', fontSize: 15 }, children: detail })] })] });
42
- return _jsxs(_Fragment, { children: [actions?.video && button(actions.videoLabel ?? 'Video call', 'video', () => void invoke('video')), (actions?.voice || validPhone) && button('Call ' + name, 'phone', () => { if (!validPhone) {
43
+ const button = (label, icon, press) => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText(label), onPress: press, style: { width: icon === 'video' ? 42 : 44, height: icon === 'video' ? 42 : 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: icon, size: 23, color: color }) });
44
+ const row = (kind, label, detail) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText(label), onPress: () => choose(kind), style: ({ pressed }) => ({ flexDirection: 'row', alignItems: 'center', gap: 16, minHeight: 80, padding: 12, borderRadius: 18, backgroundColor: pressed ? '#1D1D1D' : 'transparent' }), children: [_jsx(View, { style: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#1D1D1D', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: kind === 'phone' ? 'phone-portrait-outline' : 'phone', color: "#F7F7F7", size: 24 }) }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: { color: '#F7F7F7', fontSize: 18, fontWeight: '600' }, children: uiText(label) }), _jsx(Text, { style: { color: '#A3A3A3', fontSize: 15 }, children: uiText(detail) })] })] });
45
+ return _jsxs(_Fragment, { children: [actions?.video && button(actions.videoLabel ?? 'Video call', 'video', () => void invoke('video')), (actions?.voice || validPhone) && button(uiText('Call {name}', { name }), 'phone', () => { if (!validPhone) {
43
46
  void invoke('voice');
44
47
  return;
45
- } Keyboard.dismiss(); setVisible(true); }), _jsx(MessagingPopup, { visible: visible || !!error, compact: true, title: 'Call ' + name, background: "#111111", onDismiss: dismissed, onClose: () => { pending.current = null; setVisible(false); setError(''); }, children: error ? _jsx(Text, { accessibilityRole: "alert", style: { color: '#F7F7F7', fontSize: 16, paddingTop: 12 }, children: error }) : _jsxs(_Fragment, { children: [actions?.voice && row('voice', actions.voiceLabel ?? 'Internet call', 'Wi-Fi or mobile data'), validPhone && row('phone', 'Phone call', validPhone)] }) })] });
48
+ } Keyboard.dismiss(); setVisible(true); }), _jsx(MessagingPopup, { visible: visible || !!error, compact: true, title: uiText('Call {name}', { name }), background: "#111111", onDismiss: dismissed, onClose: () => { pending.current = null; setVisible(false); setError(''); }, children: error ? _jsx(Text, { accessibilityRole: "alert", style: { color: '#F7F7F7', fontSize: 16, paddingTop: 12 }, children: error }) : _jsxs(_Fragment, { children: [actions?.voice && row('voice', actions.voiceLabel ?? 'Internet call', 'Wi-Fi or mobile data'), validPhone && row('phone', 'Phone call', validPhone)] }) })] });
46
49
  }
package/dist/icons.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { RobonoIcon } from "./visual/RobonoIcon.js";
3
- export function MessageIcon(props) { return _jsx(RobonoIcon, { ...props }); }
3
+ import { useRobonoLocale } from './localization.js';
4
+ export function MessageIcon(props) { const { direction } = useRobonoLocale(); return _jsx(RobonoIcon, { ...props, style: [props.style, props.name === 'back' && direction === 'rtl' ? { transform: [{ scaleX: -1 }] } : undefined] }); }
4
5
  export function duration(ms = 0) { const seconds = Math.ceil(ms / 1000); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; }
5
6
  export function fileSize(bytes = 0) { return bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : bytes >= 1024 ? `${Math.ceil(bytes / 1024)} KB` : `${bytes} B`; }
6
7
  /** Neutral app chrome stays legible for light and dark customer palettes. */
@@ -1,7 +1,9 @@
1
+ import { type InboxViewMemory } from './inbox-view.js';
1
2
  import React from 'react';
2
3
  import type { ConversationSummary, MessagingInboxClient, MessagingAppearance } from '@robono/messaging';
3
4
  /** Main-screen search covers retained messages beyond the current inbox page. */
4
- export declare function InboxMessageSearch({ client, query, appearance, name, chats, onSelect }: {
5
+ export declare function InboxMessageSearch({ client, query, appearance, name, chats, onSelect, viewMemory }: {
6
+ viewMemory: InboxViewMemory;
5
7
  client: MessagingInboxClient;
6
8
  query: string;
7
9
  appearance: MessagingAppearance;
@@ -1,9 +1,14 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useInboxScroll } from './inbox-view.js';
3
+ import { Text } from './typography.js';
4
+ import { useRobonoLocale } from './localization.js';
2
5
  import { useEffect, useRef, useState } from 'react';
3
- import { ActivityIndicator, FlatList, Pressable, Text, View } from 'react-native';
6
+ import { ActivityIndicator, FlatList, Pressable, View } from 'react-native';
4
7
  /** Main-screen search covers retained messages beyond the current inbox page. */
5
- export function InboxMessageSearch({ client, query, appearance, name, chats, onSelect }) {
6
- const c = appearance.colors, [rows, setRows] = useState([]), [next, setNext] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState('');
8
+ export function InboxMessageSearch({ client, query, appearance, name, chats, onSelect, viewMemory }) {
9
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
10
+ const c = appearance.colors, [rows, setRows] = useState([]), [next, setNext] = useState(null), [busy, setBusy] = useState(true), [error, setError] = useState('');
11
+ const scroll = useInboxScroll(viewMemory, 'search:' + query, rows.length > 0 && !busy, !next);
7
12
  const controller = useRef(null), generation = useRef(0);
8
13
  async function search(more = false) {
9
14
  controller.current?.abort();
@@ -13,16 +18,34 @@ export function InboxMessageSearch({ client, query, appearance, name, chats, onS
13
18
  setBusy(true);
14
19
  setError('');
15
20
  try {
16
- const page = await client.search(query, { ...(more && next ? { before: next } : {}), signal: control.signal });
17
- if (version !== generation.current)
18
- return;
19
- setRows(current => more ? [...new Map([...current, ...page.results].map(row => [row.message.conversationId + ':' + row.message.id, row])).values()] : page.results);
21
+ const collected = new Map(more ? rows.map(row => [row.message.conversationId + ':' + row.message.id, row]) : []);
22
+ const target = more ? 0 : Math.max(20, viewMemory.searchCounts.get(query) ?? 20), seen = new Set();
23
+ let before = more ? next : null, page;
24
+ do {
25
+ page = await client.search(query, { ...(before ? { before } : {}), signal: control.signal });
26
+ if (version !== generation.current || control.signal.aborted)
27
+ return;
28
+ for (const row of page.results)
29
+ collected.set(row.message.conversationId + ':' + row.message.id, row);
30
+ if (more || !page.hasMore || collected.size >= target)
31
+ break;
32
+ if (!page.nextCursor || seen.has(page.nextCursor) || seen.size > Math.ceil(target / 20) + 2)
33
+ throw Error(uiText('Messages could not be searched.'));
34
+ seen.add(page.nextCursor);
35
+ before = page.nextCursor;
36
+ } while (true);
37
+ setRows([...collected.values()]);
20
38
  setNext(page.hasMore ? page.nextCursor : null);
39
+ viewMemory.searchCounts.delete(query);
40
+ viewMemory.searchCounts.set(query, collected.size);
41
+ while (viewMemory.searchCounts.size > 12)
42
+ viewMemory.searchCounts.delete(viewMemory.searchCounts.keys().next().value);
21
43
  }
22
44
  catch (e) {
23
45
  if (version === generation.current && !control.signal.aborted) {
24
- setRows([]);
25
- setError(e instanceof Error ? e.message : 'Messages could not be searched.');
46
+ if (!more || e?.status === 401 || e?.status === 403)
47
+ setRows([]);
48
+ setError(e instanceof Error ? e.message : uiText("Messages could not be searched."));
26
49
  }
27
50
  }
28
51
  finally {
@@ -38,5 +61,5 @@ export function InboxMessageSearch({ client, query, appearance, name, chats, onS
38
61
  const timer = setTimeout(() => void search(), 300);
39
62
  return () => { clearTimeout(timer); ++generation.current; controller.current?.abort(); };
40
63
  }, [client, query]);
41
- return _jsx(FlatList, { style: { flex: 1 }, data: rows, keyExtractor: row => row.message.conversationId + ':' + row.message.id, keyboardShouldPersistTaps: "handled", ListHeaderComponent: _jsxs(View, { children: [chats.length > 0 && _jsxs(_Fragment, { children: [_jsx(Text, { style: { paddingHorizontal: 16, paddingVertical: 8, color: c.secondary, fontSize: 14, fontWeight: '600' }, children: "Chats" }), chats.map(chat => _jsx(Pressable, { accessibilityRole: "button", onPress: () => onSelect(chat), style: { minHeight: 48, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: name(chat) }) }, chat.id))] }), _jsx(Text, { style: { paddingHorizontal: 16, paddingVertical: 12, color: c.secondary, fontSize: 14, fontWeight: '600' }, children: "Messages" })] }), ListEmptyComponent: !busy && !error ? _jsx(Text, { style: { padding: 16, color: c.secondary }, children: "No matching messages." }) : null, renderItem: ({ item }) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Message in ${name(item.conversation)}: ${item.message.text}`, onPress: () => onSelect(item.conversation, item.message.id), style: { paddingHorizontal: 16, paddingVertical: 12, gap: 5, borderBottomWidth: 1, borderBottomColor: c.foreground + '16' }, children: [_jsxs(View, { style: { flexDirection: 'row', gap: 8 }, children: [_jsx(Text, { numberOfLines: 1, style: { flex: 1, fontSize: 16, fontWeight: '600', color: c.foreground }, children: name(item.conversation) }), _jsx(Text, { style: { fontSize: 12, color: c.secondary }, children: new Date(item.message.createdAt).toLocaleDateString() })] }), _jsx(Text, { numberOfLines: 3, style: { fontSize: 15, lineHeight: 21, color: c.secondary }, children: item.message.text })] }), ListFooterComponent: _jsxs(View, { children: [busy && _jsx(ActivityIndicator, { accessibilityLabel: "Searching all messages", color: c.accent, style: { padding: 16 } }), !!error && _jsxs(_Fragment, { children: [_jsx(Text, { accessibilityRole: "alert", style: { padding: 16, color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void search(), style: { minHeight: 44, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Try again" }) })] }), next && !busy && !error && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void search(true), style: { minHeight: 48, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "More results" }) })] }) });
64
+ return _jsx(FlatList, { ...scroll, testID: "messaging-inbox-search-results", style: { flex: 1 }, data: rows, keyExtractor: row => row.message.conversationId + ':' + row.message.id, keyboardShouldPersistTaps: "handled", ListHeaderComponent: _jsxs(View, { children: [chats.length > 0 && _jsxs(_Fragment, { children: [_jsx(Text, { style: { paddingHorizontal: 16, paddingVertical: 8, color: c.secondary, fontSize: 14, fontWeight: '600' }, children: uiText("Chats") }), chats.map(chat => _jsx(Pressable, { accessibilityRole: "button", onPress: () => onSelect(chat), style: { minHeight: 48, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: name(chat) }) }, chat.id))] }), _jsx(Text, { style: { paddingHorizontal: 16, paddingVertical: 12, color: c.secondary, fontSize: 14, fontWeight: '600' }, children: uiText("Messages") })] }), ListEmptyComponent: !busy && !error ? _jsx(Text, { style: { padding: 16, color: c.secondary }, children: uiText("No matching messages.") }) : null, renderItem: ({ item }) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Message in {value1}: {value2}", { value1: name(item.conversation), value2: item.message.text ?? '' }), onPress: () => onSelect(item.conversation, item.message.id), style: { paddingHorizontal: 16, paddingVertical: 12, gap: 5, borderBottomWidth: 1, borderBottomColor: c.foreground + '16' }, children: [_jsxs(View, { style: { flexDirection: 'row', gap: 8 }, children: [_jsx(Text, { numberOfLines: 1, style: { flex: 1, fontSize: 16, fontWeight: '600', color: c.foreground }, children: name(item.conversation) }), _jsx(Text, { style: { fontSize: 12, color: c.secondary }, children: new Date(item.message.createdAt).toLocaleDateString(uiLocale) })] }), _jsx(Text, { numberOfLines: 3, style: { fontSize: 15, lineHeight: 21, color: c.secondary }, children: item.message.text })] }), ListFooterComponent: _jsxs(View, { children: [busy && _jsx(ActivityIndicator, { accessibilityLabel: uiText("Searching all messages"), color: c.accent, style: { padding: 16 } }), !!error && _jsxs(_Fragment, { children: [_jsx(Text, { accessibilityRole: "alert", style: { padding: 16, color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void search(rows.length > 0 && !!next), style: { minHeight: 44, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: uiText("Try again") }) })] }), next && !busy && !error && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void search(true), style: { minHeight: 48, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: uiText("More results") }) })] }) });
42
65
  }
@@ -0,0 +1,21 @@
1
+ import type { FlatList, NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
2
+ /** Ephemeral navigation state. Never persist it across signed-in accounts. */
3
+ export interface InboxViewMemory {
4
+ query: string;
5
+ offsets: Map<string, number>;
6
+ searchCounts: Map<string, number>;
7
+ }
8
+ export declare const createInboxViewMemory: () => InboxViewMemory;
9
+ export declare function useInboxScroll(memory: InboxViewMemory, key: string, ready: boolean, complete: boolean): {
10
+ ref: import("react").RefObject<FlatList<any> | null>;
11
+ scrollEventThrottle: number;
12
+ onLayout: (event: {
13
+ nativeEvent: {
14
+ layout: {
15
+ height: number;
16
+ };
17
+ };
18
+ }) => void;
19
+ onContentSizeChange: (_width: number, height: number) => void;
20
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
21
+ };
@@ -0,0 +1,32 @@
1
+ import { useLayoutEffect, useRef } from 'react';
2
+ export const createInboxViewMemory = () => ({ query: '', offsets: new Map(), searchCounts: new Map() });
3
+ export function useInboxScroll(memory, key, ready, complete) {
4
+ const list = useRef(null);
5
+ const state = useRef({ key, memory, wanted: memory.offsets.get(key) ?? 0, restored: false, height: 0, content: 0 });
6
+ if (state.current.key !== key || state.current.memory !== memory)
7
+ state.current = { key, memory, wanted: memory.offsets.get(key) ?? 0, restored: false, height: 0, content: 0 };
8
+ const restore = () => {
9
+ const s = state.current;
10
+ if (s.restored || !s.height || !ready)
11
+ return;
12
+ const max = Math.max(0, s.content - s.height);
13
+ if (max < s.wanted && !complete)
14
+ return;
15
+ s.restored = true;
16
+ list.current?.scrollToOffset({ offset: Math.min(max, s.wanted), animated: false });
17
+ };
18
+ useLayoutEffect(restore, [ready, complete, key, memory]);
19
+ return { ref: list, scrollEventThrottle: 32,
20
+ onLayout: (event) => { state.current.height = event.nativeEvent.layout.height; restore(); },
21
+ onContentSizeChange: (_width, height) => { state.current.content = height; restore(); },
22
+ onScroll: (event) => {
23
+ if (!state.current.restored)
24
+ return;
25
+ const value = Math.max(0, event.nativeEvent.contentOffset.y);
26
+ memory.offsets.delete(key);
27
+ memory.offsets.set(key, value);
28
+ while (memory.offsets.size > 12)
29
+ memory.offsets.delete(memory.offsets.keys().next().value);
30
+ },
31
+ };
32
+ }
package/dist/inbox.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type InboxViewMemory } from './inbox-view.js';
1
2
  import React from 'react';
2
3
  import { type MessagingAppearance, type MessagingInboxClient, type ConversationSummary } from '@robono/messaging';
3
4
  export interface MessagingInboxProps {
@@ -7,6 +8,10 @@ export interface MessagingInboxProps {
7
8
  name: string;
8
9
  avatarUrl?: string;
9
10
  }>;
11
+ viewMemory?: InboxViewMemory;
12
+ query?: string;
13
+ onSearchChange?: (value: string) => void;
14
+ selectedId?: string;
10
15
  companyName?: string;
11
16
  headerActions?: React.ReactNode;
12
17
  searchText?: (conversation: ConversationSummary) => string;
@@ -16,4 +21,4 @@ export interface MessagingInboxProps {
16
21
  onDeleted?: (conversationId: string) => Promise<void> | void;
17
22
  }
18
23
  /** Compact Messenger chat rows: avatar, name, last message, time and unread badge. */
19
- export declare function MessagingInbox({ client, userId, contacts, appearance, active, onSelect, onDeleted, companyName, headerActions, searchText }: MessagingInboxProps): React.JSX.Element;
24
+ export declare function MessagingInbox({ client, userId, contacts, appearance, active, onSelect, onDeleted, companyName, headerActions, searchText, selectedId, query: controlledQuery, onSearchChange, viewMemory }: MessagingInboxProps): React.JSX.Element;
package/dist/inbox.js CHANGED
@@ -1,19 +1,28 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { createInboxViewMemory, useInboxScroll } from './inbox-view.js';
3
+ import { MessagingTypography } from './typography.js';
4
+ import { Text, TextInput } from './typography.js';
5
+ import { useRobonoLocale } from './localization.js';
2
6
  import { MessageMenuRow } from './message-menu.js';
3
7
  import { InboxMessageSearch } from './inbox-search.js';
4
8
  import { MessageIcon } from './icons.js';
5
9
  import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
6
- import { AppState, FlatList, Image, Platform, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
10
+ import { AppState, FlatList, Image, Platform, Pressable, ScrollView, View } from 'react-native';
7
11
  import { defaultMessagingSettings } from '@robono/messaging';
8
12
  import { MessagingPopup, MessagingMenuSurface, MessagingPopupHeaderBoundary, messagingPopupColors, messagingPopupBackdrop } from './popup.js';
9
13
  /** Compact Messenger chat rows: avatar, name, last message, time and unread badge. */
10
- export function MessagingInbox({ client, userId, contacts = {}, appearance, active = true, onSelect, onDeleted, companyName, headerActions, searchText }) {
14
+ export function MessagingInbox({ client, userId, contacts = {}, appearance, active = true, onSelect, onDeleted, companyName, headerActions, searchText, selectedId, query: controlledQuery, onSearchChange, viewMemory }) {
15
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
11
16
  const state = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot), design = appearance ?? state.appearance ?? defaultMessagingSettings().appearance, c = design.colors;
12
17
  useEffect(() => { const update = (value) => { if (active && (value === null || value === 'active'))
13
18
  client.start();
14
19
  else
15
20
  client.stop(); }; update(AppState.currentState); const sub = AppState.addEventListener('change', update); return () => { sub.remove(); client.stop(); }; }, [client, active]);
16
- const [query, setQuery] = useState('');
21
+ const ownMemory = useRef(createInboxViewMemory()), memory = viewMemory ?? ownMemory.current;
22
+ const [localQuery, setLocalQuery] = useState(memory.query);
23
+ const query = controlledQuery ?? localQuery;
24
+ const setQuery = (value) => { memory.query = value; (onSearchChange ?? setLocalQuery)(value); };
25
+ const scroll = useInboxScroll(memory, 'chats:' + query.trim(), state.conversations.length > 0 && !state.loading, !state.hasMore);
17
26
  const [selected, setSelected] = useState(null), [confirm, setConfirm] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState('');
18
27
  const title = (row) => row.kind === 'group' ? row.name ?? 'Group' : contacts[row.contactId ?? '']?.name ?? row.contactId ?? 'Chat';
19
28
  const rows = useRef(new Map()), pending = useRef(null);
@@ -45,25 +54,25 @@ export function MessagingInbox({ client, userId, contacts = {}, appearance, acti
45
54
  setConfirm(false);
46
55
  }
47
56
  catch (e) {
48
- setError(e instanceof Error ? e.message : 'Chat options could not be saved.');
57
+ setError(e instanceof Error ? e.message : uiText("Chat options could not be saved."));
49
58
  }
50
59
  finally {
51
60
  setBusy(false);
52
61
  } }
53
62
  const preview = (row) => { const m = row.lastMessage; if (!m)
54
63
  return 'No messages yet'; const text = m.kind === 'text' ? m.text ?? '' : ({ voice: 'Voice message', image: 'Photo', video: 'Video', document: 'Document', poll: 'Poll' }[m.kind] ?? 'Message'); return (m.senderId === userId ? 'You: ' : '') + text; };
55
- return _jsxs(View, { style: { flex: 1, minHeight: 0, backgroundColor: c.background }, children: [_jsx(MessagingPopupHeaderBoundary, { children: _jsxs(View, { testID: "messaging-inbox-header", style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', minHeight: 64, paddingHorizontal: 16, paddingVertical: 8, gap: 8 }, children: [_jsx(Text, { accessibilityRole: "header", numberOfLines: 1, style: { flex: 1, color: c.foreground, fontSize: 20, fontWeight: '700' }, children: companyName ?? design.name }), headerActions] }) }), _jsxs(View, { style: { margin: 12, marginTop: 8, flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 10, paddingHorizontal: 10, backgroundColor: c.foreground + '0b' }, children: [_jsx(MessageIcon, { name: "search", size: 20, color: c.secondary }), _jsx(TextInput, { accessibilityLabel: "Search chats", placeholder: state.globalSearchAvailable ? "Search chats and messages" : "Search chats", placeholderTextColor: c.secondary, value: query, onChangeText: setQuery, maxLength: 200, autoCapitalize: "none", autoCorrect: false, style: { flex: 1, minHeight: 44, color: c.foreground, fontSize: 16 } }), !!query && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Clear chat search", onPress: () => setQuery(''), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 20, color: c.secondary }) })] }), state.error && _jsxs(View, { style: { padding: 12 }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: state.error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void client.refresh().catch(() => { }), children: _jsx(Text, { style: { color: c.accent, paddingVertical: 8 }, children: "Try again" }) })] }), query.trim() && state.globalSearchAvailable ? _jsx(InboxMessageSearch, { client: client, query: query.trim(), appearance: design, name: title, chats: state.conversations.filter(row => title(row).toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), onSelect: onSelect }, userId) : _jsx(FlatList, { data: state.conversations.filter(row => !query.trim() || [title(row), row.lastMessage?.text ?? '', searchText?.(row) ?? ''].join(' ').toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), keyExtractor: row => row.id, contentContainerStyle: { flexGrow: 1 }, ItemSeparatorComponent: () => _jsx(View, { style: { height: 1, backgroundColor: c.foreground + '12', marginLeft: 64 } }), ListEmptyComponent: _jsx(Text, { style: { padding: 20, textAlign: 'center', color: c.secondary }, children: state.loading ? 'Loading chats…' : state.error ? '' : query ? 'No matching chats.' : 'No chats yet' }), ListFooterComponent: state.hasMore ? _jsx(Pressable, { accessibilityRole: "button", disabled: state.loading, onPress: () => void client.loadMore().catch(() => { }), children: _jsx(Text, { style: { padding: 12, textAlign: 'center', color: c.accent }, children: state.loading ? 'Loading…' : 'More chats' }) }) : null, renderItem: ({ item }) => {
56
- const person = item.contactId ? contacts[item.contactId] : undefined, title = item.kind === 'group' ? item.name ?? 'Group' : person?.name ?? item.contactId ?? 'Chat';
57
- const date = item.lastMessage ? new Date(item.lastMessage.createdAt) : null;
58
- return _jsxs(Pressable, { ref: node => { if (node)
59
- rows.current.set(item.id, node);
60
- else
61
- rows.current.delete(item.id); }, accessibilityRole: "button", accessibilityLabel: `${title}${item.unreadCount ? `, ${item.unreadCount} unread` : ''}`, onPress: () => onSelect(item), onLongPress: () => openOptions(item), accessibilityActions: state.inboxActionsAvailable ? [{ name: 'showOptions', label: 'Chat options' }] : [], onAccessibilityAction: e => { if (e.nativeEvent.actionName === 'showOptions')
62
- openOptions(item); }, ...(Platform.OS === 'web' ? { onContextMenu: (e) => { e.preventDefault(); openOptions(item); } } : {}), style: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 10, minHeight: 68 }, children: [person?.avatarUrl ? _jsx(Image, { source: { uri: person.avatarUrl }, style: { width: 40, height: 40, borderRadius: 20 } }) : _jsx(View, { style: { width: 40, height: 40, borderRadius: 20, backgroundColor: c.accent + '20', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 18, fontWeight: '700' }, children: title.slice(0, 1).toUpperCase() }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsxs(Text, { numberOfLines: 1, style: { color: c.foreground, fontSize: 16, fontWeight: '700' }, children: [title, item.muted ? ' · Muted' : ''] }), _jsx(Text, { numberOfLines: 1, style: { color: c.secondary, marginTop: 3, fontSize: 14 }, children: preview(item) })] }), _jsxs(View, { style: { alignItems: 'flex-end', gap: 4 }, children: [_jsx(Text, { style: { color: item.unreadCount ? c.accent : c.secondary, fontSize: 12 }, children: date ? date.toDateString() === new Date().toDateString() ? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '' }), item.unreadCount > 0 && _jsx(View, { style: { minWidth: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: c.accent, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 5 }, children: _jsx(Text, { style: { color: c.accent, fontSize: 10, fontWeight: '900' }, children: item.unreadCount > 99 ? '99+' : item.unreadCount }) })] })] });
63
- } }), selected && _jsxs(_Fragment, { children: [_jsx(MessagingMenuSurface, { visible: menuVisible, onDismiss: dismissed, onClose: () => { if (!busy)
64
- setSelected(null); }, children: rect => {
65
- const width = Math.min(218, rect.width - 24), top = Math.max(12, Math.min(anchor.y - rect.top, rect.height - 124));
66
- return _jsxs(View, { style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close chat options", onPress: () => { if (!busy)
67
- setSelected(null); }, style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsx(View, { testID: "messaging-inbox-dropdown", style: { position: 'absolute', left: Math.max(12, Math.min(anchor.x - rect.left, rect.width - width - 12)), top, width, maxHeight: Math.max(44, rect.height - top - 12), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden' }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", children: [_jsx(MessageMenuRow, { icon: selected.muted ? 'notifications-outline' : 'notifications-off-outline', label: selected.muted ? 'Unmute' : 'Mute', disabled: busy, onPress: () => void act(false) }), _jsx(MessageMenuRow, { icon: "trash-outline", label: "Delete chat", danger: true, divider: true, disabled: busy, onPress: showConfirmation }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#ffffff', padding: 12 }, children: error })] }) })] });
68
- } }), confirm && _jsxs(MessagingPopup, { visible: confirmVisible, onDismiss: dismissed, compact: true, title: "Delete chat with " + title(selected) + "?", foreground: c.foreground, closeLabel: "Close delete chat", closeDisabled: busy, background: c.background, onClose: returnToMenu, children: [_jsx(Text, { style: { color: messagingPopupColors.secondary, marginTop: 8 }, children: "This removes the chat and its history from your view. Other people keep their copy. New messages can bring the chat back." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground, paddingVertical: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: returnToMenu, style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: messagingPopupColors.incoming, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 16, fontWeight: '600' }, children: "Cancel" }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Confirm delete chat", disabled: busy, onPress: () => void act(true), style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: '#b72d34', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: '#ffffff', fontSize: 16, fontWeight: '600' }, children: "Delete" }) })] })] })] })] });
64
+ return _jsx(MessagingTypography, { appearance: design, children: _jsxs(View, { style: { flex: 1, minHeight: 0, backgroundColor: c.background }, children: [_jsx(MessagingPopupHeaderBoundary, { children: _jsxs(View, { testID: "messaging-inbox-header", style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', minHeight: 64, paddingHorizontal: 16, paddingVertical: 8, gap: 8 }, children: [_jsx(Text, { accessibilityRole: "header", numberOfLines: 1, style: { flex: 1, color: c.foreground, fontSize: 20, fontWeight: '700' }, children: companyName ?? design.name }), headerActions] }) }), _jsxs(View, { style: { margin: 12, marginTop: 8, flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 10, paddingHorizontal: 10, backgroundColor: c.foreground + '0b' }, children: [_jsx(MessageIcon, { name: "search", size: 20, color: c.secondary }), _jsx(TextInput, { accessibilityLabel: uiText("Search chats"), placeholder: state.globalSearchAvailable ? uiText("Search chats and messages") : uiText("Search chats"), placeholderTextColor: c.secondary, value: query, onChangeText: setQuery, maxLength: 200, autoCapitalize: "none", autoCorrect: false, style: { flex: 1, minHeight: 44, color: c.foreground, fontSize: 16 } }), !!query && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Clear chat search"), onPress: () => setQuery(''), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 20, color: c.secondary }) })] }), state.error && _jsxs(View, { style: { padding: 12 }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: state.error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void client.refresh().catch(() => { }), children: _jsx(Text, { style: { color: c.accent, paddingVertical: 8 }, children: uiText("Try again") }) })] }), query.trim() && state.globalSearchAvailable ? _jsx(InboxMessageSearch, { viewMemory: memory, client: client, query: query.trim(), appearance: design, name: title, chats: state.conversations.filter(row => title(row).toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), onSelect: onSelect }, userId) : _jsx(FlatList, { ...scroll, testID: "messaging-inbox-list", data: state.conversations.filter(row => !query.trim() || [title(row), row.lastMessage?.text ?? '', searchText?.(row) ?? ''].join(' ').toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), keyExtractor: row => row.id, contentContainerStyle: { flexGrow: 1 }, ItemSeparatorComponent: () => _jsx(View, { style: { height: 1, backgroundColor: c.foreground + '12', marginLeft: 64 } }), ListEmptyComponent: _jsx(Text, { style: { padding: 20, textAlign: 'center', color: c.secondary }, children: state.loading ? uiText("Loading chats…") : state.error ? '' : query ? uiText("No matching chats.") : uiText("No chats yet") }), ListFooterComponent: state.hasMore ? _jsx(Pressable, { accessibilityRole: "button", disabled: state.loading, onPress: () => void client.loadMore().catch(() => { }), children: _jsx(Text, { style: { padding: 12, textAlign: 'center', color: c.accent }, children: state.loading ? uiText("Loading…") : uiText("More chats") }) }) : null, renderItem: ({ item }) => {
65
+ const person = item.contactId ? contacts[item.contactId] : undefined, title = item.kind === 'group' ? item.name ?? 'Group' : person?.name ?? item.contactId ?? 'Chat';
66
+ const date = item.lastMessage ? new Date(item.lastMessage.createdAt) : null;
67
+ return _jsxs(Pressable, { ref: node => { if (node)
68
+ rows.current.set(item.id, node);
69
+ else
70
+ rows.current.delete(item.id); }, accessibilityRole: "button", accessibilityState: { selected: item.id === selectedId }, accessibilityLabel: item.unreadCount ? uiText('{name}, {count} unread', { name: title, count: uiNumber(item.unreadCount) }) : title, onPress: () => onSelect(item), onLongPress: () => openOptions(item), accessibilityActions: state.inboxActionsAvailable ? [{ name: 'showOptions', label: uiText("Chat options") }] : [], onAccessibilityAction: e => { if (e.nativeEvent.actionName === 'showOptions')
71
+ openOptions(item); }, ...(Platform.OS === 'web' ? { onContextMenu: (e) => { e.preventDefault(); openOptions(item); } } : {}), style: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 10, minHeight: 68, backgroundColor: item.id === selectedId ? c.accent + '15' : undefined }, children: [person?.avatarUrl ? _jsx(Image, { source: { uri: person.avatarUrl }, style: { width: 40, height: 40, borderRadius: 20 } }) : _jsx(View, { style: { width: 40, height: 40, borderRadius: 20, backgroundColor: c.accent + '20', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 18, fontWeight: '700' }, children: title.slice(0, 1).toUpperCase() }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsxs(Text, { numberOfLines: 1, style: { color: c.foreground, fontSize: 16, fontWeight: '700' }, children: [title, item.muted ? uiText(" · Muted") : ''] }), _jsx(Text, { numberOfLines: 1, style: { color: c.secondary, marginTop: 3, fontSize: 14 }, children: preview(item) })] }), _jsxs(View, { style: { alignItems: 'flex-end', gap: 4 }, children: [_jsx(Text, { style: { color: item.unreadCount ? c.accent : c.secondary, fontSize: 12 }, children: date ? date.toDateString() === new Date().toDateString() ? date.toLocaleTimeString(uiLocale, { hour: 'numeric', minute: '2-digit' }) : date.toLocaleDateString(uiLocale, { month: 'short', day: 'numeric' }) : '' }), item.unreadCount > 0 && _jsx(View, { style: { minWidth: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: c.accent, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 5 }, children: _jsx(Text, { style: { color: c.accent, fontSize: 10, fontWeight: '900' }, children: item.unreadCount > 99 ? '99+' : item.unreadCount }) })] })] });
72
+ } }), selected && _jsxs(_Fragment, { children: [_jsx(MessagingMenuSurface, { visible: menuVisible, onDismiss: dismissed, onClose: () => { if (!busy)
73
+ setSelected(null); }, children: rect => {
74
+ const width = Math.min(218, rect.width - 24), top = Math.max(12, Math.min(anchor.y - rect.top, rect.height - 124));
75
+ return _jsxs(View, { style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: uiText("Close chat options"), onPress: () => { if (!busy)
76
+ setSelected(null); }, style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsx(View, { testID: "messaging-inbox-dropdown", style: { position: 'absolute', left: Math.max(12, Math.min(anchor.x - rect.left, rect.width - width - 12)), top, width, maxHeight: Math.max(44, rect.height - top - 12), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden' }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", children: [_jsx(MessageMenuRow, { icon: selected.muted ? 'notifications-outline' : 'notifications-off-outline', label: selected.muted ? uiText("Unmute") : uiText("Mute"), disabled: busy, onPress: () => void act(false) }), _jsx(MessageMenuRow, { icon: "trash-outline", label: uiText("Delete chat"), danger: true, divider: true, disabled: busy, onPress: showConfirmation }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#ffffff', padding: 12 }, children: error })] }) })] });
77
+ } }), confirm && _jsxs(MessagingPopup, { visible: confirmVisible, onDismiss: dismissed, compact: true, title: uiText('Delete chat with {name}?', { name: title(selected) }), foreground: c.foreground, closeLabel: uiText("Close delete chat"), closeDisabled: busy, background: c.background, onClose: returnToMenu, children: [_jsx(Text, { style: { color: messagingPopupColors.secondary, marginTop: 8 }, children: uiText("This removes the chat and its history from your view. Other people keep their copy. New messages can bring the chat back.") }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground, paddingVertical: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: returnToMenu, style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: messagingPopupColors.incoming, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 16, fontWeight: '600' }, children: uiText("Cancel") }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Confirm delete chat"), disabled: busy, onPress: () => void act(true), style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: '#b72d34', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: '#ffffff', fontSize: 16, fontWeight: '600' }, children: uiText("Delete") }) })] })] })] })] }) });
69
78
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { MessagingCustomization } from './customization.js';
2
+ export { RobonoLocaleProvider, useRobonoLocale } from './localization.js';
1
3
  import { type MessagingAccount } from './account.js';
2
4
  import { type MessagingCallActions } from './header-calls.js';
3
5
  export type { MessagingAccount } from './account.js';
@@ -32,7 +34,8 @@ export interface TranslationVoiceProps {
32
34
  showWaveform?: boolean;
33
35
  autoPlay?: boolean;
34
36
  }
35
- export interface MessagingComponents {
37
+ export type { MessagingCustomization, MessageRegionProps, HeaderRegionProps, CustomMessageProps, CustomMessageAction } from './customization.js';
38
+ export interface MessagingComponents extends MessagingCustomization {
36
39
  TrackingMap?: ComponentType<TrackingMapProps>;
37
40
  PrivacyLayer?: ComponentType<PrivacyLayerProps>;
38
41
  SocialPlayer?: ComponentType<SocialPlayerProps>;
@@ -57,6 +60,10 @@ export interface AttachmentChoice {
57
60
  durationMs?: number;
58
61
  }
59
62
  export interface MessagingScreenProps {
63
+ /** Compose surrounding navigation while keeping a single message context. */
64
+ renderLayout?: (conversation: ReactNode) => ReactNode;
65
+ /** Language of controls and accessibility labels; separate from translated message content. */
66
+ interfaceLocale?: import('@robono/messaging').RobonoLocale;
60
67
  /** Optional tactile feedback at the quote threshold; supplied by the native screen. */
61
68
  onQuoteReady?: () => void;
62
69
  companyName?: string;
@@ -128,7 +135,7 @@ export interface MessagingScreenProps {
128
135
  openAttachment?: (message: MessagingMessage) => Promise<void>;
129
136
  openSavedAttachment?: (record: SavedMessage) => Promise<void>;
130
137
  }
131
- interface MessagingContext extends MessagingScreenProps {
138
+ export interface MessagingContext extends MessagingScreenProps {
132
139
  state: MessagingState;
133
140
  discussionPreview?: boolean;
134
141
  discussionRoot?: string;
@@ -149,8 +156,8 @@ interface MessagingContext extends MessagingScreenProps {
149
156
  setReply: (message: MessagingMessage | null) => void;
150
157
  }
151
158
  export declare function useMessagingUI(): MessagingContext;
152
- export declare function MessagingProvider({ children, ...props }: MessagingScreenProps & {
153
- children: ReactNode;
159
+ export declare function MessagingProvider(props: MessagingScreenProps & {
160
+ children: React.ReactNode;
154
161
  }): React.JSX.Element;
155
162
  export declare function messagingHeaderColors(design: MessagingAppearance, group: boolean): {
156
163
  background: string;
@@ -161,18 +168,21 @@ export declare function MessagingHeader({ children }?: {
161
168
  }): React.JSX.Element;
162
169
  export declare function MessagingBubble({ message, pendingStatus, previousMessage, nextMessage }: {
163
170
  message: MessagingMessage;
164
- pendingStatus?: 'sending' | 'failed';
171
+ pendingStatus?: 'sending' | 'queued' | 'failed';
165
172
  previousMessage?: MessagingMessage;
166
173
  nextMessage?: MessagingMessage;
167
174
  }): React.JSX.Element;
168
175
  export declare function MessagingList(): React.JSX.Element;
169
176
  export declare function MessagingComposer(): React.JSX.Element;
170
177
  /** Account navigation. Mount in the inbox/app toolbar, never in a contact menu. */
171
- export declare function MessagingAccountMenu({ onNewGroup, onMaps }?: {
178
+ export declare function MessagingAccountMenu({ onNewGroup, onMaps, onPublicRooms }?: {
172
179
  onNewGroup?: () => void;
173
180
  onMaps?: () => void;
181
+ onPublicRooms?: () => void;
174
182
  }): React.JSX.Element;
175
183
  export declare function MessagingConversation(): React.JSX.Element;
176
184
  /** Embed inside your app's safe area and navigation. No engine, push or global audio side effects. */
177
185
  export declare function MessagingScreen(props: MessagingScreenProps): React.JSX.Element;
178
186
  export { MessagingInbox, type MessagingInboxProps } from './inbox.js';
187
+ export type { PlaybackProps } from './media-ui.js';
188
+ export type { InboxViewMemory } from './inbox-view.js';