@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
package/dist/account.js CHANGED
@@ -1,9 +1,12 @@
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 { useEffect, useRef, useState } from 'react';
3
- import { Image, Platform, Pressable, ScrollView, Text, View } from 'react-native';
5
+ import { Image, Platform, Pressable, ScrollView, View } from 'react-native';
4
6
  import { MessagingPopup, messagingPopupColors as c } from './popup.js';
5
7
  import { MessageIcon } from './icons.js';
6
8
  export function MessagingAccountScreen({ account, companyName, onClose }) {
9
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
7
10
  const [busy, setBusy] = useState(false), [error, setError] = useState(''), [visible, setVisible] = useState(true), pending = useRef(null);
8
11
  const dismissed = () => { const action = pending.current; pending.current = null; if (action)
9
12
  requestAnimationFrame(() => void run(action)); };
@@ -17,11 +20,11 @@ export function MessagingAccountScreen({ account, companyName, onClose }) {
17
20
  }
18
21
  catch (e) {
19
22
  setVisible(true);
20
- setError(e instanceof Error ? e.message : 'The change could not be completed. Please try again.');
23
+ setError(e instanceof Error ? e.message : uiText("The change could not be completed. Please try again."));
21
24
  }
22
25
  finally {
23
26
  setBusy(false);
24
27
  } };
