@robono/messaging-react-native 0.2.0 → 0.3.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 (82) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/README.md +84 -7
  3. package/SBOM.spdx.json +1295 -239
  4. package/dist/account.d.ts +15 -0
  5. package/dist/account.js +27 -0
  6. package/dist/call-message.js +7 -1
  7. package/dist/calling.js +5 -9
  8. package/dist/conversation-details.d.ts +24 -0
  9. package/dist/conversation-details.js +83 -0
  10. package/dist/emoji-text.d.ts +2 -0
  11. package/dist/emoji-text.js +8 -0
  12. package/dist/expo-history.d.ts +3 -0
  13. package/dist/expo-history.js +59 -0
  14. package/dist/expo-media.d.ts +1 -0
  15. package/dist/expo-media.js +46 -43
  16. package/dist/expo-messenger.d.ts +35 -0
  17. package/dist/expo-messenger.js +39 -0
  18. package/dist/expo-notifications.d.ts +31 -0
  19. package/dist/expo-notifications.js +92 -0
  20. package/dist/expo-outbox.js +2 -1
  21. package/dist/expo-privacy.d.ts +4 -0
  22. package/dist/expo-privacy.js +7 -0
  23. package/dist/expo-screen.d.ts +42 -0
  24. package/dist/expo-screen.js +557 -0
  25. package/dist/expo.d.ts +2 -42
  26. package/dist/expo.js +2 -549
  27. package/dist/extras-lifecycle.d.ts +2 -0
  28. package/dist/extras-lifecycle.js +27 -0
  29. package/dist/extras.d.ts +49 -0
  30. package/dist/extras.js +49 -0
  31. package/dist/forward.js +4 -3
  32. package/dist/group-editor.d.ts +18 -0
  33. package/dist/group-editor.js +98 -0
  34. package/dist/header-calls.d.ts +18 -0
  35. package/dist/header-calls.js +45 -0
  36. package/dist/inbox-search.d.ts +11 -0
  37. package/dist/inbox-search.js +42 -0
  38. package/dist/inbox.d.ts +5 -2
  39. package/dist/inbox.js +19 -15
  40. package/dist/index.d.ts +33 -3
  41. package/dist/index.js +347 -129
  42. package/dist/media-controller.d.ts +3 -0
  43. package/dist/media-controller.js +39 -0
  44. package/dist/media-ui.d.ts +14 -3
  45. package/dist/media-ui.js +90 -28
  46. package/dist/message-menu.d.ts +28 -0
  47. package/dist/message-menu.js +24 -0
  48. package/dist/message-order.d.ts +3 -0
  49. package/dist/message-order.js +10 -0
  50. package/dist/messenger-video-view.d.ts +8 -0
  51. package/dist/messenger-video-view.js +46 -0
  52. package/dist/native-outbox.d.ts +8 -2
  53. package/dist/native-outbox.js +3 -1
  54. package/dist/photo-gesture.d.ts +20 -0
  55. package/dist/photo-gesture.js +52 -0
  56. package/dist/polls.d.ts +2 -1
  57. package/dist/polls.js +19 -17
  58. package/dist/popup.d.ts +43 -1
  59. package/dist/popup.js +72 -6
  60. package/dist/quick-send.d.ts +15 -0
  61. package/dist/quick-send.js +26 -0
  62. package/dist/safety.d.ts +9 -1
  63. package/dist/safety.js +21 -6
  64. package/dist/saved.d.ts +1 -1
  65. package/dist/saved.js +9 -4
  66. package/dist/search.d.ts +2 -2
  67. package/dist/search.js +32 -14
  68. package/dist/social.d.ts +9 -3
  69. package/dist/social.js +11 -15
  70. package/dist/translation-settings.d.ts +12 -0
  71. package/dist/translation-settings.js +16 -0
  72. package/dist/use-quote-gesture.d.ts +5 -1
  73. package/dist/use-quote-gesture.js +35 -17
  74. package/dist/visual/WeatherArtwork.d.ts +9 -0
  75. package/dist/visual/WeatherArtwork.js +43 -0
  76. package/dist/visual/weather-artwork.d.ts +17 -0
  77. package/dist/visual/weather-artwork.js +86 -0
  78. package/dist/visual/weather-conditions.d.ts +5 -0
  79. package/dist/visual/weather-conditions.js +2 -0
  80. package/dist/voice-waveform.d.ts +2 -0
  81. package/dist/voice-waveform.js +21 -0
  82. package/package.json +24 -4
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ /** Profile and account actions belong to the customer's sign-in system. */
3
+ export interface MessagingAccount {
4
+ name: string;
5
+ avatarUrl?: string;
6
+ phone?: string;
7
+ email?: string;
8
+ onEditProfile?: () => void | Promise<void>;
9
+ onSignOut?: () => void | Promise<void>;
10
+ }
11
+ export declare function MessagingAccountScreen({ account, companyName, onClose }: {
12
+ account?: MessagingAccount;
13
+ companyName: string;
14
+ onClose: () => void;
15
+ }): React.JSX.Element;
@@ -0,0 +1,27 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Image, Platform, Pressable, ScrollView, Text, View } from 'react-native';
4
+ import { MessagingPopup, messagingPopupColors as c } from './popup.js';
5
+ import { MessageIcon } from './icons.js';
6
+ export function MessagingAccountScreen({ account, companyName, onClose }) {
7
+ const [busy, setBusy] = useState(false), [error, setError] = useState(''), [visible, setVisible] = useState(true), pending = useRef(null);
8
+ const dismissed = () => { const action = pending.current; pending.current = null; if (action)
9
+ requestAnimationFrame(() => void run(action)); };
10
+ useEffect(() => { if (!visible && Platform.OS !== 'ios')
11
+ dismissed(); }, [visible]);
12
+ const begin = (action) => { if (busy)
13
+ return; setBusy(true); pending.current = action; setVisible(false); };
14
+ const run = async (action) => { setBusy(true); setError(''); try {
15
+ await action();
16
+ onClose();
17
+ }
18
+ catch {
19
+ setVisible(true);
20
+ setError('The change could not be completed. Please try again.');
21
+ }
22
+ finally {
23
+ setBusy(false);
24
+ } };
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?.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 })] }) });
27
+ }
@@ -47,7 +47,13 @@ export function UnansweredCallMessage({ callId, onDismiss }) {
47
47
  setOption(current);
48
48
  return;
49
49
  }
