@robono/messaging-react-native 0.0.0-stage → 0.1.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 +5 -0
- package/LICENSE +130 -0
- package/README.md +165 -2
- package/SBOM.spdx.json +1127 -0
- package/app.plugin.cjs +5 -0
- package/dist/call-message.d.ts +6 -0
- package/dist/call-message.js +109 -0
- package/dist/calling.d.ts +15 -0
- package/dist/calling.js +22 -0
- package/dist/expo-calling.d.ts +6 -0
- package/dist/expo-calling.js +7 -0
- package/dist/expo-media.d.ts +19 -0
- package/dist/expo-media.js +388 -0
- package/dist/expo.d.ts +36 -0
- package/dist/expo.js +429 -0
- package/dist/icons.d.ts +7 -0
- package/dist/icons.js +10 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +433 -0
- package/dist/media-controller.d.ts +98 -0
- package/dist/media-controller.js +257 -0
- package/dist/media-readiness.d.ts +4 -0
- package/dist/media-readiness.js +4 -0
- package/dist/media-ui.d.ts +51 -0
- package/dist/media-ui.js +42 -0
- package/dist/polls.d.ts +21 -0
- package/dist/polls.js +54 -0
- package/dist/recent-media.d.ts +21 -0
- package/dist/recent-media.js +122 -0
- package/dist/safety.d.ts +11 -0
- package/dist/safety.js +47 -0
- package/dist/use-quote-gesture.d.ts +24 -0
- package/dist/use-quote-gesture.js +127 -0
- package/dist/visual/RobonoIcon.d.ts +232 -0
- package/dist/visual/RobonoIcon.js +20 -0
- package/dist/visual/icon-nodes.d.ts +3 -0
- package/dist/visual/icon-nodes.js +2 -0
- package/dist/visual/icon-paths.d.ts +221 -0
- package/dist/visual/icon-paths.js +221 -0
- package/dist/voice-composer.d.ts +37 -0
- package/dist/voice-composer.js +20 -0
- package/licenses/robono-icons-lucide-license.txt +43 -0
- package/licenses/robono-icons-weather-attribution.txt +1 -0
- package/licenses/robono-icons-weather-license.txt +21 -0
- package/package.json +110 -4
- package/plugin/preserve-call-audio.cjs +22 -0
package/app.plugin.cjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
const {withDangerousMod,createRunOncePlugin} = require('expo/config-plugins');
|
|
2
|
+
module.exports=createRunOncePlugin(config=>withDangerousMod(config,['ios',async value=>{
|
|
3
|
+
require('./plugin/preserve-call-audio.cjs').install(value.modRequest.projectRoot);
|
|
4
|
+
return value;
|
|
5
|
+
}]),'@robono/messaging-react-native',require('./package.json').version);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Pressable, Text, View } from 'react-native';
|
|
4
|
+
import { useMessagingUI } from './index.js';
|
|
5
|
+
/** A new, reviewed message after no answer. Never records or sends a call automatically. */
|
|
6
|
+
export function UnansweredCallMessage({ callId, onDismiss }) {
|
|
7
|
+
const { client, design, state, pickAttachment, releaseAttachment, components } = useMessagingUI();
|
|
8
|
+
const [option, setOption] = useState(null);
|
|
9
|
+
const [draft, setDraft] = useState(null);
|
|
10
|
+
const [busy, setBusy] = useState(false), [error, setError] = useState(''), [attempt, setAttempt] = useState(0);
|
|
11
|
+
const generation = useRef(0), inFlight = useRef(false), prepared = useRef(null);
|
|
12
|
+
const release = useRef(releaseAttachment);
|
|
13
|
+
release.current = releaseAttachment;
|
|
14
|
+
function clearDraft() {
|
|
15
|
+
const media = prepared.current;
|
|
16
|
+
prepared.current = null;
|
|
17
|
+
setDraft(null);
|
|
18
|
+
if (media)
|
|
19
|
+
void release.current?.(media);
|
|
20
|
+
}
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
const id = ++generation.current;
|
|
23
|
+
clearDraft();
|
|
24
|
+
setOption(null);
|
|
25
|
+
setError('');
|
|
26
|
+
setBusy(false);
|
|
27
|
+
void client.unansweredCall(callId).then(o => { if (id === generation.current)
|
|
28
|
+
setOption(o); })
|
|
29
|
+
.catch(() => { if (id === generation.current)
|
|
30
|
+
setError('Could not check message availability.'); });
|
|
31
|
+
return () => { generation.current++; const media = prepared.current; prepared.current = null; if (media)
|
|
32
|
+
void release.current?.(media); };
|
|
33
|
+
}, [client, callId, state.conversationId, attempt]);
|
|
34
|
+
async function record() {
|
|
35
|
+
if (inFlight.current || !option || !pickAttachment)
|
|
36
|
+
return;
|
|
37
|
+
const id = generation.current, conversation = state.conversationId;
|
|
38
|
+
inFlight.current = true;
|
|
39
|
+
setBusy(true);
|
|
40
|
+
setError('');
|
|
41
|
+
try {
|
|
42
|
+
const current = await client.unansweredCall(callId);
|
|
43
|
+
if (id !== generation.current)
|
|
44
|
+
return;
|
|
45
|
+
if (!current || current.kind !== option.kind) {
|
|
46
|
+
clearDraft();
|
|
47
|
+
setOption(current);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const media = await pickAttachment(current.kind, { maxDurationMs: current.maxDurationMs, ...(current.kind === 'video' ? { source: 'call-video' } : {}), intent: 'attach' });
|
|
51
|
+
if (!media)
|
|
52
|
+
return;
|
|
53
|
+
if (id !== generation.current || client.getSnapshot().conversationId !== conversation) {
|
|
54
|
+
void release.current?.(media);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
clearDraft();
|
|
58
|
+
prepared.current = media;
|
|
59
|
+
setDraft({ kind: current.kind, media });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
if (id === generation.current)
|
|
63
|
+
setError('Could not prepare your message. Try again.');
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
inFlight.current = false;
|
|
67
|
+
if (id === generation.current)
|
|
68
|
+
setBusy(false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async function send() {
|
|
72
|
+
if (inFlight.current || !draft)
|
|
73
|
+
return;
|
|
74
|
+
inFlight.current = true;
|
|
75
|
+
setBusy(true);
|
|
76
|
+
setError('');
|
|
77
|
+
const id = generation.current;
|
|
78
|
+
try {
|
|
79
|
+
const current = await client.unansweredCall(callId);
|
|
80
|
+
if (id !== generation.current)
|
|
81
|
+
return;
|
|
82
|
+
if (!current || current.kind !== draft.kind) {
|
|
83
|
+
clearDraft();
|
|
84
|
+
setOption(current);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// The client now owns the stable retry identity. Unmounting only releases
|
|
88
|
+
// local preview resources, never the uploaded asset needed by a retry.
|
|
89
|
+
const work = client.send({ kind: draft.kind, media: draft.media, unansweredCallId: callId });
|
|
90
|
+
clearDraft();
|
|
91
|
+
onDismiss();
|
|
92
|
+
await work;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
if (id === generation.current)
|
|
96
|
+
setError('The message could not be sent. Check the conversation for a retry.');
|
|
97
|
+
}
|
|
98
|
+
finally {
|
|
99
|
+
inFlight.current = false;
|
|
100
|
+
if (id === generation.current)
|
|
101
|
+
setBusy(false);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (!pickAttachment || !state.settings?.enabled || state.conversation?.canSend === false || (!option && !error))
|
|
105
|
+
return null;
|
|
106
|
+
const c = design.colors, VoiceAttachment = components?.VoiceAttachment;
|
|
107
|
+
const action = (label, onPress) => _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: onPress, style: { paddingVertical: 12, paddingHorizontal: 4, minHeight: 44 }, children: _jsx(Text, { style: { color: c.incomingText, fontWeight: '600' }, children: label }) });
|
|
108
|
+
return _jsxs(View, { style: { backgroundColor: c.incoming, padding: 16, borderTopWidth: 1, borderTopColor: c.secondary + '30', gap: 6 }, children: [_jsx(Text, { style: { color: c.incomingText, fontSize: 16, fontWeight: '600' }, children: "No answer" }), option && _jsx(Text, { style: { color: c.incomingText, fontSize: 14 }, children: draft ? 'Review your message, then send it.' : `Leave a ${option.kind === 'voice' ? 'voice' : 'video'} message in this chat.` }), draft && _jsx(View, { children: draft.kind === 'voice' && VoiceAttachment ? _jsx(VoiceAttachment, { media: draft.media, appearance: design, disabled: busy, onRemove: clearDraft }) : _jsxs(Text, { style: { color: c.incomingText }, children: [draft.kind === 'voice' ? 'Voice' : 'Video', " message ready"] }) }), _jsxs(View, { style: { flexDirection: 'row', gap: 16, flexWrap: 'wrap' }, children: [option && (draft ? action('Send message', () => void send()) : action(`Record ${option.kind === 'voice' ? 'voice' : 'video'} message`, () => void record())), !option && error && action('Try again', () => setAttempt(value => value + 1)), action('Not now', () => { clearDraft(); onDismiss(); })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.incomingText }, children: error })] });
|
|
109
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type MessagingScreenProps } from './index.js';
|
|
3
|
+
export { UnansweredCallMessage } from './call-message.js';
|
|
4
|
+
/** Optional call entry in the standard message header. The host controls eligibility and admission. */
|
|
5
|
+
export declare function MessagingCallHeader(): React.JSX.Element;
|
|
6
|
+
export interface MessagingCallingScreenProps extends MessagingScreenProps {
|
|
7
|
+
/** Supply only when this contact is eligible. Never bypass server-side permissions. */
|
|
8
|
+
onCall?: () => void;
|
|
9
|
+
callLabel?: string;
|
|
10
|
+
onVideoCall?: () => void;
|
|
11
|
+
videoCallLabel?: string;
|
|
12
|
+
unansweredCallId?: string;
|
|
13
|
+
onDismissCallMessage?: () => void;
|
|
14
|
+
}
|
|
15
|
+
export declare function MessagingCallingScreen({ onCall, onVideoCall, videoCallLabel, callLabel, components, unansweredCallId, onDismissCallMessage, ...props }: MessagingCallingScreenProps): React.JSX.Element;
|
package/dist/calling.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useSyncExternalStore } from 'react';
|
|
3
|
+
import { Pressable, View, Text } from 'react-native';
|
|
4
|
+
import { RobonoIcon } from './visual/RobonoIcon.js';
|
|
5
|
+
import { MessagingScreen, MessagingProvider, MessagingHeader, MessagingComposer, useMessagingUI } from './index.js';
|
|
6
|
+
import { UnansweredCallMessage } from './call-message.js';
|
|
7
|
+
export { UnansweredCallMessage } from './call-message.js';
|
|
8
|
+
const Action = createContext(null);
|
|
9
|
+
const FollowUp = createContext(null);
|
|
10
|
+
function CallingMessageComposer() { const follow = useContext(FollowUp); return _jsxs(_Fragment, { children: [follow && _jsx(UnansweredCallMessage, { callId: follow.callId, onDismiss: follow.dismiss }), _jsx(MessagingComposer, {})] }); }
|
|
11
|
+
/** 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: 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 }) {
|
|
18
|
+
const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
|
|
19
|
+
const design = props.appearance ?? state.settings?.appearance;
|
|
20
|
+
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 } }) }) });
|
|
22
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type ExpoMessagingScreenProps } from './expo.js';
|
|
3
|
+
import { type MessagingCallingScreenProps } from './calling.js';
|
|
4
|
+
export type ExpoMessagingCallingScreenProps = Omit<ExpoMessagingScreenProps, 'renderConversation'> & Pick<MessagingCallingScreenProps, 'onCall' | 'callLabel' | 'onVideoCall' | 'videoCallLabel' | 'unansweredCallId' | 'onDismissCallMessage'>;
|
|
5
|
+
/** Optional composition of the shared conversation, calling entry and native recording/review tools. */
|
|
6
|
+
export declare function ExpoMessagingCallingScreen({ onCall, callLabel, onVideoCall, videoCallLabel, unansweredCallId, onDismissCallMessage, ...props }: ExpoMessagingCallingScreenProps): React.JSX.Element;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { ExpoMessagingScreen } from './expo.js';
|
|
3
|
+
import { MessagingCallingScreen } from './calling.js';
|
|
4
|
+
/** Optional composition of the shared conversation, calling entry and native recording/review tools. */
|
|
5
|
+
export function ExpoMessagingCallingScreen({ onCall, callLabel, onVideoCall, videoCallLabel, unansweredCallId, onDismissCallMessage, ...props }) {
|
|
6
|
+
return _jsx(ExpoMessagingScreen, { ...props, renderConversation: screen => _jsx(MessagingCallingScreen, { ...screen, onCall: onCall, callLabel: callLabel, onVideoCall: onVideoCall, videoCallLabel: videoCallLabel, unansweredCallId: unansweredCallId, onDismissCallMessage: onDismissCallMessage }) });
|
|
7
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type AudioMode } from 'expo-audio';
|
|
2
|
+
import type { LocalMessagingMedia, MessagingMediaDevice, MessagingMediaStorage } from './media-controller.js';
|
|
3
|
+
export interface MessagingAudioSession {
|
|
4
|
+
isAvailable?: (purpose?: 'recording' | 'playback') => boolean;
|
|
5
|
+
acquire(purpose: 'recording' | 'playback'): Promise<() => Promise<void>>;
|
|
6
|
+
}
|
|
7
|
+
/** Pass your app's call-state guard and restore mode, or supply your existing audio-session coordinator. */
|
|
8
|
+
export declare function createExpoMessagingAudioSession(options?: {
|
|
9
|
+
isCallActive?: () => boolean;
|
|
10
|
+
restoreMode?: Partial<AudioMode>;
|
|
11
|
+
}): MessagingAudioSession;
|
|
12
|
+
export declare function copyMessagingCache(file: LocalMessagingMedia): Promise<LocalMessagingMedia>;
|
|
13
|
+
export declare function createExpoMessagingDevice(audio: MessagingAudioSession): MessagingMediaDevice;
|
|
14
|
+
/** Private-storage transport. The endpoint is YOUR authenticated backend, never a Robono API key on the phone. */
|
|
15
|
+
export declare function createExpoMessagingStorage(options: {
|
|
16
|
+
url: string;
|
|
17
|
+
getAccessToken: () => Promise<string>;
|
|
18
|
+
streaming?: boolean;
|
|
19
|
+
}): MessagingMediaStorage;
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { AudioModule, RecordingPresets, createAudioPlayer, setAudioModeAsync } from 'expo-audio';
|
|
2
|
+
import * as Picker from 'expo-image-picker';
|
|
3
|
+
import * as Documents from 'expo-document-picker';
|
|
4
|
+
import * as FS from 'expo-file-system/legacy';
|
|
5
|
+
import * as Crypto from 'expo-crypto';
|
|
6
|
+
import { defaultAttachmentLimits } from '@robono/messaging';
|
|
7
|
+
let audioOwner = false;
|
|
8
|
+
/** Pass your app's call-state guard and restore mode, or supply your existing audio-session coordinator. */
|
|
9
|
+
export function createExpoMessagingAudioSession(options = {}) {
|
|
10
|
+
return { isAvailable: (purpose = 'playback') => purpose === 'playback' || !options.isCallActive?.(), async acquire(purpose) {
|
|
11
|
+
if (audioOwner)
|
|
12
|
+
throw Error('Finish the current recording first.');
|
|
13
|
+
if (purpose === 'recording' && options.isCallActive?.())
|
|
14
|
+
throw Error('Finish the current call before recording.');
|
|
15
|
+
audioOwner = true;
|
|
16
|
+
// The call owns its microphone, mode and output route. A playback lease
|
|
17
|
+
// borrows that session without changing any of them. Outside our call,
|
|
18
|
+
// mix rather than requesting exclusive focus from another calling app.
|
|
19
|
+
const borrowed = purpose === 'playback' && !!options.isCallActive?.();
|
|
20
|
+
try {
|
|
21
|
+
if (!borrowed)
|
|
22
|
+
await setAudioModeAsync({ allowsRecording: purpose === 'recording', playsInSilentMode: true, shouldPlayInBackground: false, shouldRouteThroughEarpiece: false, interruptionMode: purpose === 'recording' ? 'doNotMix' : 'mixWithOthers' });
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
audioOwner = false;
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
let released = false;
|
|
29
|
+
return async () => {
|
|
30
|
+
if (released)
|
|
31
|
+
return;
|
|
32
|
+
released = true;
|
|
33
|
+
try {
|
|
34
|
+
if (!borrowed && !options.isCallActive?.())
|
|
35
|
+
await setAudioModeAsync(options.restoreMode ?? { allowsRecording: false, playsInSilentMode: true, shouldPlayInBackground: false, shouldRouteThroughEarpiece: false, interruptionMode: 'mixWithOthers' });
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
audioOwner = false;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
} };
|
|
42
|
+
}
|
|
43
|
+
function check(signal) { if (signal.aborted)
|
|
44
|
+
throw Error('Cancelled.'); }
|
|
45
|
+
const cacheRoot = () => { if (!FS.cacheDirectory)
|
|
46
|
+
throw Error('Private device storage is unavailable.'); return FS.cacheDirectory + 'robono-messaging/'; };
|
|
47
|
+
const ownedDirectories = new Set();
|
|
48
|
+
async function cacheDirectory() { const dir = cacheRoot() + Crypto.randomUUID() + '/'; await FS.makeDirectoryAsync(dir, { intermediates: true }); ownedDirectories.add(dir); return dir; }
|
|
49
|
+
const cacheName = (name) => name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(-90) || 'Attachment';
|
|
50
|
+
async function cachePath(name) { return (await cacheDirectory()) + cacheName(name); }
|
|
51
|
+
async function removeUri(uri) {
|
|
52
|
+
if (!uri.startsWith(cacheRoot()))
|
|
53
|
+
return;
|
|
54
|
+
const dir = uri.slice(0, uri.lastIndexOf('/') + 1);
|
|
55
|
+
// Each owned directory contains exactly one private copy. Remove it in one
|
|
56
|
+
// native operation rather than making the next open wait for two deletions.
|
|
57
|
+
if (ownedDirectories.has(dir)) {
|
|
58
|
+
await FS.deleteAsync(dir, { idempotent: true });
|
|
59
|
+
ownedDirectories.delete(dir);
|
|
60
|
+
}
|
|
61
|
+
else
|
|
62
|
+
await FS.deleteAsync(uri, { idempotent: true });
|
|
63
|
+
}
|
|
64
|
+
async function remove(file) { await removeUri(file.uri); }
|
|
65
|
+
async function pickedCopy(uri, fileName, mimeType, durationMs) {
|
|
66
|
+
try {
|
|
67
|
+
return await copied(uri, fileName, mimeType, durationMs);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
if (FS.cacheDirectory && uri.startsWith(FS.cacheDirectory))
|
|
71
|
+
await FS.deleteAsync(uri, { idempotent: true }).catch(() => { });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function copyMessagingCache(file) { return copied(file.uri, file.fileName, file.mimeType, file.durationMs); }
|
|
75
|
+
async function copied(uri, fileName, mimeType, durationMs) {
|
|
76
|
+
const target = await cachePath(fileName);
|
|
77
|
+
try {
|
|
78
|
+
await FS.copyAsync({ from: uri, to: target });
|
|
79
|
+
const info = await FS.getInfoAsync(target);
|
|
80
|
+
if (!info.exists || info.isDirectory)
|
|
81
|
+
throw Error('The file could not be read.');
|
|
82
|
+
return { uri: target, fileName, mimeType, byteSize: info.size, ...(durationMs === undefined ? {} : { durationMs: Math.ceil(durationMs) }) };
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
await removeUri(target).catch(() => { });
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function recordingDuration(uri) {
|
|
90
|
+
// Android resets recorder status to zero after automatic stop. Read the finished file.
|
|
91
|
+
const player = createAudioPlayer(uri);
|
|
92
|
+
try {
|
|
93
|
+
const deadline = Date.now() + 5000;
|
|
94
|
+
while (!player.isLoaded && Date.now() < deadline)
|
|
95
|
+
await new Promise(r => setTimeout(r, 40));
|
|
96
|
+
const ms = Math.ceil(player.duration * 1000);
|
|
97
|
+
if (!player.isLoaded || !Number.isFinite(ms) || ms < 1)
|
|
98
|
+
throw Error('The recording length could not be read. Record again.');
|
|
99
|
+
return ms;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
player.remove();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const extensionMimes = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', heic: 'image/heic', heif: 'image/heif', avif: 'image/avif', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', '3gp': 'video/3gpp', pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', zip: 'application/zip', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' };
|
|
106
|
+
function mime(value, name) { return value?.toLowerCase() || extensionMimes[name.split('.').at(-1)?.toLowerCase() ?? ''] || 'application/octet-stream'; }
|
|
107
|
+
export function createExpoMessagingDevice(audio) {
|
|
108
|
+
let recorder = null, release = null;
|
|
109
|
+
let recordingDeadline = 0;
|
|
110
|
+
let starting = null;
|
|
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];
|
|
119
|
+
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(() => { });
|
|
125
|
+
check(signal);
|
|
126
|
+
}
|
|
127
|
+
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));
|
|
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.');
|
|
139
|
+
}
|
|
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
|
+
},
|
|
161
|
+
async startRecording(maximum, signal) {
|
|
162
|
+
if (starting || recorder)
|
|
163
|
+
throw Error('A recording is already in progress.');
|
|
164
|
+
const work = (async () => {
|
|
165
|
+
const permission = await AudioModule.requestRecordingPermissionsAsync();
|
|
166
|
+
check(signal);
|
|
167
|
+
if (!permission.granted)
|
|
168
|
+
throw Error('Microphone permission is needed to record a voice message.');
|
|
169
|
+
release = await audio.acquire('recording');
|
|
170
|
+
try {
|
|
171
|
+
check(signal);
|
|
172
|
+
recorder = new AudioModule.AudioRecorder({ ...RecordingPresets.HIGH_QUALITY, isMeteringEnabled: true });
|
|
173
|
+
await recorder.prepareToRecordAsync({ ...RecordingPresets.HIGH_QUALITY, isMeteringEnabled: true });
|
|
174
|
+
check(signal); // Leave room for AAC encoder/container padding; the backend checks final file duration.
|
|
175
|
+
const captureMs = Math.max(100, maximum - 100);
|
|
176
|
+
recorder.record({ forDuration: captureMs / 1000 });
|
|
177
|
+
recordingDeadline = Date.now() + captureMs;
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
if (recorder) {
|
|
181
|
+
await recorder.stop().catch(() => { });
|
|
182
|
+
const uri = recorder.uri;
|
|
183
|
+
recorder.release();
|
|
184
|
+
recorder = null;
|
|
185
|
+
if (uri?.startsWith(FS.cacheDirectory ?? 'file://unavailable/'))
|
|
186
|
+
await FS.deleteAsync(uri, { idempotent: true }).catch(() => { });
|
|
187
|
+
}
|
|
188
|
+
await release?.();
|
|
189
|
+
release = null;
|
|
190
|
+
throw e;
|
|
191
|
+
}
|
|
192
|
+
})();
|
|
193
|
+
starting = work;
|
|
194
|
+
try {
|
|
195
|
+
await work;
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
if (starting === work)
|
|
199
|
+
starting = null;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
recordingStatus() { const state = recorder?.getStatus(); return { durationMs: state?.durationMillis ?? 0, level: typeof state?.metering === 'number' ? Math.pow(10, state.metering / 40) : undefined, recording: !!state?.isRecording && audio.isAvailable?.('recording') !== false, limitReached: !state?.isRecording && Date.now() >= recordingDeadline }; },
|
|
203
|
+
async stopRecording() {
|
|
204
|
+
if (stopping)
|
|
205
|
+
return stopping;
|
|
206
|
+
const work = (async () => {
|
|
207
|
+
if (starting)
|
|
208
|
+
await starting.catch(() => { });
|
|
209
|
+
const current = recorder;
|
|
210
|
+
recorder = null;
|
|
211
|
+
if (!current)
|
|
212
|
+
return null;
|
|
213
|
+
let original = null;
|
|
214
|
+
try {
|
|
215
|
+
const before = current.getStatus();
|
|
216
|
+
if (before.isRecording)
|
|
217
|
+
await current.stop();
|
|
218
|
+
original = current.uri;
|
|
219
|
+
if (!original)
|
|
220
|
+
return null;
|
|
221
|
+
const duration = await recordingDuration(original);
|
|
222
|
+
return await copied(original, 'Voice message.m4a', 'audio/mp4', duration);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
current.release();
|
|
226
|
+
if (original?.startsWith(FS.cacheDirectory ?? 'file://unavailable/'))
|
|
227
|
+
await FS.deleteAsync(original, { idempotent: true }).catch(() => { });
|
|
228
|
+
await release?.();
|
|
229
|
+
release = null;
|
|
230
|
+
}
|
|
231
|
+
})();
|
|
232
|
+
stopping = work;
|
|
233
|
+
try {
|
|
234
|
+
return await work;
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
if (stopping === work)
|
|
238
|
+
stopping = null;
|
|
239
|
+
}
|
|
240
|
+
}, remove,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function https(value) { const url = new URL(value); if (url.protocol !== 'https:' || url.username || url.password)
|
|
244
|
+
throw Error('A secure media URL is required.'); return url.href; }
|
|
245
|
+
/** Private-storage transport. The endpoint is YOUR authenticated backend, never a Robono API key on the phone. */
|
|
246
|
+
export function createExpoMessagingStorage(options) {
|
|
247
|
+
const endpoint = https(options.url);
|
|
248
|
+
async function request(operation, input, signal) {
|
|
249
|
+
const token = await options.getAccessToken();
|
|
250
|
+
if (!token)
|
|
251
|
+
throw Error('Sign in to continue.');
|
|
252
|
+
const bounded = new AbortController();
|
|
253
|
+
const cancel = () => bounded.abort();
|
|
254
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
255
|
+
if (signal?.aborted)
|
|
256
|
+
bounded.abort();
|
|
257
|
+
const timeout = setTimeout(cancel, operation === 'complete' ? 120000 : 15000);
|
|
258
|
+
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 }) });
|
|
260
|
+
const result = await response.json();
|
|
261
|
+
if (!response.ok) {
|
|
262
|
+
const error = Object.assign(Error(result?.error?.message ?? 'The file could not be transferred. Try again.'), { status: response.status });
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
clearTimeout(timeout);
|
|
269
|
+
signal?.removeEventListener('abort', cancel);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async function playbackPlan(message, signal) {
|
|
273
|
+
check(signal);
|
|
274
|
+
if (message.deleted)
|
|
275
|
+
throw Error('This attachment is no longer available.');
|
|
276
|
+
const grant = message.attachmentDownload;
|
|
277
|
+
const granted = grant && grant.messageId === message.id && grant.conversationId === message.conversationId && grant.assetId === message.media?.assetId && Number.isFinite(grant.expiresAt) && grant.expiresAt > Date.now() + 5000 && grant.expiresAt <= Date.now() + 60000;
|
|
278
|
+
const plan = granted ? { url: grant.url, media: message.media, headers: undefined } : await request('download', { conversationId: message.conversationId, messageId: message.id }, signal);
|
|
279
|
+
check(signal);
|
|
280
|
+
const media = plan.media;
|
|
281
|
+
if (!media || media.assetId !== message.media?.assetId || !Number.isSafeInteger(media.byteSize) || media.byteSize < 1 || media.byteSize > 104857600)
|
|
282
|
+
throw Error('Invalid attachment size or identity.');
|
|
283
|
+
return { ...plan, url: https(plan.url), media };
|
|
284
|
+
}
|
|
285
|
+
async function cancellable(task, context, run) {
|
|
286
|
+
check(context.signal);
|
|
287
|
+
const cancel = () => { void task.cancelAsync().catch(() => { }); };
|
|
288
|
+
context.signal.addEventListener('abort', cancel, { once: true });
|
|
289
|
+
try {
|
|
290
|
+
const result = await run();
|
|
291
|
+
check(context.signal);
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
context.signal.removeEventListener('abort', cancel);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
async stream(message, context) {
|
|
300
|
+
check(context.signal);
|
|
301
|
+
if (message.deleted)
|
|
302
|
+
throw Error('This attachment is no longer available.');
|
|
303
|
+
if (options.streaming === false || !['voice', 'video'].includes(message.kind) || !message.media?.assetId.startsWith('robono-r2:'))
|
|
304
|
+
return null;
|
|
305
|
+
const plan = await playbackPlan(message, context.signal);
|
|
306
|
+
// The native player requests byte ranges directly from the same private,
|
|
307
|
+
// metered gateway. No file copy or thumbnail precedes playback.
|
|
308
|
+
const { assetId, ...media } = plan.media;
|
|
309
|
+
return { ...media, uri: plan.url, stream: { headers: plan.headers } };
|
|
310
|
+
},
|
|
311
|
+
async upload(file, context) {
|
|
312
|
+
const { uri, ...metadata } = file;
|
|
313
|
+
const plan = await request('prepare', { uploadId: context.uploadId, conversationId: context.conversationId, kind: context.kind, file: metadata }, context.signal);
|
|
314
|
+
check(context.signal);
|
|
315
|
+
if (plan.media)
|
|
316
|
+
return plan.media;
|
|
317
|
+
const task = FS.createUploadTask(https(plan.url), uri, { httpMethod: 'PUT', uploadType: FS.FileSystemUploadType.BINARY_CONTENT, headers: plan.headers ?? {}, sessionType: FS.FileSystemSessionType.FOREGROUND }, p => context.onProgress(p.totalBytesExpectedToSend > 0 ? p.totalBytesSent / p.totalBytesExpectedToSend : 0));
|
|
318
|
+
const result = await cancellable(task, context, () => task.uploadAsync());
|
|
319
|
+
// An immutable PUT may already have succeeded before its response was lost.
|
|
320
|
+
// A 412 is not success by itself: complete still verifies ownership and bytes.
|
|
321
|
+
if (!result || result.status !== 412 && (result.status < 200 || result.status >= 300))
|
|
322
|
+
throw Error('Upload failed. Try again.');
|
|
323
|
+
// Verification is repeatable for the same upload. A transient inspector
|
|
324
|
+
// failure must not upload the bytes again or create a second attachment.
|
|
325
|
+
for (let attempt = 0;; attempt++) {
|
|
326
|
+
check(context.signal);
|
|
327
|
+
try {
|
|
328
|
+
const complete = await request('complete', { uploadId: context.uploadId, conversationId: context.conversationId }, context.signal);
|
|
329
|
+
return complete.media;
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
if (attempt >= 2 || ![502, 503, 504].includes(error.status ?? 0))
|
|
333
|
+
throw error;
|
|
334
|
+
check(context.signal);
|
|
335
|
+
await new Promise((resolve, reject) => {
|
|
336
|
+
const cancel = () => { clearTimeout(timer); context.signal.removeEventListener('abort', cancel); reject(Error('Cancelled')); };
|
|
337
|
+
const timer = setTimeout(() => { context.signal.removeEventListener('abort', cancel); resolve(); }, 1000 * (attempt + 1));
|
|
338
|
+
context.signal.addEventListener('abort', cancel, { once: true });
|
|
339
|
+
if (context.signal.aborted)
|
|
340
|
+
cancel();
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
async download(message, context) {
|
|
346
|
+
check(context.signal);
|
|
347
|
+
if (message.deleted)
|
|
348
|
+
throw Error('This attachment is no longer available.');
|
|
349
|
+
// Local directory preparation needs no media access. Run it alongside
|
|
350
|
+
// authorization, but never start the transfer until both have succeeded.
|
|
351
|
+
const [authorized, prepared] = await Promise.allSettled([
|
|
352
|
+
playbackPlan(message, context.signal),
|
|
353
|
+
cacheDirectory(),
|
|
354
|
+
]);
|
|
355
|
+
const dir = prepared.status === 'fulfilled' ? prepared.value : null;
|
|
356
|
+
let path = null;
|
|
357
|
+
try {
|
|
358
|
+
if (authorized.status === 'rejected')
|
|
359
|
+
throw authorized.reason;
|
|
360
|
+
if (prepared.status === 'rejected')
|
|
361
|
+
throw prepared.reason;
|
|
362
|
+
check(context.signal);
|
|
363
|
+
const plan = authorized.value, media = plan.media;
|
|
364
|
+
if (!media || !Number.isSafeInteger(media.byteSize) || media.byteSize < 1 || media.byteSize > 104857600)
|
|
365
|
+
throw Error('Invalid attachment size.');
|
|
366
|
+
path = prepared.value + cacheName(media.fileName);
|
|
367
|
+
const task = FS.createDownloadResumable(https(plan.url), path, { headers: plan.headers ?? {}, sessionType: FS.FileSystemSessionType.FOREGROUND }, p => {
|
|
368
|
+
if (p.totalBytesWritten > media.byteSize)
|
|
369
|
+
void task.cancelAsync().catch(() => { });
|
|
370
|
+
context.onProgress(p.totalBytesWritten / media.byteSize);
|
|
371
|
+
});
|
|
372
|
+
const result = await cancellable(task, context, () => task.downloadAsync());
|
|
373
|
+
if (!result || result.status !== 200)
|
|
374
|
+
throw Error('Download failed. Try again.');
|
|
375
|
+
const info = await FS.getInfoAsync(path);
|
|
376
|
+
if (!info.exists || info.isDirectory || info.size !== media.byteSize)
|
|
377
|
+
throw Error('The attachment could not be verified.');
|
|
378
|
+
return { uri: path, mimeType: media.mimeType, fileName: media.fileName, byteSize: media.byteSize, ...(media.durationMs ? { durationMs: media.durationMs } : {}) };
|
|
379
|
+
}
|
|
380
|
+
catch (e) {
|
|
381
|
+
if (dir)
|
|
382
|
+
await removeUri(path ?? dir + 'Attachment').catch(() => { });
|
|
383
|
+
throw e;
|
|
384
|
+
}
|
|
385
|
+
},
|
|
386
|
+
async discard(uploadId) { await request('discard', { uploadId }); },
|
|
387
|
+
};
|
|
388
|
+
}
|
package/dist/expo.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type MessagingAppearance } from '@robono/messaging';
|
|
3
|
+
import { type MessagingScreenProps } from './index.js';
|
|
4
|
+
import { MessagingMediaController, type LocalMessagingMedia, type MessagingMediaStorage } from './media-controller.js';
|
|
5
|
+
import { type MediaReadyStage } from './media-readiness.js';
|
|
6
|
+
import { type MessagingAudioSession } from './expo-media.js';
|
|
7
|
+
export { createExpoMessagingAudioSession, createExpoMessagingDevice, createExpoMessagingStorage } from './expo-media.js';
|
|
8
|
+
export type { MessagingAudioSession } from './expo-media.js';
|
|
9
|
+
export type { LocalMessagingMedia, MessagingMediaStorage, MediaTransferOptions } from './media-controller.js';
|
|
10
|
+
export { MessagingMediaController } from './media-controller.js';
|
|
11
|
+
export declare function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice, inlineRecorder }: {
|
|
12
|
+
controller: MessagingMediaController;
|
|
13
|
+
audio: MessagingAudioSession;
|
|
14
|
+
appearance: MessagingAppearance;
|
|
15
|
+
inlineVoice?: boolean;
|
|
16
|
+
inlineRecorder?: boolean;
|
|
17
|
+
}): React.JSX.Element | null;
|
|
18
|
+
export interface ExpoMessagingScreenProps extends Omit<MessagingScreenProps, 'pickAttachment' | 'openAttachment'> {
|
|
19
|
+
storage: MessagingMediaStorage;
|
|
20
|
+
audioSession: MessagingAudioSession;
|
|
21
|
+
/** Optional timing callback from native image/frame/playback readiness, measured from opening. */
|
|
22
|
+
onMediaReady?: (event: {
|
|
23
|
+
messageId: string;
|
|
24
|
+
kind: string;
|
|
25
|
+
stage: MediaReadyStage;
|
|
26
|
+
elapsedMs: number;
|
|
27
|
+
}) => void;
|
|
28
|
+
/** Preload a bounded set of small recent attachments while this conversation is open. Default true. */
|
|
29
|
+
preloadRecentMedia?: boolean;
|
|
30
|
+
/** Compose a conversation with the supplied media adapters. */
|
|
31
|
+
renderConversation?: (props: MessagingScreenProps) => React.ReactNode;
|
|
32
|
+
/** Resolve an authorized recent attachment to an adapter-owned cache copy, never a library original. */
|
|
33
|
+
resolveAttachmentChoice?: (id: string, signal: AbortSignal) => Promise<LocalMessagingMedia | null>;
|
|
34
|
+
}
|
|
35
|
+
/** Optional ready-made recording, preview, upload and download tools for both mobile platforms. */
|
|
36
|
+
export declare function ExpoMessagingScreen({ storage, audioSession, renderConversation, preloadRecentMedia, ...props }: ExpoMessagingScreenProps): React.JSX.Element;
|