25
- return _jsx(MessagingPopup, { visible: visible, onDismiss: dismissed, title: "Account", closeLabel: "Close account", closeDisabled: busy, background: c.background, contentWidth: 640, onClose: () => { if (!busy)
26
- onClose(); }, children: _jsxs(ScrollView, { contentContainerStyle: { gap: 16, paddingBottom: 12 }, children: [_jsxs(View, { style: { backgroundColor: '#111111', borderRadius: 16, padding: 16, gap: 14 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 14 }, children: [account?.avatarUrl ? _jsx(Image, { source: { uri: account.avatarUrl }, style: { width: 56, height: 56, borderRadius: 28 } }) : _jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#252525', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "person-circle-outline", color: c.foreground, size: 36 }) }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: { color: c.foreground, fontSize: 20, fontWeight: '600' }, children: account?.name ?? 'Your account' }), _jsx(Text, { style: { color: c.secondary, fontSize: 14 }, children: companyName })] })] }), account?.phone && _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: account.phone }), account?.email && _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: account.email }), account?.onEditProfile && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: "Edit profile", disabled: busy, onPress: () => begin(account.onEditProfile), style: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [_jsx(MessageIcon, { name: "edit", color: c.foreground, size: 20 }), _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Edit profile" })] })] }), account?.onEnableNotifications && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Enable notifications", disabled: busy, onPress: () => void run(account.onEnableNotifications), style: { backgroundColor: '#111111', borderRadius: 12, minHeight: 52, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Enable notifications" }) }), account?.onSignOut && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Sign out", disabled: busy, onPress: () => begin(account.onSignOut), style: { backgroundColor: '#111111', borderRadius: 12, minHeight: 52, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Sign out" }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, fontSize: 15 }, children: error })] }) });
28
+ return _jsx(MessagingPopup, { visible: visible, onDismiss: dismissed, title: uiText("Account"), closeLabel: uiText("Close account"), closeDisabled: busy, background: c.background, contentWidth: 640, onClose: () => { if (!busy)
29
+ onClose(); }, children: _jsxs(ScrollView, { contentContainerStyle: { gap: 16, paddingBottom: 12 }, children: [_jsxs(View, { style: { backgroundColor: '#111111', borderRadius: 16, padding: 16, gap: 14 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 14 }, children: [account?.avatarUrl ? _jsx(Image, { source: { uri: account.avatarUrl }, style: { width: 56, height: 56, borderRadius: 28 } }) : _jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#252525', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "person-circle-outline", color: c.foreground, size: 36 }) }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: { color: c.foreground, fontSize: 20, fontWeight: '600' }, children: account?.name ?? uiText("Your account") }), _jsx(Text, { style: { color: c.secondary, fontSize: 14 }, children: companyName })] })] }), account?.phone && _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: account.phone }), account?.email && _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: account.email }), account?.onEditProfile && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Edit profile"), disabled: busy, onPress: () => begin(account.onEditProfile), style: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [_jsx(MessageIcon, { name: "edit", color: c.foreground, size: 20 }), _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: uiText("Edit profile") })] })] }), account?.onEnableNotifications && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Enable notifications"), disabled: busy, onPress: () => void run(account.onEnableNotifications), style: { backgroundColor: '#111111', borderRadius: 12, minHeight: 52, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: uiText("Enable notifications") }) }), account?.onSignOut && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Sign out"), disabled: busy, onPress: () => begin(account.onSignOut), style: { backgroundColor: '#111111', borderRadius: 12, minHeight: 52, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: uiText("Sign out") }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, fontSize: 15 }, children: error })] }) });
27
30
  }
@@ -1,10 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Text } from './typography.js';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
- import { Animated, BackHandler, Platform, Pressable, ScrollView, Text, View, useWindowDimensions } from 'react-native';
5
+ import { Animated, BackHandler, Platform, Pressable, ScrollView, View, useWindowDimensions } from 'react-native';
4
6
  import { MessagingPopupLayer, useMessagingPopupBounds } from './popup.js';
5
7
  import { MessageIcon } from './icons.js';
6
8
  /** Messenger's 206 × 46 attachment chips, staggered above its plus button. */
7
9
  export function AttachmentMenu({ anchor, actions, accent, onClose }) {
10
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
8
11
  const bounds = useMessagingPopupBounds(), window = useWindowDimensions(), fade = useRef(new Animated.Value(0)).current;
9
12
  const [position, setPosition] = useState(null);
10
13
  const menu = useRef(null), close = useRef(onClose);
@@ -59,7 +62,7 @@ export function AttachmentMenu({ anchor, actions, accent, onClose }) {
59
62
  const listHeight = Math.min(Math.max(46, position.height - 28), actions.length * 54 - 8);
60
63
  const listTop = Math.max(14, Math.min(position.height - listHeight - 14, position.y - listHeight - 30));
61
64
  const Container = compact ? ScrollView : View;
62
- return _jsxs(View, { style: { position: 'absolute', inset: 0, zIndex: 1000, elevation: 1000 }, children: [_jsx(Animated.View, { pointerEvents: "none", style: { position: 'absolute', inset: 0, backgroundColor: '#000', opacity: fade.interpolate({ inputRange: [0, 1], outputRange: [0, .38] }) } }), _jsx(Pressable, { accessibilityLabel: "Close attachment menu", onPress: onClose, style: { position: 'absolute', inset: 0 } }), _jsx(View, { ref: menu, accessibilityViewIsModal: true, style: { position: 'absolute', inset: 0 }, pointerEvents: "box-none", children: _jsx(Container, { style: compact ? { position: 'absolute', top: listTop, left: 10, right: 10, maxHeight: listHeight } : { position: 'absolute', inset: 0 }, ...(compact ? { contentContainerStyle: { gap: 8, paddingHorizontal: 4 }, keyboardShouldPersistTaps: 'always' } : { pointerEvents: 'box-none' }), children: (compact ? [...actions].reverse() : actions).map((action, index) => {
65
+ return _jsxs(View, { style: { position: 'absolute', inset: 0, zIndex: 1000, elevation: 1000 }, children: [_jsx(Animated.View, { pointerEvents: "none", style: { position: 'absolute', inset: 0, backgroundColor: '#000', opacity: fade.interpolate({ inputRange: [0, 1], outputRange: [0, .38] }) } }), _jsx(Pressable, { accessibilityLabel: uiText("Close attachment menu"), onPress: onClose, style: { position: 'absolute', inset: 0 } }), _jsx(View, { ref: menu, accessibilityViewIsModal: true, style: { position: 'absolute', inset: 0 }, pointerEvents: "box-none", children: _jsx(Container, { style: compact ? { position: 'absolute', top: listTop, left: 10, right: 10, maxHeight: listHeight } : { position: 'absolute', inset: 0 }, ...(compact ? { contentContainerStyle: { gap: 8, paddingHorizontal: 4 }, keyboardShouldPersistTaps: 'always' } : { pointerEvents: 'box-none' }), children: (compact ? [...actions].reverse() : actions).map((action, index) => {
63
66
  const chipWidth = Math.min(206, position.width - 20), step = Math.min(58, Math.max(46, (position.y - 80) / Math.max(1, actions.length - 1)));
64
67
  return _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: action.label, onPress: () => { onClose(); if (Platform.OS === 'web')
65
68
  action.run();
@@ -1,9 +1,12 @@
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 { useEffect, useRef, useState } from 'react';
3
- import { Pressable, Text, View } from 'react-native';
5
+ import { Pressable, View } from 'react-native';
4
6
  import { useMessagingUI } from './index.js';
5
7
  /** A new, reviewed message after no answer. Never records or sends a call automatically. */
6
8
  export function UnansweredCallMessage({ callId, onDismiss }) {
9
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
7
10
  const { client, design, state, pickAttachment, releaseAttachment, components } = useMessagingUI();
8
11
  const [option, setOption] = useState(null);
9
12
  const [draft, setDraft] = useState(null);
@@ -27,7 +30,7 @@ export function UnansweredCallMessage({ callId, onDismiss }) {
27
30
  void client.unansweredCall(callId).then(o => { if (id === generation.current)
28
31
  setOption(o); })
29
32
  .catch(() => { if (id === generation.current)
30
- setError('Could not check message availability.'); });
33
+ setError(uiText("Could not check message availability.")); });
31
34
  return () => { generation.current++; const media = prepared.current; prepared.current = null; if (media)
32
35
  void release.current?.(media); };
33
36
  }, [client, callId, state.conversationId, attempt]);
@@ -66,7 +69,7 @@ export function UnansweredCallMessage({ callId, onDismiss }) {
66
69
  }
67
70
  catch {
68
71
  if (id === generation.current)
69
- setError('Could not prepare your message. Try again.');
72
+ setError(uiText("Could not prepare your message. Try again."));
70
73
  }
71
74
  finally {
72
75
  inFlight.current = false;
@@ -99,7 +102,7 @@ export function UnansweredCallMessage({ callId, onDismiss }) {
99
102
  }
100
103
  catch {
101
104
  if (id === generation.current)
102
- setError('The message could not be sent. Check the conversation for a retry.');
105
+ setError(uiText("The message could not be sent. Check the conversation for a retry."));
103
106
  }
104
107
  finally {
105
108
  inFlight.current = false;
@@ -111,5 +114,5 @@ export function UnansweredCallMessage({ callId, onDismiss }) {
111
114
  return null;
112
115
  const c = design.colors, VoiceAttachment = components?.VoiceAttachment;
113
116
  const action = (label, onPress) => _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: onPress, style: { paddingVertical: 12, paddingHorizontal: 4, minHeight: 44 }, children: _jsx(Text, { style: { color: c.incomingText, fontWeight: '600' }, children: label }) });
114
- return _jsxs(View, { style: { backgroundColor: c.incoming, padding: 16, borderTopWidth: 1, borderTopColor: c.secondary + '30', gap: 6 }, children: [_jsx(Text, { style: { color: c.incomingText, fontSize: 16, fontWeight: '600' }, children: "No answer" }), option && _jsx(Text, { style: { color: c.incomingText, fontSize: 14 }, children: draft ? 'Review your message, then send it.' : `Leave a ${option.kind === 'voice' ? 'voice' : 'video'} message in this chat.` }), draft && _jsx(View, { children: draft.kind === 'voice' && VoiceAttachment ? _jsx(VoiceAttachment, { media: draft.media, appearance: design, disabled: busy, onRemove: clearDraft }) : _jsxs(Text, { style: { color: c.incomingText }, children: [draft.kind === 'voice' ? 'Voice' : 'Video', " message ready"] }) }), _jsxs(View, { style: { flexDirection: 'row', gap: 16, flexWrap: 'wrap' }, children: [option && (draft ? action('Send message', () => void send()) : action(`Record ${option.kind === 'voice' ? 'voice' : 'video'} message`, () => void record())), !option && error && action('Try again', () => setAttempt(value => value + 1)), action('Not now', () => { clearDraft(); onDismiss(); })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.incomingText }, children: error })] });
117
+ return _jsxs(View, { style: { backgroundColor: c.incoming, padding: 16, borderTopWidth: 1, borderTopColor: c.secondary + '30', gap: 6 }, children: [_jsx(Text, { style: { color: c.incomingText, fontSize: 16, fontWeight: '600' }, children: uiText("No answer") }), option && _jsx(Text, { style: { color: c.incomingText, fontSize: 14 }, children: draft ? uiText("Review your message, then send it.") : uiText("Leave a {value1} message in this chat.", { value1: option.kind === 'voice' ? 'voice' : 'video' }) }), draft && _jsx(View, { children: draft.kind === 'voice' && VoiceAttachment ? _jsx(VoiceAttachment, { media: draft.media, appearance: design, disabled: busy, onRemove: clearDraft }) : _jsx(Text, { style: { color: c.incomingText }, children: draft.kind === 'voice' ? uiText('Voice message ready') : uiText('Video message ready') }) }), _jsxs(View, { style: { flexDirection: 'row', gap: 16, flexWrap: 'wrap' }, children: [option && (draft ? action('Send message', () => void send()) : action(`Record ${option.kind === 'voice' ? 'voice' : 'video'} message`, () => void record())), !option && error && action('Try again', () => setAttempt(value => value + 1)), action('Not now', () => { clearDraft(); onDismiss(); })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.incomingText }, children: error })] });
115
118
  }
package/dist/calling.js CHANGED
@@ -1,6 +1,8 @@
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 { createContext, useContext, useSyncExternalStore } from 'react';
3
- import { Pressable, View, Text } from 'react-native';
5
+ import { Pressable, View } from 'react-native';
4
6
  import { RobonoIcon } from './visual/RobonoIcon.js';
5
7
  import { MessagingScreen, MessagingProvider, MessagingHeader, MessagingComposer } from './index.js';
6
8
  import { UnansweredCallMessage } from './call-message.js';
@@ -10,9 +12,11 @@ function CallingMessageComposer() { const follow = useContext(FollowUp); return
10
12
  /** Optional call entry in the standard message header. The host controls eligibility and admission. */
11
13
  export function MessagingCallHeader() { return _jsx(MessagingHeader, {}); }
12
14
  export function MessagingCallingScreen({ onCall, onVideoCall, videoCallLabel = 'Video call', callLabel = 'Robono call', components, unansweredCallId, onDismissCallMessage, ...props }) {
15
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
13
16
  const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
14
17
  const design = props.appearance ?? state.settings?.appearance;
15
18
  const screenProps = { ...props, callActions: { ...props.callActions, ...(onCall ? { voice: onCall, voiceLabel: callLabel } : {}), ...(onVideoCall ? { video: onVideoCall, videoLabel: videoCallLabel } : {}) } };
16
19
  const callingOnly = (onCall || onVideoCall) && design && state.settings && !state.settings.enabled;
17
- return _jsx(FollowUp.Provider, { value: unansweredCallId && onDismissCallMessage ? { callId: unansweredCallId, dismiss: onDismissCallMessage } : null, children: callingOnly ? _jsx(MessagingProvider, { ...screenProps, children: _jsxs(View, { style: [{ flex: 1, backgroundColor: design.colors.background }, props.style], children: [_jsx(MessagingCallHeader, {}), _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 24, padding: 24 }, children: [_jsx(Text, { style: { fontSize: 28, fontWeight: '600', color: design.colors.foreground }, children: props.contact.name }), onCall && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Start ${callLabel.toLowerCase()}`, onPress: onCall, style: { minHeight: 56, minWidth: 160, borderRadius: 28, backgroundColor: design.colors.incoming, alignItems: 'center', justifyContent: 'center', padding: 16, flexDirection: 'row', gap: 12 }, children: [_jsx(RobonoIcon, { name: "phone", size: 24, color: design.colors.incomingText }), _jsx(Text, { style: { fontSize: 18, color: design.colors.incomingText }, children: callLabel })] }), onVideoCall && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Start ${videoCallLabel.toLowerCase()}`, onPress: onVideoCall, style: { minHeight: 56, padding: 16, borderRadius: 28, backgroundColor: design.colors.incoming, flexDirection: 'row', alignItems: 'center', gap: 12 }, children: [_jsx(RobonoIcon, { name: "video", size: 24, color: design.colors.incomingText }), _jsx(Text, { style: { color: design.colors.incomingText, fontSize: 18 }, children: videoCallLabel })] })] })] }) }) : _jsx(MessagingScreen, { ...screenProps, components: { Header: MessagingCallHeader, Composer: CallingMessageComposer, ...components } }) });
20
+ const callingContent = design ? _jsxs(View, { style: [{ flex: 1, backgroundColor: design.colors.background }, props.style], children: [_jsx(MessagingCallHeader, {}), _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 24, padding: 24 }, children: [_jsx(Text, { style: { fontSize: 28, fontWeight: '600', color: design.colors.foreground }, children: props.contact.name }), onCall && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Start {value1}", { value1: callLabel.toLowerCase() }), onPress: onCall, style: { minHeight: 56, minWidth: 160, borderRadius: 28, backgroundColor: design.colors.incoming, alignItems: 'center', justifyContent: 'center', padding: 16, flexDirection: 'row', gap: 12 }, children: [_jsx(RobonoIcon, { name: "phone", size: 24, color: design.colors.incomingText }), _jsx(Text, { style: { fontSize: 18, color: design.colors.incomingText }, children: callLabel })] }), onVideoCall && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Start {value1}", { value1: videoCallLabel.toLowerCase() }), onPress: onVideoCall, style: { minHeight: 56, padding: 16, borderRadius: 28, backgroundColor: design.colors.incoming, flexDirection: 'row', alignItems: 'center', gap: 12 }, children: [_jsx(RobonoIcon, { name: "video", size: 24, color: design.colors.incomingText }), _jsx(Text, { style: { color: design.colors.incomingText, fontSize: 18 }, children: videoCallLabel })] })] })] }) : null;
21
+ return _jsx(FollowUp.Provider, { value: unansweredCallId && onDismissCallMessage ? { callId: unansweredCallId, dismiss: onDismissCallMessage } : null, children: callingOnly ? _jsx(MessagingProvider, { ...screenProps, children: props.renderLayout ? props.renderLayout(callingContent) : callingContent }) : _jsx(MessagingScreen, { ...screenProps, components: { Header: MessagingCallHeader, Composer: CallingMessageComposer, ...components } }) });
18
22
  }
@@ -13,6 +13,7 @@ export interface ConversationDetailsProps {
13
13
  accent: string;
14
14
  onClose: () => void;
15
15
  onEditGroup?: () => void;
16
+ onModerate?: () => void;
16
17
  onLeft?: () => void;
17
18
  attachmentPreview?: (message: MessagingMessage) => Promise<{
18
19
  uri?: string;
@@ -1,17 +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 { useEffect, useRef, useState } from 'react';
3
- import { ActivityIndicator, FlatList, Image, Platform, Pressable, Text, View } from 'react-native';
5
+ import { ActivityIndicator, FlatList, Image, Platform, Pressable, View } from 'react-native';
4
6
  import { MessagingPopup, messagingPopupColors as c } from './popup.js';
5
7
  import { MessageIcon, fileSize, duration } from './icons.js';
6
8
  function MediaThumbnail({ message, preview }) {
9
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
7
10
  const [uri, setUri] = useState();
8
11
  useEffect(() => { let live = true; setUri(undefined); if (preview && ['image', 'video'].includes(message.kind))
9
12
  void preview(message).then(value => { if (live)
10
13
  setUri(value.uri); }).catch(() => { }); return () => { live = false; }; }, [message.id, message.media?.assetId, preview]);
11
- return _jsxs(View, { style: { width: 56, height: 56, borderRadius: 8, overflow: 'hidden', alignItems: 'center', justifyContent: 'center', backgroundColor: c.incoming }, children: [uri ? _jsx(Image, { source: { uri }, accessibilityLabel: message.media?.fileName ?? 'Attachment', onError: () => setUri(undefined), style: { width: '100%', height: '100%' } }) : _jsx(MessageIcon, { name: message.kind === 'voice' ? 'mic' : message.kind === 'document' ? 'document' : message.kind === 'video' ? 'video' : 'image', color: c.foreground, size: 24 }), uri && message.kind === 'video' && _jsx(View, { style: { position: 'absolute', backgroundColor: '#0008', padding: 3, borderRadius: 20 }, children: _jsx(MessageIcon, { name: "play", color: "#fff", size: 18 }) })] });
14
+ return _jsxs(View, { style: { width: 56, height: 56, borderRadius: 8, overflow: 'hidden', alignItems: 'center', justifyContent: 'center', backgroundColor: c.incoming }, children: [uri ? _jsx(Image, { source: { uri }, accessibilityLabel: message.media?.fileName ?? uiText("Attachment"), onError: () => setUri(undefined), style: { width: '100%', height: '100%' } }) : _jsx(MessageIcon, { name: message.kind === 'voice' ? 'mic' : message.kind === 'document' ? 'document' : message.kind === 'video' ? 'video' : 'image', color: c.foreground, size: 24 }), uri && message.kind === 'video' && _jsx(View, { style: { position: 'absolute', backgroundColor: '#0008', padding: 3, borderRadius: 20 }, children: _jsx(MessageIcon, { name: "play", color: "#fff", size: 18 }) })] });
12
15
  }
13
16
  /** Contact/group identity and retained shared media, using Messenger's bounded popup. */
14
17
  export function ConversationDetails(props) {
18
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
15
19
  const { client, state, contact, participants = {}, userId, accent, onClose } = props, group = state.conversation?.kind === 'group';
16
20
  const [tab, setTab] = useState('media'), [messages, setMessages] = useState([]), [members, setMembers] = useState([]);
17
21
  const [next, setNext] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState(''), [visible, setVisible] = useState(true);
@@ -33,7 +37,7 @@ export function ConversationDetails(props) {
33
37
  setVisible(false);
34
38
  }
35
39
  catch (e) {
36
- setError(e instanceof Error ? e.message : 'The group could not be left.');
40
+ setError(e instanceof Error ? e.message : uiText("The group could not be left."));
37
41
  }
38
42
  finally {
39
43
  setBusy(false);
@@ -64,7 +68,7 @@ export function ConversationDetails(props) {
64
68
  }
65
69
  catch (e) {
66
70
  if (version === generation.current && !control.signal.aborted)
67
- setError(e instanceof Error ? e.message : 'These details are unavailable.');
71
+ setError(e instanceof Error ? e.message : uiText("These details are unavailable."));
68
72
  }
69
73
  finally {
70
74
  if (version === generation.current)
@@ -73,11 +77,11 @@ export function ConversationDetails(props) {
73
77
  }
74
78
  useEffect(() => { setMessages([]); setMembers([]); setNext(null); if (tab === 'members' || state.sharedMediaAvailable)
75
79
  void load(); return () => { ++generation.current; request.current?.abort(); }; }, [client, state.conversationId, tab]);
76
- const footer = _jsxs(View, { children: [busy && _jsx(ActivityIndicator, { accessibilityLabel: "Loading conversation details", color: accent, style: { padding: 16 } }), !!error && _jsxs(_Fragment, { children: [_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, padding: 12 }, children: error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void load(!!next), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: "Try again" }) })] }), next && !busy && !error && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void load(true), style: { minHeight: 48, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: "Load more" }) })] });
77
- return _jsx(MessagingPopup, { visible: visible, onDismiss: dismiss, title: leaving ? 'Leave group?' : group ? 'Group details' : 'Contact details', compact: leaving, closeDisabled: busy, background: c.background, onClose: () => { if (!busy) {
80
+ const footer = _jsxs(View, { children: [busy && _jsx(ActivityIndicator, { accessibilityLabel: uiText("Loading conversation details"), color: accent, style: { padding: 16 } }), !!error && _jsxs(_Fragment, { children: [_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, padding: 12 }, children: error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void load(!!next), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: uiText("Try again") }) })] }), next && !busy && !error && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void load(true), style: { minHeight: 48, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: uiText("Load more") }) })] });
81
+ return _jsx(MessagingPopup, { visible: visible, onDismiss: dismiss, title: leaving ? uiText("Leave group?") : group ? uiText("Group details") : uiText("Contact details"), compact: leaving, closeDisabled: busy, background: c.background, onClose: () => { if (!busy) {
78
82
  if (leaving)
79
83
  setLeaving(false);
80
84
  else
81
85
  setVisible(false);
82
- } }, children: leaving ? _jsxs(View, { style: { gap: 16, paddingTop: 12 }, children: [_jsxs(Text, { style: { color: c.secondary, fontSize: 16, lineHeight: 23 }, children: ["You will stop receiving this group\u2019s messages.", state.conversation?.ownerId === userId ? ' Ownership will pass to another member.' : ''] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => void leave(), style: { minHeight: 48, borderRadius: 12, backgroundColor: '#b72d34', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: '#fff', fontSize: 16, fontWeight: '600' }, children: busy ? 'Leaving…' : 'Leave group' }) })] }) : _jsxs(_Fragment, { children: [_jsxs(View, { style: { alignItems: 'center', gap: 8, paddingVertical: 20 }, children: [!group && contact.avatarUrl ? _jsx(Image, { source: { uri: contact.avatarUrl }, style: { width: 64, height: 64, borderRadius: 32 } }) : _jsx(View, { style: { width: 64, height: 64, borderRadius: 32, backgroundColor: c.incoming, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontSize: 26, color: accent }, children: title.slice(0, 1).toUpperCase() }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 21, fontWeight: '600' }, children: title }), group && _jsxs(Text, { style: { color: c.secondary, fontSize: 15 }, children: [state.conversation?.memberCount ?? state.conversation?.memberIds?.length ?? 0, " members"] })] }), _jsx(View, { style: { flexDirection: 'row', gap: 8, paddingBottom: 12 }, children: ['media', ...(group ? ['members'] : [])].map(value => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: value === 'media' ? 'Shared media' : 'Members', accessibilityState: { selected: tab === value }, onPress: () => setTab(value), style: { flex: 1, minHeight: 44, alignItems: 'center', justifyContent: 'center', borderBottomWidth: 2, borderBottomColor: tab === value ? accent : 'transparent' }, children: _jsx(Text, { style: { color: tab === value ? accent : c.secondary, fontSize: 16, fontWeight: '600' }, children: value === 'media' ? 'Shared media' : 'Members' }) }, value)) }), tab === 'media' ? _jsx(FlatList, { data: messages, keyExtractor: m => m.id, style: { flex: 1 }, ListFooterComponent: footer, ListEmptyComponent: !busy && !error ? _jsx(Text, { style: { color: c.secondary, padding: 16, textAlign: 'center' }, children: state.sharedMediaAvailable ? 'No shared photos, videos, files or voice messages.' : 'Shared media is unavailable for this integration.' }) : null, renderItem: ({ item }) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Open ' + (item.media?.fileName ?? 'attachment'), disabled: !props.openAttachment, onPress: () => { pending.current = () => { void props.openAttachment?.(item).catch(() => { }); }; setVisible(false); }, style: { minHeight: 76, flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#ffffff16' }, children: [_jsx(MediaThumbnail, { message: item, preview: props.attachmentPreview }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 4 }, children: [_jsx(Text, { numberOfLines: 2, style: { color: c.foreground, fontSize: 16 }, children: item.media?.fileName ?? ({ image: 'Photo', video: 'Video', voice: 'Voice message', document: 'File' }[item.kind]) }), _jsxs(Text, { style: { color: c.secondary, fontSize: 13 }, children: [item.senderId === userId ? 'You' : participants[item.senderId]?.name ?? (!group ? contact.name : 'Member'), " \u00B7 ", item.media?.durationMs ? duration(item.media.durationMs) : fileSize(item.media?.byteSize), " \u00B7 ", new Date(item.createdAt).toLocaleDateString()] })] }), _jsx(MessageIcon, { name: "chevron-right", color: c.secondary, size: 20 })] }) }) : _jsx(FlatList, { data: members, keyExtractor: m => m.userId, style: { flex: 1 }, ListFooterComponent: footer, renderItem: ({ item }) => _jsxs(View, { style: { minHeight: 56, flexDirection: 'row', alignItems: 'center', gap: 12, borderBottomWidth: 1, borderBottomColor: '#ffffff16' }, children: [participants[item.userId]?.avatarUrl ? _jsx(Image, { source: { uri: participants[item.userId].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 }, children: (participants[item.userId]?.name ?? 'Member').slice(0, 1) }) }), _jsx(Text, { style: { flex: 1, color: c.foreground, fontSize: 16 }, children: item.userId === userId ? 'You' : participants[item.userId]?.name ?? 'Member' }), state.conversation?.ownerId === item.userId && _jsx(Text, { style: { color: accent, fontSize: 13 }, children: "Owner" }), !item.canSend && _jsx(Text, { style: { color: c.secondary, fontSize: 13 }, children: "Read only" })] }) }), group && state.conversation?.managementAvailable && _jsxs(View, { style: { flexDirection: 'row', gap: 12, borderTopWidth: 1, borderTopColor: '#ffffff20' }, children: [state.conversation.ownerId === userId && props.onEditGroup && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { pending.current = props.onEditGroup; setVisible(false); }, style: { flex: 1, minHeight: 48, justifyContent: 'center', alignItems: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 16 }, children: "Edit group" }) }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setError(''); setLeaving(true); }, style: { flex: 1, minHeight: 48, justifyContent: 'center', alignItems: 'center' }, children: _jsx(Text, { style: { color: '#ff8282', fontSize: 16 }, children: "Leave group" }) })] })] }) });
86
+ } }, children: leaving ? _jsxs(View, { style: { gap: 16, paddingTop: 12 }, children: [_jsxs(Text, { style: { color: c.secondary, fontSize: 16, lineHeight: 23 }, children: [uiText("You will stop receiving this group’s messages."), state.conversation?.ownerId === userId ? ' ' + uiText("Ownership will pass to another member.") : ''] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => void leave(), style: { minHeight: 48, borderRadius: 12, backgroundColor: '#b72d34', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: '#fff', fontSize: 16, fontWeight: '600' }, children: busy ? uiText("Leaving…") : uiText("Leave group") }) })] }) : _jsxs(_Fragment, { children: [_jsxs(View, { style: { alignItems: 'center', gap: 8, paddingVertical: 20 }, children: [!group && contact.avatarUrl ? _jsx(Image, { source: { uri: contact.avatarUrl }, style: { width: 64, height: 64, borderRadius: 32 } }) : _jsx(View, { style: { width: 64, height: 64, borderRadius: 32, backgroundColor: c.incoming, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontSize: 26, color: accent }, children: title.slice(0, 1).toUpperCase() }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 21, fontWeight: '600' }, children: title }), group && _jsx(Text, { style: { color: c.secondary, fontSize: 15 }, children: uiText('{count} members', { count: state.conversation?.memberCount ?? state.conversation?.memberIds?.length ?? 0 }) })] }), _jsx(View, { style: { flexDirection: 'row', gap: 8, paddingBottom: 12 }, children: ['media', ...(group ? ['members'] : [])].map(value => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: value === 'media' ? uiText("Shared media") : uiText("Members"), accessibilityState: { selected: tab === value }, onPress: () => setTab(value), style: { flex: 1, minHeight: 44, alignItems: 'center', justifyContent: 'center', borderBottomWidth: 2, borderBottomColor: tab === value ? accent : 'transparent' }, children: _jsx(Text, { style: { color: tab === value ? accent : c.secondary, fontSize: 16, fontWeight: '600' }, children: value === 'media' ? uiText("Shared media") : uiText("Members") }) }, value)) }), tab === 'media' ? _jsx(FlatList, { data: messages, keyExtractor: m => m.id, style: { flex: 1 }, ListFooterComponent: footer, ListEmptyComponent: !busy && !error ? _jsx(Text, { style: { color: c.secondary, padding: 16, textAlign: 'center' }, children: state.sharedMediaAvailable ? uiText("No shared photos, videos, files or voice messages.") : uiText("Shared media is unavailable for this integration.") }) : null, renderItem: ({ item }) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText('Open {name}', { name: item.media?.fileName ?? uiText('Attachment') }), disabled: !props.openAttachment, onPress: () => { pending.current = () => { void props.openAttachment?.(item).catch(() => { }); }; setVisible(false); }, style: { minHeight: 76, flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#ffffff16' }, children: [_jsx(MediaThumbnail, { message: item, preview: props.attachmentPreview }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 4 }, children: [_jsx(Text, { numberOfLines: 2, style: { color: c.foreground, fontSize: 16 }, children: item.media?.fileName ?? ({ image: 'Photo', video: 'Video', voice: 'Voice message', document: 'File' }[item.kind]) }), _jsxs(Text, { style: { color: c.secondary, fontSize: 13 }, children: [item.senderId === userId ? uiText("You") : participants[item.senderId]?.name ?? (!group ? contact.name : 'Member'), " \u00B7 ", item.media?.durationMs ? duration(item.media.durationMs) : fileSize(item.media?.byteSize), " \u00B7 ", new Date(item.createdAt).toLocaleDateString(uiLocale)] })] }), _jsx(MessageIcon, { name: "chevron-right", color: c.secondary, size: 20 })] }) }) : _jsx(FlatList, { data: members, keyExtractor: m => m.userId, style: { flex: 1 }, ListFooterComponent: footer, renderItem: ({ item }) => _jsxs(View, { style: { minHeight: 56, flexDirection: 'row', alignItems: 'center', gap: 12, borderBottomWidth: 1, borderBottomColor: '#ffffff16' }, children: [participants[item.userId]?.avatarUrl ? _jsx(Image, { source: { uri: participants[item.userId].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 }, children: (participants[item.userId]?.name ?? 'Member').slice(0, 1) }) }), _jsx(Text, { style: { flex: 1, color: c.foreground, fontSize: 16 }, children: item.userId === userId ? uiText("You") : participants[item.userId]?.name ?? uiText("Member") }), state.conversation?.ownerId === item.userId && _jsx(Text, { style: { color: accent, fontSize: 13 }, children: uiText("Owner") }), !item.canSend && _jsx(Text, { style: { color: c.secondary, fontSize: 13 }, children: uiText("Read only") })] }) }), group && props.onModerate && ['owner', 'moderator'].includes(state.conversation?.roomRole ?? '') && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { pending.current = props.onModerate; setVisible(false); }, style: { minHeight: 48, justifyContent: 'center', alignItems: 'center', borderTopWidth: 1, borderTopColor: '#ffffff20' }, children: _jsx(Text, { style: { color: accent, fontSize: 16 }, children: uiText('Group moderation') }) }), group && state.conversation?.managementAvailable && _jsxs(View, { style: { flexDirection: 'row', gap: 12, borderTopWidth: 1, borderTopColor: '#ffffff20' }, children: [state.conversation.ownerId === userId && props.onEditGroup && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { pending.current = props.onEditGroup; setVisible(false); }, style: { flex: 1, minHeight: 48, justifyContent: 'center', alignItems: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 16 }, children: uiText("Edit group") }) }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setError(''); setLeaving(true); }, style: { flex: 1, minHeight: 48, justifyContent: 'center', alignItems: 'center' }, children: _jsx(Text, { style: { color: '#ff8282', fontSize: 16 }, children: uiText("Leave group") }) })] })] }) });
83
87
  }
@@ -0,0 +1,7 @@
1
+ import React from 'react';
2
+ import type { MessageRegionProps, MessagingCustomization } from './customization.js';
3
+ /** Data and customer render errors fall back inside the original message, never the whole chat. */
4
+ export declare function CustomMessage({ components, enabled, ...props }: MessageRegionProps & {
5
+ components?: MessagingCustomization;
6
+ enabled: boolean;
7
+ }): React.JSX.Element;
@@ -0,0 +1,53 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Component, useRef, useState } from 'react';
3
+ import { Pressable, View } from 'react-native';
4
+ import { validateCustomMessage, validateMessageCard } from '@robono/messaging';
5
+ import { Text } from './typography.js';
6
+ import { useRobonoLocale } from './localization.js';
7
+ class CardBoundary extends Component {
8
+ state = { failed: false };
9
+ static getDerivedStateFromError() { return { failed: true }; }
10
+ render() { return this.state.failed ? this.props.fallback : this.props.children; }
11
+ }
12
+ /** Data and customer render errors fall back inside the original message, never the whole chat. */
13
+ export function CustomMessage({ components, enabled, ...props }) {
14
+ const { text: uiText } = useRobonoLocale();
15
+ const [busy, setBusy] = useState(null), [failed, setFailed] = useState(false), inFlight = useRef(false);
16
+ let custom;
17
+ try {
18
+ custom = validateCustomMessage(props.message.custom);
19
+ }
20
+ catch {
21
+ return _jsx(_Fragment, { children: props.children });
22
+ }
23
+ const { colors } = props.appearance, ink = props.mine ? colors.outgoingText : colors.incomingText;
24
+ const actionsEnabled = enabled && !props.pendingStatus && !props.message.deleted && !!components?.onCustomMessageAction;
25
+ const onAction = async (actionId) => {
26
+ if (!actionsEnabled || inFlight.current)
27
+ return;
28
+ if (!/^[a-zA-Z][a-zA-Z0-9_.:-]{0,79}$/.test(actionId))
29
+ throw Error('Invalid card action.');
30
+ inFlight.current = true;
31
+ setBusy(actionId);
32
+ setFailed(false);
33
+ try {
34
+ await components.onCustomMessageAction({ message: props.message, actionId });
35
+ }
36
+ catch {
37
+ setFailed(true);
38
+ }
39
+ finally {
40
+ inFlight.current = false;
41
+ setBusy(null);
42
+ }
43
+ };
44
+ const registered = Object.hasOwn(components?.customMessages ?? {}, custom.type) ? components?.customMessages?.[custom.type] : undefined;
45
+ let content = props.children;
46
+ if (registered?.versions.includes(custom.version))
47
+ content = _jsx(registered.Component, { ...props, custom: custom, actionsEnabled: actionsEnabled && !busy, onAction: onAction });
48
+ else if (custom.type === 'robono.card' && custom.version === 1) {
49
+ const card = validateMessageCard(custom.data);
50
+ content = _jsxs(View, { testID: "message-business-card", style: { gap: 6, minWidth: 180, maxWidth: 320 }, children: [_jsx(Text, { selectable: true, style: { fontSize: 16, lineHeight: 22, fontWeight: '600', color: ink }, children: card.title }), !!card.body && _jsx(Text, { selectable: true, style: { fontSize: 15, lineHeight: 21, color: ink }, children: card.body }), card.fields?.map((field, index) => _jsxs(View, { style: { flexDirection: 'row', flexWrap: 'wrap', columnGap: 12, rowGap: 2, justifyContent: 'space-between' }, children: [_jsx(Text, { style: { fontSize: 13, lineHeight: 19, color: ink, opacity: .75 }, children: field.label }), _jsx(Text, { selectable: true, style: { fontSize: 14, lineHeight: 20, color: ink, flexShrink: 1 }, children: field.value })] }, index)), !!components?.onCustomMessageAction && card.actions?.map(action => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: action.label, accessibilityState: { disabled: !actionsEnabled || !!busy, busy: busy === action.id }, disabled: !actionsEnabled || !!busy, onPress: () => void onAction(action.id), style: { minHeight: 44, paddingHorizontal: 12, justifyContent: 'center', alignItems: 'center', borderWidth: 1, borderColor: colors.accent, borderRadius: 8, opacity: actionsEnabled && !busy ? 1 : .55 }, children: _jsx(Text, { style: { fontSize: 14, fontWeight: '600', color: colors.accent }, children: action.label }) }, action.id))] });
51
+ }
52
+ return _jsxs(CardBoundary, { fallback: props.children, children: [content, failed && _jsx(Text, { accessibilityRole: "alert", style: { color: ink, fontSize: 13, lineHeight: 19, marginTop: 6 }, children: uiText('This action could not be completed. Try again.') })] }, props.message.id + ':' + custom.type + ':' + custom.version);
53
+ }
@@ -0,0 +1,49 @@
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ import type { MessagingAppearance, MessagingMessage, CustomMessageContent } from '@robono/messaging';
3
+ export interface MessageRegionProps {
4
+ message: MessagingMessage;
5
+ mine: boolean;
6
+ appearance: MessagingAppearance;
7
+ pendingStatus?: 'sending' | 'queued' | 'failed';
8
+ /** The supplied region. Render children to retain its standard behavior. */
9
+ children?: ReactNode;
10
+ }
11
+ export interface HeaderRegionProps {
12
+ conversationId: string;
13
+ contact: {
14
+ name: string;
15
+ avatarUrl?: string;
16
+ language?: string;
17
+ phoneNumber?: string;
18
+ };
19
+ appearance: MessagingAppearance;
20
+ }
21
+ export interface CustomMessageProps extends MessageRegionProps {
22
+ custom: CustomMessageContent;
23
+ /** False for queued/deleted messages and when no customer callback is supplied. */
24
+ actionsEnabled: boolean;
25
+ /** Invokes the same supplied callback as ready-made cards. Never interprets the ID. */
26
+ onAction: (actionId: string) => Promise<void>;
27
+ }
28
+ export interface CustomMessageAction {
29
+ message: MessagingMessage;
30
+ actionId: string;
31
+ }
32
+ /** Small visual additions/replacements, independent of the media engine.
33
+ * Define components outside render so drafts and players are not remounted.
34
+ * These options never grant service access or change server permissions. */
35
+ export interface MessagingCustomization {
36
+ /** Register only the data versions your component understands. Unknown types use text. */
37
+ customMessages?: Record<string, {
38
+ versions: readonly number[];
39
+ Component: ComponentType<CustomMessageProps>;
40
+ }>;
41
+ /** Connect to your authorized application action. Check permissions and current state on your server. */
42
+ onCustomMessageAction?: (event: CustomMessageAction) => void | Promise<void>;
43
+ /** Wrap/replace only the message body, preserving its outer status and gestures. */
44
+ MessageBody?: ComponentType<MessageRegionProps>;
45
+ /** Optional content inside the bubble, after the body and supplied language actions. */
46
+ MessageFooter?: ComponentType<MessageRegionProps>;
47
+ /** Add a customer action beside the contact identity; built-in actions remain. */
48
+ HeaderActions?: ComponentType<HeaderRegionProps>;
49
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { callDiagnosticsText } from '@robono/calling-react-native';
3
+ import { Text } from './typography.js';
4
+ import { callingText } from '@robono/calling-react-native/appearance';
5
+ import { useRobonoLocale } from './localization.js';
2
6
  import { useEffect, useSyncExternalStore } from 'react';
3
- import { Pressable, Text, View } from 'react-native';
7
+ import { Pressable, View } from 'react-native';
4
8
  import { setAudioModeAsync } from 'expo-audio';
5
9
  import { createMessengerCalling } from '@robono/calling-react-native/messenger';
6
10
  import { createNativeCallAdapters } from '@robono/calling-react-native/native';
@@ -49,5 +53,6 @@ export function useExpoMessengerCalling(calling) {
49
53
  return state;
50
54
  }
51
55
  export function ExpoMessengerCalls({ calling, state }) {
52
- return _jsxs(_Fragment, { children: [state.error && _jsxs(View, { style: { backgroundColor: '#171717', paddingHorizontal: 16, paddingVertical: 8, flexDirection: 'row', gap: 12, alignItems: 'center' }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: '#FAFAFA', flex: 1 }, children: state.error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void calling?.start().catch(() => { }), style: { padding: 8 }, children: _jsx(Text, { style: { color: '#FF8A2C' }, children: "Retry calls" }) })] }), state.direct && _jsx(NativeCallingOverlay, { controller: state.direct, appearance: state.appearance }), state.group && _jsx(NativeGroupCallingOverlay, { controller: state.group, appearance: state.appearance })] });
56
+ const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
57
+ return _jsxs(_Fragment, { children: [state.error && _jsxs(View, { style: { backgroundColor: '#171717', paddingHorizontal: 16, paddingVertical: 8, flexDirection: 'row', gap: 12, alignItems: 'center' }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: '#FAFAFA', flex: 1 }, children: state.error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void calling?.start().catch(() => { }), style: { padding: 8 }, children: _jsx(Text, { style: { color: '#FF8A2C' }, children: uiText("Retry calls") }) })] }), state.direct && _jsx(NativeCallingOverlay, { controller: state.direct, diagnostics: { labels: Object.fromEntries(Object.entries(callDiagnosticsText).map(([key, value]) => [key, uiText(value)])) }, appearance: state.appearance, labels: Object.fromEntries(Object.entries({ ...callingText, ...state.appearance?.labels }).map(([key, value]) => [key, uiText(value)])) }), state.group && _jsx(NativeGroupCallingOverlay, { controller: state.group, diagnostics: { labels: Object.fromEntries(Object.entries(callDiagnosticsText).map(([key, value]) => [key, uiText(value)])) }, appearance: state.appearance, labels: Object.fromEntries(Object.entries({ ...callingText, ...state.appearance?.labels }).map(([key, value]) => [key, uiText(value)])) })] });
53
58
  }