50
- const media = await pickAttachment(current.kind, { maxDurationMs: current.maxDurationMs, ...(current.kind === 'video' ? { source: 'call-video' } : {}), intent: 'attach' });
50
+ const selected = await pickAttachment(current.kind, { maxCount: 1, maxDurationMs: current.maxDurationMs, ...(current.kind === 'video' ? { source: 'call-video' } : {}), intent: 'attach' });
51
+ const items = selected ? (Array.isArray(selected) ? selected : [selected]) : [];
52
+ if (items.length > 1) {
53
+ items.forEach(item => void release.current?.(item));
54
+ throw Error('Choose one call message.');
55
+ }
56
+ const media = items[0];
51
57
  if (!media)
52
58
  return;
53
59
  if (id !== generation.current || client.getSnapshot().conversationId !== conversation) {
package/dist/calling.js CHANGED
@@ -2,21 +2,17 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
2
2
  import { createContext, useContext, useSyncExternalStore } from 'react';
3
3
  import { Pressable, View, Text } from 'react-native';
4
4
  import { RobonoIcon } from './visual/RobonoIcon.js';
5
- import { messagingHeaderColors, MessagingScreen, MessagingProvider, MessagingHeader, MessagingComposer, useMessagingUI } from './index.js';
5
+ import { MessagingScreen, MessagingProvider, MessagingHeader, MessagingComposer } from './index.js';
6
6
  import { UnansweredCallMessage } from './call-message.js';
7
7
  export { UnansweredCallMessage } from './call-message.js';
8
- const Action = createContext(null);
9
8
  const FollowUp = createContext(null);
10
9
  function CallingMessageComposer() { const follow = useContext(FollowUp); return _jsxs(_Fragment, { children: [follow && _jsx(UnansweredCallMessage, { callId: follow.callId, onDismiss: follow.dismiss }), _jsx(MessagingComposer, {})] }); }
11
10
  /** Optional call entry in the standard message header. The host controls eligibility and admission. */
12
- export function MessagingCallHeader() {
13
- const action = useContext(Action), { design, state } = useMessagingUI();
14
- const allowed = state.conversation?.canSend !== false;
15
- return _jsxs(MessagingHeader, { children: [allowed && action?.onCall && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: action.label, onPress: action.onCall, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(RobonoIcon, { name: "phone", size: 24, color: messagingHeaderColors(design, state.conversation?.kind === 'group').foreground }) }), allowed && action?.onVideoCall && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: action.videoLabel, onPress: action.onVideoCall, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(RobonoIcon, { name: "video", size: 24, color: messagingHeaderColors(design, state.conversation?.kind === 'group').foreground }) })] });
16
- }
17
- export function MessagingCallingScreen({ onCall, onVideoCall, videoCallLabel = 'Video call', callLabel = 'Voice call', components, unansweredCallId, onDismissCallMessage, ...props }) {
11
+ export function MessagingCallHeader() { return _jsx(MessagingHeader, {}); }
12
+ export function MessagingCallingScreen({ onCall, onVideoCall, videoCallLabel = 'Video call', callLabel = 'Robono call', components, unansweredCallId, onDismissCallMessage, ...props }) {
18
13
  const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
19
14
  const design = props.appearance ?? state.settings?.appearance;
15
+ const screenProps = { ...props, callActions: { ...props.callActions, ...(onCall ? { voice: onCall, voiceLabel: callLabel } : {}), ...(onVideoCall ? { video: onVideoCall, videoLabel: videoCallLabel } : {}) } };
20
16
  const callingOnly = (onCall || onVideoCall) && design && state.settings && !state.settings.enabled;
21
- return _jsx(Action.Provider, { value: onCall || onVideoCall ? { onCall, onVideoCall, label: callLabel, videoLabel: videoCallLabel } : null, children: _jsx(FollowUp.Provider, { value: unansweredCallId && onDismissCallMessage ? { callId: unansweredCallId, dismiss: onDismissCallMessage } : null, children: callingOnly ? _jsx(MessagingProvider, { ...props, 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, { ...props, components: { Header: MessagingCallHeader, Composer: CallingMessageComposer, ...components } }) }) });
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 } }) });
22
18
  }
