@robono/messaging-react-native 0.1.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.
- package/CHANGELOG.md +65 -0
- package/README.md +160 -8
- package/SBOM.spdx.json +1985 -209
- package/dist/account.d.ts +15 -0
- package/dist/account.js +27 -0
- package/dist/call-message.js +7 -1
- package/dist/calling.js +5 -9
- package/dist/conversation-details.d.ts +24 -0
- package/dist/conversation-details.js +83 -0
- package/dist/emoji-text.d.ts +2 -0
- package/dist/emoji-text.js +8 -0
- package/dist/expo-download.d.ts +3 -0
- package/dist/expo-download.js +43 -0
- package/dist/expo-history.d.ts +3 -0
- package/dist/expo-history.js +59 -0
- package/dist/expo-media.d.ts +1 -0
- package/dist/expo-media.js +69 -43
- package/dist/expo-messenger.d.ts +35 -0
- package/dist/expo-messenger.js +39 -0
- package/dist/expo-notifications.d.ts +31 -0
- package/dist/expo-notifications.js +92 -0
- package/dist/expo-outbox.d.ts +13 -0
- package/dist/expo-outbox.js +82 -0
- package/dist/expo-privacy.d.ts +4 -0
- package/dist/expo-privacy.js +7 -0
- package/dist/expo-saved.d.ts +24 -0
- package/dist/expo-saved.js +76 -0
- package/dist/expo-screen.d.ts +42 -0
- package/dist/expo-screen.js +557 -0
- package/dist/expo-social.d.ts +3 -0
- package/dist/expo-social.js +21 -0
- package/dist/expo-thumbnails.d.ts +9 -0
- package/dist/expo-thumbnails.js +68 -0
- package/dist/expo.d.ts +2 -36
- package/dist/expo.js +2 -429
- package/dist/extras-lifecycle.d.ts +2 -0
- package/dist/extras-lifecycle.js +27 -0
- package/dist/extras.d.ts +49 -0
- package/dist/extras.js +49 -0
- package/dist/forward.d.ts +13 -0
- package/dist/forward.js +62 -0
- package/dist/group-editor.d.ts +18 -0
- package/dist/group-editor.js +98 -0
- package/dist/header-calls.d.ts +18 -0
- package/dist/header-calls.js +45 -0
- package/dist/icons.d.ts +1 -1
- package/dist/inbox-search.d.ts +11 -0
- package/dist/inbox-search.js +42 -0
- package/dist/inbox.d.ts +19 -0
- package/dist/inbox.js +52 -0
- package/dist/index.d.ts +76 -3
- package/dist/index.js +648 -167
- package/dist/media-controller.d.ts +21 -0
- package/dist/media-controller.js +157 -3
- package/dist/media-readiness.d.ts +1 -1
- package/dist/media-ui.d.ts +27 -5
- package/dist/media-ui.js +95 -29
- package/dist/message-links.d.ts +5 -0
- package/dist/message-links.js +30 -0
- package/dist/message-menu.d.ts +28 -0
- package/dist/message-menu.js +24 -0
- package/dist/message-order.d.ts +7 -0
- package/dist/message-order.js +27 -0
- package/dist/messenger-video-view.d.ts +8 -0
- package/dist/messenger-video-view.js +46 -0
- package/dist/native-outbox.d.ts +55 -0
- package/dist/native-outbox.js +53 -0
- package/dist/photo-gesture.d.ts +20 -0
- package/dist/photo-gesture.js +52 -0
- package/dist/pollVoteQueue.d.ts +3 -0
- package/dist/pollVoteQueue.js +29 -0
- package/dist/polls.d.ts +7 -2
- package/dist/polls.js +89 -20
- package/dist/popup.d.ts +51 -0
- package/dist/popup.js +73 -0
- package/dist/quick-send.d.ts +15 -0
- package/dist/quick-send.js +26 -0
- package/dist/recent-media.js +1 -0
- package/dist/safety.d.ts +10 -1
- package/dist/safety.js +29 -8
- package/dist/saved.d.ts +10 -0
- package/dist/saved.js +51 -0
- package/dist/search.d.ts +9 -0
- package/dist/search.js +59 -0
- package/dist/social.d.ts +25 -0
- package/dist/social.js +31 -0
- package/dist/translation-settings.d.ts +12 -0
- package/dist/translation-settings.js +16 -0
- package/dist/use-quote-gesture.d.ts +5 -1
- package/dist/use-quote-gesture.js +35 -17
- package/dist/visual/RobonoIcon.js +2 -1
- package/dist/visual/WeatherArtwork.d.ts +9 -0
- package/dist/visual/WeatherArtwork.js +43 -0
- package/dist/visual/weather-artwork.d.ts +17 -0
- package/dist/visual/weather-artwork.js +86 -0
- package/dist/visual/weather-conditions.d.ts +5 -0
- package/dist/visual/weather-conditions.js +2 -0
- package/dist/voice-composer.js +2 -2
- package/dist/voice-waveform.d.ts +2 -0
- package/dist/voice-waveform.js +21 -0
- package/package.json +41 -5
|
@@ -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;
|
package/dist/account.js
ADDED
|
@@ -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
|
+
}
|
package/dist/call-message.js
CHANGED
|
@@ -47,7 +47,13 @@ export function UnansweredCallMessage({ callId, onDismiss }) {
|
|
|
47
47
|
setOption(current);
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
50
|
-
const
|
|
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 { MessagingScreen, MessagingProvider, MessagingHeader, MessagingComposer
|
|
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
|
-
|
|
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: design.colors.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: design.colors.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(
|
|
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,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,43 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
import * as FS from 'expo-file-system/legacy';
|
|
3
|
+
import * as Sharing from 'expo-sharing';
|
|
4
|
+
import * as MediaLibrary from 'expo-media-library';
|
|
5
|
+
/** Export an already-authorized viewer copy; never expose its private source URL. */
|
|
6
|
+
export async function saveMessagingAttachment(file, kind) {
|
|
7
|
+
if (kind === 'image' || kind === 'video') {
|
|
8
|
+
const permission = await MediaLibrary.requestPermissionsAsync(true, [kind === 'image' ? 'photo' : 'video']);
|
|
9
|
+
if (!permission.granted)
|
|
10
|
+
throw Error('Allow photo access to save this attachment.');
|
|
11
|
+
await MediaLibrary.saveToLibraryAsync(file.uri);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (kind !== 'document')
|
|
15
|
+
throw Error('This attachment cannot be downloaded.');
|
|
16
|
+
if (Platform.OS !== 'android') {
|
|
17
|
+
if (!await Sharing.isAvailableAsync())
|
|
18
|
+
throw Error('File saving is unavailable.');
|
|
19
|
+
await Sharing.shareAsync(file.uri, { mimeType: file.mimeType, dialogTitle: 'Save ' + file.fileName });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const permission = await FS.StorageAccessFramework.requestDirectoryPermissionsAsync();
|
|
23
|
+
if (!permission.granted)
|
|
24
|
+
return;
|
|
25
|
+
const name = file.fileName.split(/[\\/]/).at(-1)?.replace(/[\u0000-\u001f\u007f]/g, '').trim() || 'Attachment';
|
|
26
|
+
const destination = await FS.StorageAccessFramework.createFileAsync(permission.directoryUri, name, file.mimeType);
|
|
27
|
+
try {
|
|
28
|
+
// Bounded chunks avoid keeping an entire large document and its base64 copy in memory.
|
|
29
|
+
const chunk = 3 * 256 * 1024;
|
|
30
|
+
for (let position = 0; position < file.byteSize; position += chunk) {
|
|
31
|
+
const length = Math.min(chunk, file.byteSize - position);
|
|
32
|
+
const data = await FS.readAsStringAsync(file.uri, { encoding: FS.EncodingType.Base64, position, length });
|
|
33
|
+
const bytes = data.length * 3 / 4 - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0);
|
|
34
|
+
if (bytes !== length)
|
|
35
|
+
throw Error('The attachment could not be read completely.');
|
|
36
|
+
await FS.writeAsStringAsync(destination, data, { encoding: FS.EncodingType.Base64, append: position > 0 });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
await FS.deleteAsync(destination, { idempotent: true }).catch(() => { });
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/expo-media.d.ts
CHANGED
package/dist/expo-media.js
CHANGED
|
@@ -109,55 +109,58 @@ export function createExpoMessagingDevice(audio) {
|
|
|
109
109
|
let recordingDeadline = 0;
|
|
110
110
|
let starting = null;
|
|
111
111
|
let stopping = null;
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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:
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
129
|
-
const asset = result.assets[0];
|
|
130
|
-
if (!asset)
|
|
131
|
-
return null;
|
|
132
|
-
return pickedCopy(asset.uri, asset.name, mime(asset.mimeType, asset.name));
|
|
133
|
-
}
|
|
134
|
-
if ((source === 'camera' || source === 'call-video')) {
|
|
135
|
-
const permission = await Picker.requestCameraPermissionsAsync();
|
|
136
|
-
check(signal);
|
|
137
|
-
if (!permission.granted)
|
|
138
|
-
throw Error('Camera permission is needed to take photos or record videos.');
|
|
137
|
+
return [];
|
|
139
138
|
}
|
|
140
|
-
|
|
141
|
-
if (
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
await FS.deleteAsync(a.uri, { idempotent: true }).catch(() => { });
|
|
139
|
+
check(signal);
|
|
140
|
+
if (assets.length > maximum)
|
|
141
|
+
throw Error(`Choose up to ${maximum} attachments.`);
|
|
142
|
+
for (const asset of assets) {
|
|
145
143
|
check(signal);
|
|
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));
|
|
146
149
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 });
|
|
@@ -342,6 +345,29 @@ export function createExpoMessagingStorage(options) {
|
|
|
342
345
|
}
|
|
343
346
|
}
|
|
344
347
|
},
|
|
348
|
+
async thumbnail(message, context) {
|
|
349
|
+
check(context.signal);
|
|
350
|
+
const plan = await request('thumbnail', { conversationId: message.conversationId, messageId: message.id }, context.signal);
|
|
351
|
+
if (!plan)
|
|
352
|
+
return null;
|
|
353
|
+
const media = plan.media;
|
|
354
|
+
if (media?.assetId !== message.media?.assetId || media?.mimeType !== 'image/jpeg' || !Number.isInteger(media.byteSize) || media.byteSize < 1 || media.byteSize > 131072)
|
|
355
|
+
throw Error('Invalid attachment preview.');
|
|
356
|
+
const path = await cachePath('preview.jpg');
|
|
357
|
+
try {
|
|
358
|
+
const task = FS.createDownloadResumable(https(plan.url), path, { headers: plan.headers ?? {}, sessionType: FS.FileSystemSessionType.FOREGROUND }, progress => { if (progress.totalBytesWritten > media.byteSize)
|
|
359
|
+
void task.cancelAsync().catch(() => { }); });
|
|
360
|
+
const result = await cancellable(task, context, () => task.downloadAsync());
|
|
361
|
+
const info = await FS.getInfoAsync(path);
|
|
362
|
+
if (!result || result.status !== 200 || !info.exists || info.isDirectory || info.size !== media.byteSize)
|
|
363
|
+
throw Error('Attachment preview is incomplete.');
|
|
364
|
+
return { uri: path, mimeType: 'image/jpeg', fileName: 'preview.jpg', byteSize: media.byteSize };
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
await removeUri(path).catch(() => { });
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
},
|
|
345
371
|
async download(message, context) {
|
|
346
372
|
check(context.signal);
|
|
347
373
|
if (message.deleted)
|
|
@@ -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
|
+
};
|