@@ -1,4 +1,6 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { RobonoLocationLocaleProvider } from '@robono/location-react-native';
3
+ import { useRobonoLocale } from './localization.js';
2
4
  import { prepareBackgroundTracking, captureBackgroundTracking, endBackgroundAccount } from './expo-tracking-task.js';
3
5
  import * as Location from 'expo-location';
4
6
  import Constants from 'expo-constants';
@@ -25,7 +27,8 @@ export function createExpoMessagingExtras(options) {
25
27
  stop(); }).catch(() => { stop(); onError(); }); },
26
28
  });
27
29
  function ConnectedTrackingMap(props) {
28
- return _jsx(ManagedMap, { client: extras, children: onLoad => _jsx(TrackingMap, { ...props, onLoad: onLoad }) });
30
+ const { configuration } = useRobonoLocale();
31
+ return _jsx(RobonoLocationLocaleProvider, { value: configuration, children: _jsx(ManagedMap, { client: extras, children: onLoad => _jsx(TrackingMap, { ...props, onLoad: onLoad }) }) });
29
32
  }
30
33
  return { extras, TrackingMap: ConnectedTrackingMap, async signOut() { extras.dispose(); const results = await Promise.allSettled([request.cancelUnfinished(), endBackgroundAccount(url.href, options.userId)]); const failed = results.find(r => r.status === 'rejected'); if (failed?.status === 'rejected')
31
34
  throw failed.reason; }, async dispose() { extras.dispose(); await endBackgroundAccount(url.href, options.userId); } };