@@ -0,0 +1,24 @@
1
+ import React from 'react';
2
+ import type { MessagingClient, MessagingMessage, MessagingState } from '@robono/messaging';
3
+ type Person = {
4
+ name: string;
5
+ avatarUrl?: string;
6
+ };
7
+ export interface ConversationDetailsProps {
8
+ client: MessagingClient;
9
+ state: MessagingState;
10
+ userId: string;
11
+ contact: Person;
12
+ participants?: Record<string, Person>;
13
+ accent: string;
14
+ onClose: () => void;
15
+ onEditGroup?: () => void;
16
+ onLeft?: () => void;
17
+ attachmentPreview?: (message: MessagingMessage) => Promise<{
18
+ uri?: string;
19
+ }>;
20
+ openAttachment?: (message: MessagingMessage) => Promise<void>;
21
+ }
22
+ /** Contact/group identity and retained shared media, using Messenger's bounded popup. */
23
+ export declare function ConversationDetails(props: ConversationDetailsProps): React.JSX.Element;
24
+ export {};
@@ -0,0 +1,83 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { ActivityIndicator, FlatList, Image, Platform, Pressable, Text, View } from 'react-native';
4
+ import { MessagingPopup, messagingPopupColors as c } from './popup.js';
5
+ import { MessageIcon, fileSize, duration } from './icons.js';
6
+ function MediaThumbnail({ message, preview }) {
7
+ const [uri, setUri] = useState();
8
+ useEffect(() => { let live = true; setUri(undefined); if (preview && ['image', 'video'].includes(message.kind))
9
+ void preview(message).then(value => { if (live)
10
+ 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 }) })] });
12
+ }
13
+ /** Contact/group identity and retained shared media, using Messenger's bounded popup. */
14
+ export function ConversationDetails(props) {
15
+ const { client, state, contact, participants = {}, userId, accent, onClose } = props, group = state.conversation?.kind === 'group';
16
+ const [tab, setTab] = useState('media'), [messages, setMessages] = useState([]), [members, setMembers] = useState([]);
17
+ const [next, setNext] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState(''), [visible, setVisible] = useState(true);
18
+ const [leaving, setLeaving] = useState(false), leaveRequest = useRef(undefined);
19
+ const request = useRef(null), generation = useRef(0), pending = useRef(null);
20
+ const title = group ? state.conversation?.name ?? 'Group' : contact.name;
21
+ const dismiss = () => { const action = pending.current; pending.current = null; onClose(); action?.(); };
22
+ useEffect(() => { if (!visible && Platform.OS !== 'ios')
23
+ dismiss(); }, [visible]);
24
+ useEffect(() => { if (!state.settings?.enabled && !leaving)
25
+ onClose(); }, [state.settings?.enabled]);
26
+ async function leave() {
27
+ setBusy(true);
28
+ setError('');
29
+ leaveRequest.current ??= client.createForwardId();
30
+ try {
31
+ await client.leaveGroup(leaveRequest.current);
32
+ pending.current = props.onLeft ?? null;
33
+ setVisible(false);
34
+ }
35
+ catch (e) {
36
+ setError(e instanceof Error ? e.message : 'The group could not be left.');
37
+ }
38
+ finally {
39
+ setBusy(false);
40
+ }
41
+ }
42
+ async function load(more = false) {
43
+ request.current?.abort();
44
+ const control = new AbortController();
45
+ request.current = control;
46
+ const version = ++generation.current;
47
+ setBusy(true);
48
+ setError('');
49
+ try {
50
+ if (tab === 'media') {
51
+ const page = await client.sharedMedia({ ...(more && next ? { before: next } : {}), signal: control.signal });
52
+ if (version !== generation.current)
53
+ return;
54
+ setMessages(current => more ? [...new Map([...current, ...page.messages].map(m => [m.id, m])).values()] : page.messages);
55
+ setNext(page.hasMore ? page.nextCursor : null);
56
+ }
57
+ else {
58
+ const page = await client.groupMembers(more && next ? next : undefined);
59
+ if (version !== generation.current)
60
+ return;
61
+ setMembers(current => more ? [...new Map([...current, ...page.members].map(m => [m.userId, m])).values()] : page.members);
62
+ setNext(page.hasMore ? page.nextCursor : null);
63
+ }
64
+ }
65
+ catch (e) {
66
+ if (version === generation.current && !control.signal.aborted)
67
+ setError(e instanceof Error ? e.message : 'These details are unavailable.');
68
+ }
69
+ finally {
70
+ if (version === generation.current)
71
+ setBusy(false);
72
+ }
73
+ }
74
+ useEffect(() => { setMessages([]); setMembers([]); setNext(null); if (tab === 'members' || state.sharedMediaAvailable)
75
+ 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) {
78
+ if (leaving)
79
+ setLeaving(false);
80
+ else
81
+ 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" }) })] })] }) });
83
+ }
@@ -0,0 +1,2 @@
1
+ /** Match Messenger's emoji-only message layout (AppRoot.isBigEmojiText). */
2
+ export declare function isBigEmojiText(value: string | undefined | null): boolean;
@@ -0,0 +1,8 @@
1
+ /** Match Messenger's emoji-only message layout (AppRoot.isBigEmojiText). */
2
+ export function isBigEmojiText(value) {
3
+ const compact = (value ?? '').trim().replace(/\s+/g, '');
4
+ if (!compact || compact.length > 16)
5
+ return false;
6
+ const sequences = compact.match(/(?:\p{Regional_Indicator}{2}|[0-9#*]\uFE0F?\u20E3|(?:\p{Extended_Pictographic}|\p{Emoji_Presentation})(?:\p{Emoji_Modifier}|\uFE0F|\uFE0E)?(?:\u200D(?:\p{Extended_Pictographic}|\p{Emoji_Presentation})(?:\p{Emoji_Modifier}|\uFE0F|\uFE0E)?)*(?:[\u{E0020}-\u{E007E}]+\u{E007F})?)/gu) ?? [];
7
+ return sequences.length > 0 && sequences.join('') === compact;
8
+ }
@@ -0,0 +1,3 @@
1
+ import { MessagingHistoryCache } from '@robono/messaging';
2
+ /** App-private recent history; no downloaded attachments or access tokens. */
3
+ export declare function createExpoMessagingHistory(endpoint: string, userId: string, namespace?: string): MessagingHistoryCache;
@@ -0,0 +1,59 @@
1
+ import * as FS from 'expo-file-system/legacy';
2
+ import * as Crypto from 'expo-crypto';
3
+ import { MessagingHistoryCache } from '@robono/messaging';
4
+ /** App-private recent history; no downloaded attachments or access tokens. */
5
+ export function createExpoMessagingHistory(endpoint, userId, namespace = 'default') {
6
+ const digest = (value) => Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, value);
7
+ const directory = async () => {
8
+ if (!FS.documentDirectory)
9
+ throw Error('Offline history storage is unavailable.');
10
+ return FS.documentDirectory + 'robono-recent-history/' + await digest(JSON.stringify([endpoint, namespace, userId])) + '/';
11
+ };
12
+ const names = async (dir) => (await FS.getInfoAsync(dir)).exists ? (await FS.readDirectoryAsync(dir)).filter(name => /^\d+-[a-f\d]{64}\.json$/.test(name)).sort((a, b) => Number(b.split('-')[0]) - Number(a.split('-')[0])) : [];
13
+ const store = {
14
+ async read(cid) {
15
+ const dir = await directory(), hash = await digest(cid), file = (await names(dir)).find(name => name.endsWith('-' + hash + '.json'));
16
+ if (!file)
17
+ return null;
18
+ const info = await FS.getInfoAsync(dir + file);
19
+ if (!info.exists || info.isDirectory || info.size > 200000) {
20
+ await FS.deleteAsync(dir + file, { idempotent: true });
21
+ return null;
22
+ }
23
+ try {
24
+ return JSON.parse(await FS.readAsStringAsync(dir + file));
25
+ }
26
+ catch {
27
+ await FS.deleteAsync(dir + file, { idempotent: true });
28
+ return null;
29
+ }
30
+ },
31
+ async write(snapshot) {
32
+ const dir = await directory(), hash = await digest(snapshot.conversationId), file = snapshot.savedAt + '-' + hash + '.json';
33
+ await FS.makeDirectoryAsync(dir, { intermediates: true });
34
+ const old = await names(dir);
35
+ if (old.some(name => name.endsWith('-' + hash + '.json') && Number(name.split('-')[0]) > snapshot.savedAt))
36
+ return;
37
+ const temporary = dir + Crypto.randomUUID() + '.tmp';
38
+ try {
39
+ await FS.writeAsStringAsync(temporary, JSON.stringify(snapshot));
40
+ await FS.moveAsync({ from: temporary, to: dir + file });
41
+ }
42
+ finally {
43
+ await FS.deleteAsync(temporary, { idempotent: true }).catch(() => { });
44
+ }
45
+ const ordered = await names(dir), keep = new Set(ordered.filter(name => name === file || !name.endsWith('-' + hash + '.json')).slice(0, 20));
46
+ await Promise.all(ordered.filter(name => !keep.has(name)).map(name => FS.deleteAsync(dir + name, { idempotent: true })));
47
+ },
48
+ async clear(cid) {
49
+ const dir = await directory();
50
+ if (cid === undefined) {
51
+ await FS.deleteAsync(dir, { idempotent: true });
52
+ return;
53
+ }
54
+ const hash = await digest(cid);
55
+ await Promise.all((await names(dir)).filter(name => name.endsWith('-' + hash + '.json')).map(name => FS.deleteAsync(dir + name, { idempotent: true })));
56
+ },
57
+ };
58
+ return new MessagingHistoryCache(store);
59
+ }
@@ -16,4 +16,5 @@ export declare function createExpoMessagingStorage(options: {
16
16
  url: string;
17
17
  getAccessToken: () => Promise<string>;
18
18
  streaming?: boolean;
19
+ fetch?: typeof fetch;
19
20
  }): MessagingMediaStorage;
@@ -109,55 +109,58 @@ export function createExpoMessagingDevice(audio) {
109
109
  let recordingDeadline = 0;
110
110
  let starting = null;
111
111
  let stopping = null;
112
- return {
113
- async pick(kind, settings, signal, source) {
114
- if (source === 'camera' && (!['image', 'video'].includes(kind) || !settings.appearance.showCamera))
115
- throw Error('Camera capture is unavailable.');
116
- if (source === 'call-video' && (kind !== 'video' || !settings.enabled || !settings.features.video || settings.unansweredCalls?.video !== 'video'))
117
- throw Error('Video messages after a call are unavailable.');
118
- const limits = (settings.attachments ?? defaultAttachmentLimits())[kind];
112
+ async function chooseFiles(kind, settings, signal, source, maximum) {
113
+ if (source === 'camera' && (!['image', 'video'].includes(kind) || !settings.appearance.showCamera))
114
+ throw Error('Camera capture is unavailable.');
115
+ if (source === 'call-video' && (kind !== 'video' || !settings.enabled || !settings.features.video || settings.unansweredCalls?.video !== 'video'))
116
+ throw Error('Video messages after a call are unavailable.');
117
+ const limits = (settings.attachments ?? defaultAttachmentLimits())[kind];
118
+ let assets = [];
119
+ const copies = [];
120
+ try {
119
121
  if (kind === 'document') {
120
- const result = await Documents.getDocumentAsync({ type: limits.allowedMimeTypes, multiple: false, copyToCacheDirectory: true });
121
- if (signal.aborted) {
122
- for (const a of result.assets ?? [])
123
- if (FS.cacheDirectory && a.uri.startsWith(FS.cacheDirectory))
124
- await FS.deleteAsync(a.uri, { idempotent: true }).catch(() => { });
122
+ const result = await Documents.getDocumentAsync({ type: limits.allowedMimeTypes, multiple: maximum > 1, copyToCacheDirectory: true });
123
+ assets = result.assets ?? [];
124
+ if (result.canceled)
125
+ return [];
126
+ }
127
+ else {
128
+ if (source) {
129
+ const permission = await Picker.requestCameraPermissionsAsync();
125
130
  check(signal);
131
+ if (!permission.granted)
132
+ throw Error('Camera permission is needed to take photos or record videos.');
126
133
  }
134
+ const result = await (source ? Picker.launchCameraAsync : Picker.launchImageLibraryAsync)({ mediaTypes: source === 'call-video' ? ['videos'] : source ? [...((settings.features?.image ?? kind === 'image') ? ['images'] : []), ...(settings.features?.video ? ['videos'] : [])] : kind === 'image' ? ['images'] : ['videos'], ...(source ? { videoMaxDuration: settings.attachments?.video?.maxDurationSeconds ?? 60 } : {}), allowsMultipleSelection: !source && maximum > 1, selectionLimit: maximum, orderedSelection: true, allowsEditing: false, quality: 1, preferredAssetRepresentationMode: Picker.UIImagePickerPreferredAssetRepresentationMode.Current });
135
+ assets = result.assets ?? [];
127
136
  if (result.canceled)
128
- return null;
129
- const asset = result.assets[0];
130
- if (!asset)
131
- return null;
132
- return pickedCopy(asset.uri, asset.name, mime(asset.mimeType, asset.name));
137
+ return [];
133
138
  }
134
- if ((source === 'camera' || source === 'call-video')) {
135
- const permission = await Picker.requestCameraPermissionsAsync();
139
+ check(signal);
140
+ if (assets.length > maximum)
141
+ throw Error(`Choose up to ${maximum} attachments.`);
142
+ for (const asset of assets) {
136
143
  check(signal);
137
- if (!permission.granted)
138
- throw Error('Camera permission is needed to take photos or record videos.');
144
+ const video = kind === 'video' || !!source && (asset.type === 'video' || asset.mimeType?.startsWith('video/'));
145
+ const name = asset.name ?? asset.fileName ?? asset.uri.split('/').at(-1) ?? (video ? 'Video' : 'Photo');
146
+ if (video && (!asset.duration || !Number.isFinite(asset.duration)))
147
+ throw Error('The video length could not be read. Choose another video.');
148
+ copies.push(await copied(asset.uri, name, mime(asset.mimeType, name), video ? asset.duration : undefined));
139
149
  }
140
- const result = await ((source === 'camera' || source === 'call-video') ? Picker.launchCameraAsync : Picker.launchImageLibraryAsync)({ mediaTypes: source === 'call-video' ? ['videos'] : (source === 'camera' || source === 'call-video') ? ([...((settings.features?.image ?? kind === 'image') ? ['images'] : []), ...(settings.features?.video ? ['videos'] : [])]) : kind === 'image' ? ['images'] : ['videos'], ...((source === 'camera' || source === 'call-video') ? { videoMaxDuration: settings.attachments?.video?.maxDurationSeconds ?? 60 } : {}), allowsMultipleSelection: false, allowsEditing: false, quality: 1, preferredAssetRepresentationMode: Picker.UIImagePickerPreferredAssetRepresentationMode.Current });
141
- if (signal.aborted) {
142
- for (const a of result.assets ?? [])
143
- if (FS.cacheDirectory && a.uri.startsWith(FS.cacheDirectory))
144
- await FS.deleteAsync(a.uri, { idempotent: true }).catch(() => { });
145
- check(signal);
146
- }
147
- if (result.canceled)
148
- return null;
149
- const asset = result.assets[0];
150
- if (!asset)
151
- return null;
152
- const video = (source === 'camera' || source === 'call-video') ? (asset.type === 'video' || asset.mimeType?.startsWith('video/')) : kind === 'video';
153
- const name = asset.fileName ?? asset.uri.split('/').at(-1) ?? (kind === 'image' ? 'Photo' : 'Video');
154
- if (video && (!asset.duration || !Number.isFinite(asset.duration))) {
155
- if (FS.cacheDirectory && asset.uri.startsWith(FS.cacheDirectory))
156
- await FS.deleteAsync(asset.uri, { idempotent: true }).catch(() => { });
157
- throw Error('The video length could not be read. Choose another video.');
158
- }
159
- return pickedCopy(asset.uri, name, mime(asset.mimeType, name), video ? asset.duration : undefined);
160
- },
150
+ check(signal);
151
+ return copies;
152
+ }
153
+ catch (error) {
154
+ await Promise.all(copies.map(file => remove(file).catch(() => { })));
155
+ throw error;
156
+ }
157
+ finally {
158
+ await Promise.all(assets.filter(a => FS.cacheDirectory && a.uri.startsWith(FS.cacheDirectory)).map(a => FS.deleteAsync(a.uri, { idempotent: true }).catch(() => { })));
159
+ }
160
+ }
161
+ return {
162
+ async pick(kind, settings, signal, source) { return (await chooseFiles(kind, settings, signal, source, 1))[0] ?? null; },
163
+ pickMany: (kind, settings, signal, maximum) => chooseFiles(kind, settings, signal, undefined, maximum),
161
164
  async startRecording(maximum, signal) {
162
165
  if (starting || recorder)
163
166
  throw Error('A recording is already in progress.');
@@ -256,7 +259,7 @@ export function createExpoMessagingStorage(options) {
256
259
  bounded.abort();
257
260
  const timeout = setTimeout(cancel, operation === 'complete' ? 120000 : 15000);
258
261
  try {
259
- const response = await fetch(endpoint, { method: 'POST', redirect: 'error', signal: bounded.signal, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ ...input, operation }) });
262
+ 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 }) });
260
263
  const result = await response.json();
261
264
  if (!response.ok) {
262
265
  const error = Object.assign(Error(result?.error?.message ?? 'The file could not be transferred. Try again.'), { status: response.status });
@@ -0,0 +1,35 @@
1
+ import React from 'react';
2
+ import { MessagingInboxClient, type ConversationSummary } from '@robono/messaging';
3
+ import { type ExpoMessagingScreenProps } from './expo-screen.js';
4
+ import { type MessagingAudioSession } from './expo-media.js';
5
+ /** One account, one authenticated route, supplied storage and media-session adapters. */
6
+ export declare function createExpoMessenger(options: {
7
+ endpoint: string;
8
+ userId: string;
9
+ getAccessToken: () => Promise<string>;
10
+ fetch?: typeof fetch;
11
+ persistence?: false;
12
+ namespace?: string;
13
+ audioSession?: MessagingAudioSession;
14
+ isCallActive?: () => boolean;
15
+ }): {
16
+ client: import("@robono/messaging").MessagingClient;
17
+ inbox: MessagingInboxClient;
18
+ storage: import("./media-controller.js").MessagingMediaStorage;
19
+ audioSession: MessagingAudioSession;
20
+ signOut(): Promise<void>;
21
+ dispose(): void;
22
+ };
23
+ export interface ExpoMessengerProps extends Omit<ExpoMessagingScreenProps, 'contact' | 'renderConversation' | 'onBack'> {
24
+ inbox: MessagingInboxClient;
25
+ contacts?: Record<string, {
26
+ name: string;
27
+ avatarUrl?: string;
28
+ phoneNumber?: string;
29
+ }>;
30
+ initialConversation?: ConversationSummary;
31
+ selectedConversation?: ConversationSummary | null;
32
+ onSelectConversation?: (conversation: ConversationSummary | null) => void;
33
+ }
34
+ /** Supplied inbox, group creation, conversation, settings and media screens. */
35
+ export declare function ExpoMessenger({ inbox, contacts, initialConversation, selectedConversation, onSelectConversation, ...props }: ExpoMessengerProps): React.JSX.Element;
@@ -0,0 +1,39 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState, useSyncExternalStore } from 'react';
3
+ import { createMessagingTransport, defaultMessagingSettings, MessagingInboxClient } from '@robono/messaging';
4
+ import { ExpoMessagingScreen } from './expo-screen.js';
5
+ import { createExpoMessagingClient } from './expo-outbox.js';
6
+ import { createExpoMessagingAudioSession, createExpoMessagingStorage } from './expo-media.js';
7
+ import { MessagingProvider, MessagingConversation, MessagingAccountMenu } from './index.js';
8
+ import { MessagingInbox } from './inbox.js';
9
+ import { MessagingGroupEditor } from './group-editor.js';
10
+ /** One account, one authenticated route, supplied storage and media-session adapters. */
11
+ export function createExpoMessenger(options) {
12
+ const endpoint = new URL(options.endpoint);
13
+ if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password)
14
+ throw Error('Use your authenticated HTTPS messenger endpoint.');
15
+ const media = new URL(endpoint);
16
+ media.searchParams.set('service', 'media');
17
+ const client = createExpoMessagingClient({ ...options, url: endpoint.href });
18
+ const inbox = new MessagingInboxClient({ request: createMessagingTransport({ ...options, url: endpoint.href }) });
19
+ const storage = createExpoMessagingStorage({ url: media.href, getAccessToken: options.getAccessToken, ...(options.fetch ? { fetch: options.fetch } : {}) });
20
+ const audioSession = options.audioSession ?? createExpoMessagingAudioSession({ isCallActive: options.isCallActive });
21
+ return { client, inbox, storage, audioSession,
22
+ async signOut() { inbox.dispose(); await client.signOut(); },
23
+ dispose() { inbox.dispose(); client.dispose(); },
24
+ };
25
+ }
26
+ /** Supplied inbox, group creation, conversation, settings and media screens. */
27
+ export function ExpoMessenger({ inbox, contacts = {}, initialConversation, selectedConversation, onSelectConversation, ...props }) {
28
+ const [internal, setInternal] = useState(initialConversation ?? null), [newGroup, setNewGroup] = useState(false);
29
+ const selected = selectedConversation === undefined ? internal : selectedConversation;
30
+ const setSelected = (row) => { setInternal(row); onSelectConversation?.(row); };
31
+ const inboxState = useSyncExternalStore(inbox.subscribe, inbox.getSnapshot, inbox.getSnapshot);
32
+ const open = (row, messageId) => {
33
+ setSelected(row);
34
+ void props.client.open(row.id).then(async () => { if (messageId && props.client.getSnapshot().conversationId === row.id)
35
+ await props.client.focusMessage(messageId); }).catch(() => { });
36
+ };
37
+ const contact = selected?.kind === 'group' ? { name: selected.name ?? 'Group' } : contacts[selected?.contactId ?? ''] ?? { name: selected?.contactId ?? 'Chat' };
38
+ return _jsx(ExpoMessagingScreen, { ...props, contact: contact, participants: props.participants ?? contacts, extras: selected ? props.extras : undefined, callActions: selected ? { ...props.callActions, phoneNumber: contacts[selected.contactId ?? '']?.phoneNumber ?? props.callActions?.phoneNumber } : undefined, onBack: () => { props.client.stop(); setSelected(null); void inbox.refresh().catch(() => { }); }, renderConversation: screen => _jsx(MessagingProvider, { ...screen, children: selected ? _jsx(MessagingConversation, {}) : _jsxs(_Fragment, { children: [_jsx(MessagingInbox, { client: inbox, userId: props.userId, contacts: contacts, companyName: props.companyName, appearance: props.appearance, active: props.active !== false, headerActions: _jsx(MessagingAccountMenu, { onNewGroup: inboxState.groupCreationAvailable ? () => setNewGroup(true) : undefined }), onSelect: open, onDeleted: id => props.client.forgetConversation(id) }), newGroup && _jsx(MessagingGroupEditor, { client: props.client, inbox: inbox, userId: props.userId, contacts: contacts, groups: inboxState.groups, accent: (props.appearance ?? inboxState.appearance ?? defaultMessagingSettings().appearance).colors.accent, onClose: () => setNewGroup(false), onSaved: (id, name) => { setNewGroup(false); const row = inbox.getSnapshot().conversations.find(c => c.id === id) ?? { id, kind: 'group', name, contactId: null, lastMessage: null, unreadCount: 0, canSend: true }; open(row); } })] }) }) });
39
+ }
@@ -0,0 +1,31 @@
1
+ import * as Notifications from 'expo-notifications';
2
+ import { type MessagingNotification } from '@robono/messaging';
3
+ export interface ExpoMessagingNotificationOptions {
4
+ /** The combined server route, with service=notifications. */
5
+ url: string;
6
+ userId: string;
7
+ getAccessToken: () => Promise<string>;
8
+ authorize: (event: MessagingNotification) => Promise<boolean>;
9
+ open: (event: MessagingNotification) => void | Promise<void>;
10
+ refresh: () => void | Promise<void>;
11
+ onError: (error: Error) => void;
12
+ fetch?: typeof fetch;
13
+ }
14
+ /** One restored account. Never prompts on mount, replaces a global handler or handles calling pushes. */
15
+ export declare function connectExpoMessagingNotifications(options: ExpoMessagingNotificationOptions): Promise<{
16
+ ready: Promise<void>;
17
+ /** Call only after the user chooses Enable notifications in your interface. */
18
+ enable(): Promise<boolean>;
19
+ /** Await before revoking your login session, so the old account's token is removed. */
20
+ signOut(): Promise<void>;
21
+ dispose: () => void;
22
+ }>;
23
+ /** Compose with the host's existing handler. Suppress only this account's open conversation. */
24
+ export declare function withMessagingNotificationPresentation(handler: Notifications.NotificationHandler, current: () => {
25
+ accountScope: string;
26
+ conversationId: string | null;
27
+ }): {
28
+ handleNotification: (notification: Notifications.Notification) => Promise<Notifications.NotificationBehavior>;
29
+ handleSuccess?: (notificationId: string) => void;
30
+ handleError?: (notificationId: string, error: Notifications.NotificationHandlingError) => void;
31
+ };