@@ -1,3 +1,4 @@
1
+ import { MessagingError } from '@robono/messaging';
1
2
  import { awaitMediaScreening } from './media-screening';
2
3
  import { AudioModule, RecordingPresets, createAudioPlayer, setAudioModeAsync } from 'expo-audio';
3
4
  import * as Picker from 'expo-image-picker';
@@ -252,7 +253,7 @@ export function createExpoMessagingStorage(options) {
252
253
  async function request(operation, input, signal) {
253
254
  const token = await options.getAccessToken();
254
255
  if (!token)
255
- throw Error('Sign in to continue.');
256
+ throw new MessagingError('Sign in to continue.', 'authentication_required', 401);
256
257
  const bounded = new AbortController();
257
258
  const cancel = () => bounded.abort();
258
259
  signal?.addEventListener('abort', cancel, { once: true });
@@ -263,11 +264,19 @@ export function createExpoMessagingStorage(options) {
263
264
  const response = await (options.fetch ?? fetch)(endpoint, { method: 'POST', redirect: 'error', signal: bounded.signal, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ ...input, operation }) });
264
265
  const result = await response.json();
265
266
  if (!response.ok || result?.error) {
266
- const error = Object.assign(Error(result?.error?.message ?? 'The file could not be transferred. Try again.'), { status: response.status, code: result?.error?.code });
267
- throw error;
267
+ throw new MessagingError(result?.error?.message ?? 'The file could not be transferred. Try again.', result?.error?.code ?? 'media_unavailable', response.status);
268
268
  }
269
269
  return result;
270
270
  }
271
+ catch (error) {
272
+ if (signal?.aborted)
273
+ throw error;
274
+ if (bounded.signal.aborted)
275
+ throw new MessagingError('Connection timed out.', 'request_timeout');
276
+ if (error instanceof TypeError)
277
+ throw new MessagingError('Connect to send your attachment.', 'network_unavailable');
278
+ throw error;
279
+ }
271
280
  finally {
272
281
  clearTimeout(timeout);
273
282
  signal?.removeEventListener('abort', cancel);
@@ -319,11 +328,24 @@ export function createExpoMessagingStorage(options) {
319
328
  if (plan.media)
320
329
  return plan.media;
321
330
  const task = FS.createUploadTask(https(plan.url), uri, { httpMethod: 'PUT', uploadType: FS.FileSystemUploadType.BINARY_CONTENT, headers: plan.headers ?? {}, sessionType: FS.FileSystemSessionType.FOREGROUND }, p => context.onProgress(p.totalBytesExpectedToSend > 0 ? p.totalBytesSent / p.totalBytesExpectedToSend : 0));
322
- const result = await cancellable(task, context, () => task.uploadAsync());
331
+ const result = await cancellable(task, context, async () => {
332
+ try {
333
+ return await task.uploadAsync();
334
+ }
335
+ catch (error) {
336
+ check(context.signal);
337
+ // Native upload failures are not JavaScript TypeErrors. Preserve local
338
+ // file/permission errors, but allow the durable queue to retry network loss.
339
+ const detail = error instanceof Error ? error.message : String(error);
340
+ if (/(?:network (?:request failed|is unreachable|connection)|not connected to the internet|internet connection appears to be offline|unknownhost|unable to resolve host|failed to connect|connection (?:reset|refused|timed out)|sockettimeout|timed? ?out|NSURLErrorDomain.*(?:-1001|-1005|-1009)|ENETUNREACH|ECONNRESET)/i.test(detail))
341
+ throw new MessagingError('Connect to send your attachment.', 'network_unavailable');
342
+ throw error;
343
+ }
344
+ });
323
345
  // An immutable PUT may already have succeeded before its response was lost.
324
346
  // A 412 is not success by itself: complete still verifies ownership and bytes.
325
347
  if (!result || result.status !== 412 && (result.status < 200 || result.status >= 300))
326
- throw Error('Upload failed. Try again.');
348
+ throw new MessagingError('The attachment upload was interrupted.', 'upload_interrupted', result?.status ?? 503);
327
349
  // Verification is repeatable for the same upload. A transient inspector
328
350
  // failure must not upload the bytes again or create a second attachment.
329
351
  for (let attempt = 0;; attempt++) {
@@ -1,7 +1,7 @@
1
1
  import { createExpoMessengerCalling } from './expo-connected-calling.js';
2
2
  import type { LocationWorkspace } from '@robono/location';
3
3
  import React from 'react';
4
- import { MessagingInboxClient, type ConversationSummary } from '@robono/messaging';
4
+ import { MessagingAvailabilityClient, type HostedRobonoConnectionOptions, MessagingInboxClient, type ConversationSummary } from '@robono/messaging';
5
5
  import { type ExpoMessagingScreenProps } from './expo-screen.js';
6
6
  import { type MessagingAudioSession } from './expo-media.js';
7
7
  /** One account, one authenticated route, supplied storage and media-session adapters. */
@@ -10,6 +10,7 @@ export declare function createExpoMessenger(options: {
10
10
  userId: string;
11
11
  getAccessToken: () => Promise<string>;
12
12
  fetch?: typeof fetch;
13
+ availability?: false;
13
14
  notifications?: false;
14
15
  extras?: false;
15
16
  maps?: false;
@@ -54,8 +55,46 @@ export declare function createExpoMessenger(options: {
54
55
  prime(): void;
55
56
  call(conversationId: string, kind: "voice" | "video", group?: boolean): Promise<void>;
56
57
  } | undefined;
58
+ availability: MessagingAvailabilityClient | undefined;
57
59
  maps: LocationWorkspace | undefined;
58
60
  };
61
+ export declare function createHostedExpoMessenger(options: Omit<Parameters<typeof createExpoMessenger>[0], 'endpoint' | 'getAccessToken' | 'namespace'> & HostedRobonoConnectionOptions & {
62
+ signal?: AbortSignal;
63
+ }): Promise<{
64
+ signOut(): Promise<void>;
65
+ dispose(): void;
66
+ extras?: import("@robono/messaging").MessagingExtrasClient | undefined;
67
+ components?: {
68
+ TrackingMap: (props: import("./extras.js").TrackingMapProps) => React.JSX.Element;
69
+ } | undefined;
70
+ client: import("@robono/messaging").MessagingClient;
71
+ inbox: MessagingInboxClient;
72
+ storage: import("./media-controller.js").MessagingMediaStorage;
73
+ audioSession: MessagingAudioSession;
74
+ notifications: {
75
+ start(open: (conversationId: string, messageId: string) => Promise<void>): Promise<void>;
76
+ enable(): Promise<void>;
77
+ } | undefined;
78
+ calling: {
79
+ systemNotifications: import("@robono/calling-react-native/system-calling").SystemCallNotifications;
80
+ notification(data: unknown, opened?: boolean): Promise<boolean>;
81
+ dispose(): Promise<void>;
82
+ updateContacts(next: Record<string, {
83
+ name: string;
84
+ avatarUrl?: string;
85
+ }>): void;
86
+ start: () => Promise<void>;
87
+ getSnapshot: () => import("@robono/calling-react-native/messenger").MessengerCallingState;
88
+ subscribe(fn: () => void): () => void;
89
+ isActive: () => boolean;
90
+ reportError(message: string): void;
91
+ dismissUnanswered(): void;
92
+ prime(): void;
93
+ call(conversationId: string, kind: "voice" | "video", group?: boolean): Promise<void>;
94
+ } | undefined;
95
+ availability: MessagingAvailabilityClient | undefined;
96
+ maps: LocationWorkspace | undefined;
97
+ }>;
59
98
  export interface ExpoMessengerProps extends Omit<ExpoMessagingScreenProps, 'contact' | 'renderConversation' | 'onBack'> {
60
99
  calling?: ReturnType<typeof createExpoMessengerCalling>;
61
100
  maps?: LocationWorkspace;
@@ -74,4 +113,4 @@ export interface ExpoMessengerProps extends Omit<ExpoMessagingScreenProps, 'cont
74
113
  onSelectConversation?: (conversation: ConversationSummary | null) => void;
75
114
  }
76
115
  /** Supplied inbox, group creation, conversation, settings and media screens. */
77
- export declare function ExpoMessenger({ inbox, contacts, initialConversation, selectedConversation, onSelectConversation, notifications, maps, calling, ...props }: ExpoMessengerProps): React.JSX.Element;
116
+ export declare function ExpoMessenger(props: ExpoMessengerProps): React.JSX.Element;