@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,557 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { MessagingPopupHeader, messagingPopupBackdrop } from './popup.js';
3
+ import { ExpoMessagingPrivacyLayer } from './expo-privacy.js';
4
+ import { ExpoSocialPlayer } from './expo-social.js';
5
+ import { expoOutbox } from './expo-outbox.js';
6
+ import { expoSaved } from './expo-saved.js';
7
+ export { createExpoMessagingClient } from './expo-outbox.js';
8
+ import { createExpoThumbnails } from './expo-thumbnails.js';
9
+ import { useCallback, useEffect, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
10
+ import { AppState, Image, Modal, Platform, Pressable, ScrollView, Text, View } from 'react-native';
11
+ import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
12
+ import MaterialIcons from "./visual/RobonoIcon";
13
+ import { useAudioPlayer } from 'expo-audio';
14
+ import { useVideoPlayer } from 'expo-video';
15
+ import { MessengerVideoView } from './messenger-video-view.js';
16
+ import * as Sharing from 'expo-sharing';
17
+ import { saveMessagingAttachment } from './expo-download.js';
18
+ import * as Crypto from 'expo-crypto';
19
+ import * as Clipboard from 'expo-clipboard';
20
+ import { defaultMessagingSettings } from '@robono/messaging';
21
+ import { MessagingScreen, useMessagingUI } from './index.js';
22
+ import { VoiceMessageComposer, VoiceDraftPlayback } from './voice-composer.js';
23
+ import { VoicePlayback, MediaViewer } from './media-ui.js';
24
+ import { MessagingMediaController } from './media-controller.js';
25
+ import { MediaReadiness, StreamRecovery } from './media-readiness.js';
26
+ import { RecentMessagingMedia } from './recent-media.js';
27
+ import { copyMessagingCache, createExpoMessagingDevice } from './expo-media.js';
28
+ export { createExpoMessagingAudioSession, createExpoMessagingDevice, createExpoMessagingStorage } from './expo-media.js';
29
+ export { MessagingMediaController } from './media-controller.js';
30
+ const size = (bytes) => bytes < 1024 ? `${bytes} bytes` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(2)} MB`;
31
+ const time = (ms) => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
32
+ function Control({ label, icon, onPress, disabled = false, color }) {
33
+ return _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: label, disabled: disabled, onPress: onPress, style: { minHeight: 48, padding: 12, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 8, opacity: disabled ? .45 : 1 }, children: [_jsx(MaterialIcons, { name: icon, size: 24, color: color }), _jsx(Text, { style: { color, fontSize: 16 }, children: label })] });
34
+ }
35
+ const voiceStops = new Set();
36
+ const stopVoicePlayers = async (except) => { await Promise.all([...voiceStops].filter(stop => stop !== except).map(stop => stop())); };
37
+ const voiceScope = () => ({ alive: true, busy: false, epoch: 0, release: null, acquiring: null, releasing: Promise.resolve() });
38
+ function releaseVoice(scope) {
39
+ const release = scope.release;
40
+ scope.release = null;
41
+ if (release)
42
+ scope.releasing = scope.releasing.then(release).catch(() => { });
43
+ return scope.releasing;
44
+ }
45
+ async function acquireVoice(scope, audio) {
46
+ const epoch = scope.epoch;
47
+ const work = (async () => {
48
+ await scope.releasing;
49
+ if (!scope.alive || scope.epoch !== epoch)
50
+ return;
51
+ const release = await audio.acquire('playback');
52
+ if (!scope.alive || scope.epoch !== epoch) {
53
+ await release();
54
+ return;
55
+ }
56
+ scope.release = release;
57
+ })();
58
+ scope.acquiring = work;
59
+ try {
60
+ await work;
61
+ }
62
+ finally {
63
+ if (scope.acquiring === work)
64
+ scope.acquiring = null;
65
+ }
66
+ return scope.alive && scope.epoch === epoch && !!scope.release;
67
+ }
68
+ function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
69
+ const [playing, setPlaying] = useState(false), [position, setPosition] = useState(0), [error, setError] = useState('');
70
+ const source = useMemo(() => file.stream ? { uri: file.uri, headers: file.stream.headers } : file.uri, [file]);
71
+ const player = useAudioPlayer(source, { updateInterval: 100, downloadFirst: false, preferredForwardBufferDuration: 5, keepAudioSessionActive: true });
72
+ const mediaReady = useContext(MediaReadiness), recover = useContext(StreamRecovery);
73
+ const recovered = useRef(false), lastPosition = useRef(file.resumeAtMs ?? 0), playIntent = useRef(file.autoPlay ?? autoPlay);
74
+ const [loaded, setLoaded] = useState(false);
75
+ autoPlay = file.autoPlay ?? autoPlay;
76
+ const current = useRef(voiceScope());
77
+ const playbackDisabled = useRef(false);
78
+ playbackDisabled.current = !!draft?.disabled;
79
+ useEffect(() => { if (draft?.disabled) {
80
+ player.pause();
81
+ current.current.epoch++;
82
+ void releaseVoice(current.current);
83
+ } }, [draft?.disabled, player]);
84
+ const width = useRef(1);
85
+ const c = design.colors;
86
+ const recoverStream = () => {
87
+ if (!file.stream || recovered.current || !current.current.alive)
88
+ return false;
89
+ recovered.current = true;
90
+ const resume = lastPosition.current, wanted = playIntent.current;
91
+ player.pause();
92
+ const scope = current.current;
93
+ scope.epoch++;
94
+ void releaseVoice(scope).then(() => { if (scope.alive)
95
+ recover(file.uri, resume, wanted); });
96
+ return true;
97
+ };
98
+ useEffect(() => {
99
+ const scope = voiceScope();
100
+ current.current = scope;
101
+ let started = false, reported = false, cancelled = false, autoPreparing = autoPlay;
102
+ let startupTimeout;
103
+ const stop = async () => { scope.epoch++; cancelled = true; if (autoPreparing) {
104
+ autoPreparing = false;
105
+ scope.busy = false;
106
+ } clearTimeout(startupTimeout); playIntent.current = false; try {
107
+ player.pause();
108
+ }
109
+ catch { } await releaseVoice(scope); await scope.acquiring?.catch(() => { }); await releaseVoice(scope); };
110
+ const fail = (e) => { cancelled = true; autoPreparing = false; scope.busy = false; clearTimeout(startupTimeout); if (scope.alive)
111
+ setError(e instanceof Error ? e.message : 'Playback failed.'); scope.epoch++; void releaseVoice(scope); };
112
+ // Native file loading and audio-session preparation are independent. Start
113
+ // both now; playback still requires both and an active app.
114
+ const begin = () => {
115
+ if (!scope.alive || recovered.current || !autoPlay || started || cancelled || !scope.release || !player.isLoaded)
116
+ return;
117
+ if (AppState.currentState !== 'active' || audio.isAvailable?.() === false) {
118
+ void stop();
119
+ return;
120
+ }
121
+ started = true;
122
+ autoPreparing = false;
123
+ clearTimeout(startupTimeout);
124
+ void (async () => { if (file.resumeAtMs)
125
+ await player.seekTo(file.resumeAtMs / 1000); if (!scope.alive || cancelled || AppState.currentState !== 'active')
126
+ return; playIntent.current = true; player.play(); setPlaying(player.playing); })().catch(e => { if (!recoverStream())
127
+ fail(e); }).finally(() => { scope.busy = false; });
128
+ };
129
+ const restorePaused = () => { if (!scope.alive || !player.isLoaded || autoPlay || started || !file.resumeAtMs)
130
+ return; started = true; void player.seekTo(file.resumeAtMs / 1000).then(() => { if (scope.alive)
131
+ setPosition(file.resumeAtMs); }).catch(() => { if (!recoverStream())
132
+ fail(Error('Could not resume the recording.')); }); };
133
+ setLoaded(player.isLoaded);
134
+ restorePaused();
135
+ const subscription = player.addListener('playbackStatusUpdate', status => { if (!scope.alive)
136
+ return; setLoaded(status.isLoaded); if (status.error) {
137
+ if (!recoverStream())
138
+ fail(Error('The recording could not be played. Try again.'));
139
+ return;
140
+ } restorePaused(); begin(); });
141
+ voiceStops.add(stop);
142
+ if (autoPlay) {
143
+ scope.busy = true;
144
+ startupTimeout = setTimeout(() => { if (!recoverStream())
145
+ fail(Error('The recording could not start. Try again.')); }, 15000);
146
+ void (async () => {
147
+ await stopVoicePlayers(stop);
148
+ if (!scope.alive || cancelled || AppState.currentState !== 'active') {
149
+ cancelled = true;
150
+ return;
151
+ }
152
+ if (!await acquireVoice(scope, audio))
153
+ return;
154
+ if (!scope.alive || recovered.current || cancelled || AppState.currentState !== 'active' || audio.isAvailable?.() === false) {
155
+ cancelled = true;
156
+ await releaseVoice(scope);
157
+ return;
158
+ }
159
+ begin();
160
+ })().catch(fail).finally(() => { if (cancelled || !scope.alive) {
161
+ scope.busy = false;
162
+ clearTimeout(startupTimeout);
163
+ } });
164
+ }
165
+ const timer = setInterval(() => {
166
+ if (!scope.alive)
167
+ return;
168
+ if (audio.isAvailable?.() === false)
169
+ void stop();
170
+ if (player.currentTime > 0)
171
+ lastPosition.current = player.currentTime * 1000;
172
+ setPosition(player.currentTime * 1000);
173
+ setPlaying(player.playing);
174
+ if (!reported && player.playing && player.currentTime > 0) {
175
+ reported = true;
176
+ mediaReady('audio-playing', file.uri);
177
+ }
178
+ if (!player.playing && player.duration > 0 && player.currentTime >= player.duration - .05 && scope.release && !scope.busy) {
179
+ playIntent.current = false;
180
+ void releaseVoice(scope);
181
+ }
182
+ }, 150);
183
+ const app = AppState.addEventListener('change', state => { if (state !== 'active')
184
+ void stop(); });
185
+ return () => { scope.alive = false; cancelled = true; subscription.remove(); clearTimeout(startupTimeout); clearInterval(timer); app.remove(); void stop().finally(() => voiceStops.delete(stop)); };
186
+ }, [player]);
187
+ async function toggle() {
188
+ const scope = current.current;
189
+ if (scope.busy)
190
+ return;
191
+ scope.busy = true;
192
+ setError('');
193
+ try {
194
+ if (player.playing) {
195
+ playIntent.current = false;
196
+ player.pause();
197
+ scope.epoch++;
198
+ await releaseVoice(scope);
199
+ }
200
+ else {
201
+ if (!player.isLoaded)
202
+ throw Error('The recording is still loading. Try again.');
203
+ await stopVoicePlayers();
204
+ if (!await acquireVoice(scope, audio))
205
+ return;
206
+ if (!scope.alive || playbackDisabled.current || AppState.currentState !== 'active') {
207
+ await releaseVoice(scope);
208
+ return;
209
+ }
210
+ if (player.currentTime >= player.duration - .05)
211
+ await player.seekTo(0);
212
+ playIntent.current = true;
213
+ player.play();
214
+ }
215
+ }
216
+ catch (e) {
217
+ if (scope.alive)
218
+ setError(e instanceof Error ? e.message : 'Playback failed.');
219
+ await releaseVoice(scope);
220
+ }
221
+ finally {
222
+ scope.busy = false;
223
+ if (scope.alive)
224
+ setPlaying(player.playing);
225
+ }
226
+ }
227
+ const duration = file.durationMs ?? player.duration * 1000;
228
+ if (draft)
229
+ return _jsx(VoiceDraftPlayback, { compact: draft.compact, appearance: design, playing: playing, positionMs: position, durationMs: duration, waveform: draft.waveform, onToggle: () => void toggle(), onRemove: draft.onRemove, disabled: draft.disabled, error: error });
230
+ return _jsx(VoicePlayback, { mine: inline?.mine, heard: inline?.heard, onPlayed: inline?.onPlayed, background: inline?.background, ink: inline?.ink ?? c.foreground, accent: inline?.accent ?? c.accent, waveform: inline?.waveform, showWaveform: inline?.showWaveform, footer: inline?.footer, onLongPress: inline?.onLongPress, durationMs: duration, positionMs: position, playing: playing, loading: !!file.stream && !loaded && !error, error: error, onToggle: () => void toggle(), onSeek: ms => { lastPosition.current = Math.max(0, Math.min(duration, ms)); void player.seekTo(lastPosition.current / 1000).catch(() => { if (!recoverStream())
231
+ setError('Could not seek. Try again.'); }); } });
232
+ }
233
+ function VideoPlayer({ file, audio }) {
234
+ const mediaReady = useContext(MediaReadiness), recover = useContext(StreamRecovery);
235
+ const source = useMemo(() => file.stream ? { uri: file.uri, headers: file.stream.headers, contentType: 'progressive', useCaching: false } : file.uri, [file]);
236
+ const player = useVideoPlayer(source, p => { p.loop = false; p.audioMixingMode = 'mixWithOthers'; if (file.stream)
237
+ p.bufferOptions = { preferredForwardBufferDuration: 5, minBufferForPlayback: 0.5 }; });
238
+ const [ready, setReady] = useState(false), [error, setError] = useState('');
239
+ const desired = useRef(file.autoPlay ?? true), lastPosition = useRef(file.resumeAtMs ?? 0);
240
+ useEffect(() => {
241
+ let alive = true, recovered = false, audioReady = false, resumed = false, release;
242
+ const recoverStream = () => {
243
+ if (!alive || recovered || !file.stream)
244
+ return false;
245
+ recovered = true;
246
+ player.pause();
247
+ const done = release;
248
+ release = undefined;
249
+ void Promise.resolve(done?.()).catch(() => { }).then(() => { if (alive)
250
+ recover(file.uri, lastPosition.current, desired.current); });
251
+ return true;
252
+ };
253
+ const begin = () => { if (!alive || recovered || !audioReady || resumed || player.status !== 'readyToPlay')
254
+ return; resumed = true; if (file.resumeAtMs)
255
+ player.currentTime = file.resumeAtMs / 1000; if (desired.current && AppState.currentState === 'active' && audio.isAvailable?.() !== false)
256
+ player.play(); };
257
+ void audio.acquire('playback').then(r => { if (!alive || recovered) {
258
+ void r().catch(() => { });
259
+ return;
260
+ } release = r; audioReady = true; setReady(true); begin(); }).catch(e => { if (alive)
261
+ setError(e.message); });
262
+ let consumed = false;
263
+ const timer = setInterval(() => { if (!alive)
264
+ return; if (!consumed && player.playing && player.currentTime > 0) {
265
+ consumed = true;
266
+ mediaReady('video-playing', file.uri);
267
+ } if (player.currentTime > 0)
268
+ lastPosition.current = player.currentTime * 1000; if (audio.isAvailable?.() === false) {
269
+ desired.current = false;
270
+ player.pause();
271
+ setReady(false);
272
+ setError('Playback paused for a call. Close and reopen the video to continue.');
273
+ void release?.().catch(() => { });
274
+ release = undefined;
275
+ } }, 100);
276
+ // Native controls must retain resume intent and respect the host's call audio session.
277
+ const playback = player.addListener('playingChange', ({ isPlaying }) => {
278
+ if (recovered || !alive)
279
+ return;
280
+ if (isPlaying && (!audioReady || !release || audio.isAvailable?.() === false || AppState.currentState !== 'active')) {
281
+ player.pause();
282
+ return;
283
+ }
284
+ desired.current = isPlaying;
285
+ });
286
+ const listener = player.addListener('statusChange', s => { if (s.status === 'error') {
287
+ if (!recoverStream())
288
+ setError('This video could not be played on this phone.');
289
+ }
290
+ else
291
+ begin(); });
292
+ if (player.status === 'error')
293
+ recoverStream();
294
+ const timeout = setTimeout(() => { if (alive && desired.current && player.currentTime === 0 && player.status !== 'readyToPlay' && !recoverStream())
295
+ setError('This video could not be loaded. Try again.'); }, 15000);
296
+ const app = AppState.addEventListener('change', s => { if (s !== 'active') {
297
+ desired.current = false;
298
+ player.pause();
299
+ } });
300
+ return () => { alive = false; clearTimeout(timeout); clearInterval(timer); listener.remove(); playback.remove(); app.remove(); try {
301
+ player.pause();
302
+ }
303
+ catch { } void release?.().catch(() => { }); };
304
+ }, [player, audio]);
305
+ return _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(MessengerVideoView, { testID: "messenger-video-player", onFirstFrameRender: () => mediaReady('video-frame', file.uri), player: player, nativeControls: ready, style: { height: '100%', width: '100%' } }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { position: 'absolute', bottom: 12, left: 12, right: 12, color: '#fff', backgroundColor: '#000c', padding: 12 }, children: error })] });
306
+ }
307
+ function FileViewer({ file, kind, audio, design }) {
308
+ const [error, setError] = useState('');
309
+ if (kind === 'voice')
310
+ return _jsx(VoicePlayer, { file: file, audio: audio, design: design });
311
+ if (kind === 'video')
312
+ return _jsx(VideoPlayer, { file: file, audio: audio, design: design });
313
+ if (kind === 'image')
314
+ return _jsxs(View, { children: [_jsx(Image, { source: { uri: file.uri }, resizeMode: "contain", accessibilityLabel: file.fileName, onError: () => setError('This image could not be displayed on this phone.'), style: { height: 300, width: '100%' } }), !!error && _jsx(Text, { style: { color: design.colors.foreground }, children: error })] });
315
+ return _jsxs(View, { children: [_jsx(Control, { label: "File options", icon: "open-in-new", color: design.colors.accent, onPress: () => {
316
+ void (async () => { try {
317
+ if (!await Sharing.isAvailableAsync())
318
+ throw Error('No file-opening menu is available.');
319
+ await Sharing.shareAsync(file.uri, { mimeType: file.mimeType, dialogTitle: file.fileName });
320
+ }
321
+ catch (e) {
322
+ setError(e instanceof Error ? e.message : 'The file could not be opened.');
323
+ } })();
324
+ } }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: design.colors.foreground }, children: error })] });
325
+ }
326
+ export function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice = false, inlineRecorder = false, viewerActions, onDismiss }) {
327
+ const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
328
+ const c = appearance.colors;
329
+ const mediaReady = useContext(MediaReadiness);
330
+ const [fileError, setFileError] = useState(''), [downloading, setDownloading] = useState(false);
331
+ const saving = useRef(false);
332
+ const [expanded, setExpanded] = useState(false);
333
+ useEffect(() => setExpanded(false), [state.file?.uri, state.phase]);
334
+ const review = ['review', 'failed'].includes(state.phase) && !!state.file;
335
+ if (state.phase !== 'idle' && (typeof inlineRecorder === 'function' ? inlineRecorder() : inlineRecorder) && state.kind === 'voice')
336
+ return null;
337
+ if ((typeof inlineVoice === 'function' ? inlineVoice() : inlineVoice) && state.kind === 'voice' && ['opening', 'viewing', 'failed'].includes(state.phase) && !review)
338
+ return null;
339
+ const viewer = state.file && (review || state.phase === 'viewing') ? _jsx(MediaViewer, { expanded: expanded, onExpandedChange: setExpanded, kind: state.kind, uri: state.file.uri, title: review ? 'Preview attachment' : state.kind === 'image' ? 'Photo' : state.kind === 'video' ? 'Video' : state.kind === 'voice' ? 'Voice message' : 'File', subtitle: state.file.fileName, fileName: state.file.fileName, byteSize: state.file.byteSize, error: state.error ?? fileError, onClose: () => { setFileError(''); void controller.cancel(); }, reviewAction: review ? (state.phase === 'failed' ? 'Retry upload' : 'Add attachment') : undefined, onReview: review ? () => void controller.send() : undefined, ...(!review ? viewerActions?.() : {}), downloading: downloading, onDownload: !review && appearance.showDownloadButton === true && ['image', 'video', 'document'].includes(state.kind) ? () => { if (saving.current || !state.file)
340
+ return; saving.current = true; setDownloading(true); setFileError(''); void controller.exportFile(async (file) => { await saveMessagingAttachment(file, state.kind); mediaReady('file-opened', state.file.uri); }).catch(e => setFileError(e instanceof Error ? e.message : 'Could not save the attachment.')).finally(() => { saving.current = false; setDownloading(false); }); } : undefined, onShare: !review ? () => { void controller.exportFile(async (file) => { await Sharing.shareAsync(file.uri, { mimeType: file.mimeType, dialogTitle: file.fileName }); mediaReady('file-opened', state.file.uri); }).catch(() => setFileError('Could not share this attachment.')); } : undefined, onFileAction: () => { void (async () => { try {
341
+ if (!await Sharing.isAvailableAsync())
342
+ throw Error('File opening is unavailable.');
343
+ await Sharing.shareAsync(state.file.uri, { mimeType: state.file.mimeType, dialogTitle: state.file.fileName });
344
+ mediaReady('file-opened', state.file.uri);
345
+ }
346
+ catch (e) {
347
+ setFileError(e instanceof Error ? e.message : 'Could not open the file.');
348
+ } })(); }, children: state.kind === 'video' ? _jsx(VideoPlayer, { file: state.file, audio: audio, design: appearance }, state.file.uri) : state.kind === 'voice' ? _jsx(View, { style: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 }, children: _jsx(VoicePlayer, { file: state.file, audio: audio, design: { ...appearance, colors: { ...appearance.colors, foreground: '#ffffff', accent: '#8ab4f8' } } }) }) : null }) : null;
349
+ // A native modal has its own window; measure its safe area inside that window.
350
+ return _jsx(Modal, { transparent: true, visible: state.phase !== 'idle' && state.phase !== 'choosing', onDismiss: onDismiss, animationType: "fade", onRequestClose: () => { if (expanded)
351
+ setExpanded(false);
352
+ else
353
+ void controller.cancel(); }, children: _jsx(SafeAreaProvider, { style: { flex: 1, backgroundColor: state.kind === 'image' || state.kind === 'video' ? 'rgba(0,0,0,0.94)' : messagingPopupBackdrop }, children: _jsx(SafeAreaView, { edges: expanded ? [] : ['top', 'bottom', 'left', 'right'], style: { flex: 1 }, children: _jsx(View, { style: { flex: 1, margin: state.kind === 'image' || state.kind === 'video' ? 0 : 8, borderRadius: state.kind === 'image' || state.kind === 'video' ? 0 : 18, overflow: 'hidden', backgroundColor: state.kind === 'image' || state.kind === 'video' ? 'transparent' : '#000000' }, children: viewer ?? (_jsx(View, { style: { flex: 1, backgroundColor: c.background }, children: _jsxs(ScrollView, { contentContainerStyle: { padding: 8, paddingTop: 0, gap: 12, flexGrow: 1 }, children: [_jsx(MessagingPopupHeader, { title: state.kind === 'voice' ? 'Voice message' : state.kind === 'image' ? 'Photo' : state.kind === 'video' ? 'Video' : 'File', foreground: c.foreground, closeLabel: "Close attachment", onClose: () => void controller.cancel() }), state.phase === 'recording' ? _jsxs(View, { style: { alignItems: 'center', gap: 20, paddingVertical: 32 }, children: [_jsx(MaterialIcons, { name: "mic", size: 48, color: c.accent }), _jsx(Text, { style: { color: c.foreground, fontSize: 32, fontVariant: ['tabular-nums'] }, children: time(state.elapsedMs) }), _jsxs(Text, { style: { color: c.secondary }, children: [time(Math.max(0, state.maxDurationMs - state.elapsedMs)), " remaining"] }), _jsx(Control, { label: "Stop recording", icon: "stop", color: c.accent, onPress: () => void controller.finishRecording() })] }) : null, ['choosing', 'preparing', 'uploading', 'opening'].includes(state.phase) && _jsx(Text, { accessibilityRole: "progressbar", accessibilityValue: { min: 0, max: 100, now: Math.round(state.progress * 100) }, style: { color: c.foreground, fontSize: 18 }, children: state.phase === 'uploading' ? `Uploading… ${Math.round(state.progress * 100)}%` : state.phase === 'opening' ? (state.progress > 0 ? `Opening… ${Math.round(state.progress * 100)}%` : 'Opening…') : state.phase === 'choosing' ? 'Choose an attachment.' : 'Preparing recording…' }), state.file && _jsxs(_Fragment, { children: [_jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: state.file.fileName }), _jsx(Text, { style: { color: c.secondary }, children: size(state.file.byteSize) }), (review || state.phase === 'viewing') && _jsx(FileViewer, { file: state.file, kind: state.kind, audio: audio, design: appearance }, state.file.uri)] }), state.error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: state.error }), review && (state.phase === 'review' || state.retryUpload) && _jsx(Control, { label: state.phase === 'failed' ? 'Retry upload' : 'Add attachment', icon: state.phase === 'failed' ? 'refresh' : 'send', color: c.accent, onPress: () => void controller.send() })] }) })) }) }) }) });
354
+ }
355
+ /** Optional ready-made recording, preview, upload and download tools for both mobile platforms. */
356
+ export function ExpoMessagingScreen({ storage, audioSession, renderConversation, preloadRecentMedia = true, ...props }) {
357
+ useEffect(() => { void props.client.reportUsage({ platform: Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'other', components: ['media-tools'] }).catch(() => { }); }, [props.client]);
358
+ const [requestedMessageAction, setRequestedMessageAction] = useState();
359
+ const afterViewer = useRef(undefined);
360
+ const dismissFallback = useRef(undefined);
361
+ const viewerDismissed = () => { clearTimeout(dismissFallback.current); const action = afterViewer.current; if (!action)
362
+ return; requestAnimationFrame(() => { if (afterViewer.current !== action)
363
+ return; afterViewer.current = undefined; setRequestedMessageAction(action); }); };
364
+ useEffect(() => { afterViewer.current = undefined; return () => { afterViewer.current = undefined; clearTimeout(dismissFallback.current); }; }, [props.client, props.active]);
365
+ const current = useRef(props);
366
+ current.current = props;
367
+ const message = useRef(null);
368
+ const recent = useMemo(() => new RecentMessagingMedia(storage, { copy: copyMessagingCache, remove: createExpoMessagingDevice(audioSession).remove }), [storage, audioSession, props.client]);
369
+ const openingIntent = useRef(0);
370
+ const opened = useRef(null);
371
+ const chosen = useRef(undefined);
372
+ const controller = useMemo(() => {
373
+ const device = createExpoMessagingDevice(audioSession);
374
+ return new MessagingMediaController({ device: { ...device, pick: async (kind, settings, signal, source) => {
375
+ const id = chosen.current;
376
+ chosen.current = undefined;
377
+ if (id) {
378
+ if (!current.current.resolveAttachmentChoice)
379
+ throw Error('This attachment is unavailable. Choose it from Photos or Files.');
380
+ return current.current.resolveAttachmentChoice(id, signal);
381
+ }
382
+ return device.pick(kind, settings, signal, source);
383
+ } }, storage: recent.storage, createId: Crypto.randomUUID, stageLocal: true,
384
+ getSettings: () => current.current.client.getSnapshot().settings ?? defaultMessagingSettings(), getConversationId: () => current.current.client.getSnapshot().conversationId });
385
+ }, [recent, audioSession, props.client]);
386
+ const recorder = useMemo(() => { const device = createExpoMessagingDevice(audioSession); return new MessagingMediaController({ device: { ...device, startRecording: async (maximum, signal) => { await stopVoicePlayers(); return device.startRecording(maximum, signal); } }, storage, stageLocal: true, createId: Crypto.randomUUID, getSettings: () => current.current.client.getSnapshot().settings ?? defaultMessagingSettings(), getConversationId: () => current.current.client.getSnapshot().conversationId }); }, [storage, audioSession, props.client]);
387
+ const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
388
+ const outbox = expoOutbox(props.client);
389
+ const restore = useCallback(async (media, kind) => {
390
+ const present = controller.getPrepared(media.assetId) ?? recorder.getPrepared(media.assetId);
391
+ if (present)
392
+ return present;
393
+ const conversationId = props.client.getSnapshot().conversationId, saved = await outbox?.file(conversationId, media.assetId);
394
+ if (!saved)
395
+ return undefined;
396
+ const copy = await copyMessagingCache(saved);
397
+ if (props.client.getSnapshot().conversationId !== conversationId) {
398
+ await createExpoMessagingDevice(audioSession).remove(copy);
399
+ throw Error('The conversation changed.');
400
+ }
401
+ controller.restorePrepared(media.assetId, copy, kind);
402
+ return copy;
403
+ }, [controller, recorder, props.client, outbox, audioSession]);
404
+ const prepare = async (kind, media) => { await restore(media, kind); const owner = recorder.getPrepared(media.assetId) ? recorder : controller; if (!owner.getPrepared(media.assetId))
405
+ return media; const uploaded = await owner.uploadPrepared(media.assetId, kind); thumbnails.alias(media.assetId, uploaded.assetId); return uploaded; };
406
+ useEffect(() => props.client.setAttachmentPreparer(async (content) => { if (!content.media || content.kind === 'text' || content.kind === 'poll' || !await restore(content.media, content.kind))
407
+ throw Error('The saved attachment is unavailable. Remove it and choose it again.'); return { ...content, media: await prepare(content.kind, content.media) }; }), [props.client, controller, recorder, outbox]);
408
+ const thumbnails = useMemo(() => createExpoThumbnails(recent.storage, id => controller.getPrepared(id) ?? recorder.getPrepared(id), createExpoMessagingDevice(audioSession).remove), [recent, controller, recorder, audioSession]);
409
+ useEffect(() => () => thumbnails.clear(), [thumbnails, state.conversationId, state.settings?.enabled, state.conversation?.canSend, props.active]);
410
+ const cacheDisposal = useRef(new Map());
411
+ useEffect(() => {
412
+ clearTimeout(cacheDisposal.current.get(recent));
413
+ cacheDisposal.current.delete(recent);
414
+ const sync = () => { const snapshot = props.client.getSnapshot(); if (preloadRecentMedia && current.current.active !== false && snapshot.settings?.enabled && !snapshot.error && AppState.currentState === 'active')
415
+ recent.sync(snapshot.messages.filter(m => snapshot.settings?.features[m.kind]));
416
+ else
417
+ recent.clear(); };
418
+ sync();
419
+ const unsubscribe = props.client.subscribe(sync);
420
+ const app = AppState.addEventListener('change', s => { if (s === 'active')
421
+ sync();
422
+ else
423
+ recent.clear(); });
424
+ return () => { unsubscribe(); app.remove(); cacheDisposal.current.set(recent, setTimeout(() => { recent.clear(); cacheDisposal.current.delete(recent); }, 0)); };
425
+ }, [recent, props.client, props.active, preloadRecentMedia]);
426
+ const TranslationVoice = useMemo(() => function TranslationVoice({ transform, ink, accent, showWaveform, autoPlay }) {
427
+ const { design } = useMessagingUI(), audio = transform.audio;
428
+ return _jsx(VoicePlayer, { file: { uri: audio.url, mimeType: audio.mimeType, fileName: 'Translated voice', byteSize: audio.byteSize, durationMs: audio.durationMs, stream: {} }, audio: audioSession, design: design, autoPlay: autoPlay, inline: { message: { id: transform.messageId, clientMessageId: 'translation-' + transform.messageId, conversationId: 'translation', senderId: '', kind: 'voice', createdAt: '', readAt: null }, ink, accent, showWaveform, waveform: audio.waveform, onOpen: () => { } } }, audio.url);
429
+ }, [audioSession]);
430
+ const InlineVoice = useMemo(() => function InlineVoice(bubble) {
431
+ const media = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
432
+ const { design } = useMessagingUI();
433
+ const selected = message.current?.id === bubble.message.id && message.current?.conversationId === bubble.message.conversationId && media.kind === 'voice';
434
+ if (selected && media.phase === 'viewing' && media.file)
435
+ return _jsx(VoicePlayer, { file: media.file, audio: audioSession, design: design, inline: bubble, autoPlay: true }, media.file.uri);
436
+ return _jsx(VoicePlayback, { ...bubble, durationMs: bubble.message.media?.durationMs, loading: selected && media.phase === 'opening', error: selected ? media.error ?? undefined : undefined, onToggle: bubble.onOpen });
437
+ }, [controller, audioSession]);
438
+ const InlineComposer = useMemo(() => function InlineComposer(ui) {
439
+ const media = useSyncExternalStore(recorder.subscribe, recorder.getSnapshot, recorder.getSnapshot);
440
+ const capturing = media.kind === 'voice' && !['opening', 'viewing'].includes(media.phase);
441
+ const phase = capturing && media.phase !== 'choosing' && media.phase !== 'opening' && media.phase !== 'viewing' ? media.phase : 'idle';
442
+ const remove = () => { void recorder.cancel(); };
443
+ return _jsx(VoiceMessageComposer, { ...ui, phase: phase, elapsedMs: media.elapsedMs, waveform: media.waveform, error: capturing ? media.error : null, onStop: () => void recorder.finishRecording(), onSend: () => void recorder.send(), onRemove: remove, review: capturing && media.file ? _jsx(VoicePlayer, { file: media.file, audio: audioSession, design: ui.appearance, draft: { onRemove: remove, waveform: media.waveform, disabled: media.phase === 'uploading', compact: !!(ui.renderTextComposer || ui.renderAttachments) } }) : undefined });
444
+ }, [recorder, audioSession]);
445
+ const VoiceAttachment = useMemo(() => function VoiceAttachment(ui) { const [file, setFile] = useState(recorder.getPrepared(ui.media.assetId) ?? controller.getPrepared(ui.media.assetId)); useEffect(() => { let live = true; void restore(ui.media, 'voice').then(file => { if (live)
446
+ setFile(file); }).catch(() => { }); return () => { live = false; }; }, [ui.media.assetId]); return file ? _jsx(VoicePlayer, { file: file, audio: audioSession, design: ui.appearance, draft: { onRemove: ui.onRemove, disabled: ui.disabled, compact: true } }) : null; }, [recorder, audioSession]);
447
+ const conversation = useRef(state.conversationId);
448
+ const disposal = useRef(new Map());
449
+ useEffect(() => {
450
+ if (conversation.current !== state.conversationId || props.active === false || !state.settings?.enabled || message.current && !state.messages.some(m => m.id === message.current.id && !m.deleted) && !controller.getPrepared(message.current.media?.assetId ?? '') && !recorder.getPrepared(message.current.media?.assetId ?? '')) {
451
+ ++openingIntent.current;
452
+ afterViewer.current = undefined;
453
+ message.current = null;
454
+ void controller.cancel();
455
+ void recorder.cancel();
456
+ }
457
+ conversation.current = state.conversationId;
458
+ }, [state, props.active, controller, recorder]);
459
+ useEffect(() => {
460
+ const controllers = [controller, recorder];
461
+ for (const item of controllers) {
462
+ const pending = disposal.current.get(item);
463
+ if (pending) {
464
+ clearTimeout(pending);
465
+ disposal.current.delete(item);
466
+ }
467
+ }
468
+ const sub = AppState.addEventListener('change', s => { if (s !== 'active')
469
+ void recorder.interrupt(); });
470
+ return () => { ++openingIntent.current; sub.remove(); for (const item of controllers)
471
+ disposal.current.set(item, setTimeout(() => { disposal.current.delete(item); void item.dispose(); }, 0)); };
472
+ }, [controller, recorder]);
473
+ useEffect(() => props.client.setForwardPreparer((message, target) => controller.prepareForward(message, target)), [props.client, controller]);
474
+ const saved = expoSaved(props.client);
475
+ const SocialPlayer = useMemo(() => function SocialPlayer(ui) {
476
+ const [ready, setReady] = useState(false);
477
+ useEffect(() => {
478
+ let alive = true, release;
479
+ void stopVoicePlayers().then(() => audioSession.acquire('playback')).then(async (done) => { if (!alive) {
480
+ await done();
481
+ return;
482
+ } release = done; setReady(true); }).catch(() => { if (alive)
483
+ ui.onError(); });
484
+ return () => { alive = false; void release?.().catch(() => { }); };
485
+ }, []);
486
+ return ready ? _jsx(ExpoSocialPlayer, { ...ui }) : null;
487
+ }, [audioSession]);
488
+ useEffect(() => saved?.bindDownload(async (m) => { const device = createExpoMessagingDevice(audioSession), file = await storage.download(m, { signal: AbortSignal.timeout(120000), onProgress: () => { } }); return { file, release: () => device.remove(file) }; }), [saved, storage, audioSession]);
489
+ const attachmentPreview = useCallback(async (m) => { if (m.media && m.kind !== 'text' && m.kind !== 'poll')
490
+ await restore(m.media, m.kind); return thumbnails.preview(m); }, [restore, thumbnails]);
491
+ const screenProps = { ...props, requestedMessageAction,
492
+ copyText: props.copyText ?? (async (text) => { await Clipboard.setStringAsync(text); }),
493
+ components: { PrivacyLayer: ExpoMessagingPrivacyLayer, SocialPlayer, TranslationVoice, VoiceMessage: InlineVoice, VoiceComposer: InlineComposer, VoiceAttachment, ...props.components },
494
+ attachmentPreview: props.attachmentPreview ?? attachmentPreview,
495
+ prepareAttachment: prepare,
496
+ retainDraftAttachments: !!outbox,
497
+ releaseAttachment: async (media) => { await outbox?.releaseFile(state.conversationId, media.assetId); await controller.releasePrepared(media.assetId); await recorder.releasePrepared(media.assetId); await props.releaseAttachment?.(media); },
498
+ pickAttachment: async (kind, options) => {
499
+ const conversationId = props.client.getSnapshot().conversationId;
500
+ let media;
501
+ if (kind === 'voice')
502
+ media = await recorder.pick('voice', undefined, 'attach');
503
+ else {
504
+ message.current = null;
505
+ chosen.current = options.attachmentId;
506
+ media = !options.source && !options.attachmentId && options.intent !== 'preview' && (options.maxCount ?? 1) > 1 ? await controller.pickMany(kind, options.maxCount) : await controller.pick(kind, options.source, options.source === 'call-video' ? 'preview' : options.intent === 'preview' ? 'preview' : 'attach');
507
+ }
508
+ const selection = media ? (Array.isArray(media) ? media : [media]) : [];
509
+ try {
510
+ if (outbox)
511
+ for (const item of selection) {
512
+ const file = recorder.getPrepared(item.assetId) ?? controller.getPrepared(item.assetId);
513
+ if (!file)
514
+ throw Error('The selected file is unavailable.');
515
+ await outbox.saveFile(conversationId, item, file, kind);
516
+ }
517
+ }
518
+ catch (error) {
519
+ await Promise.all(selection.map(async (item) => { await outbox?.releaseFile(conversationId, item.assetId).catch(() => { }); await recorder.releasePrepared(item.assetId); await controller.releasePrepared(item.assetId); }));
520
+ throw error;
521
+ }
522
+ return media;
523
+ },
524
+ openAttachment: async (m) => { const intent = ++openingIntent.current; await stopVoicePlayers(); if (intent !== openingIntent.current)
525
+ return; opened.current = { messageId: m.id, kind: m.kind, started: Date.now(), reported: false }; message.current = m; const draft = m.media && m.kind !== 'text' && m.kind !== 'poll' ? await restore(m.media, m.kind) : undefined; if (draft && m.kind !== 'text' && m.kind !== 'poll')
526
+ await controller.previewLocal(await copyMessagingCache(draft), m.kind);
527
+ else
528
+ await controller.open(m); },
529
+ openSavedAttachment: async (record) => { if (!saved || record.message.kind === 'text' || record.message.kind === 'poll')
530
+ throw Error('This saved attachment is unavailable.'); const intent = ++openingIntent.current; await stopVoicePlayers(); const file = await copyMessagingCache(await saved.file(record)); if (intent !== openingIntent.current) {
531
+ await createExpoMessagingDevice(audioSession).remove(file);
532
+ return;
533
+ } opened.current = null; message.current = null; await controller.previewLocal(file, record.message.kind); }, };
534
+ const mediaReady = (stage, uri) => { const event = opened.current; if (!event || controller.getSnapshot().phase !== 'viewing' || controller.getSnapshot().file?.uri !== uri)
535
+ return; if (stage !== 'video-frame' && message.current?.conversationId === current.current.client.getSnapshot().conversationId)
536
+ void current.current.client.consumeMessage(event.messageId).catch(() => { }); if (event.reported)
537
+ return; event.reported = true; try {
538
+ current.current.onMediaReady?.({ messageId: event.messageId, kind: event.kind, stage, elapsedMs: Date.now() - event.started });
539
+ }
540
+ catch { } };
541
+ return _jsx(StreamRecovery.Provider, { value: (uri, position, playing) => { void controller.fallbackStream(uri, position, playing); }, children: _jsxs(MediaReadiness.Provider, { value: mediaReady, children: [renderConversation ? renderConversation(screenProps) : _jsx(MessagingScreen, { ...screenProps }), _jsx(MessagingMediaOverlay, { onDismiss: viewerDismissed, viewerActions: () => {
542
+ const m = message.current;
543
+ if (!m || !state.messages.some(row => row.id === m.id && !row.deleted))
544
+ return {};
545
+ const action = (kind) => {
546
+ afterViewer.current = { kind, messageId: m.id, sequence: Date.now() };
547
+ void controller.cancel().then(() => {
548
+ // Some installed native runtimes omit onDismiss after a media modal closes.
549
+ // Keep the normal event fast path; recover only after the native fade ends.
550
+ // Navigation/unmount clears the queued action, so it cannot open in another chat.
551
+ if (afterViewer.current)
552
+ dismissFallback.current = setTimeout(viewerDismissed, Platform.OS === 'ios' ? 500 : 250);
553
+ });
554
+ };
555
+ return { onForward: props.client.canForward(m) ? () => action('forward') : undefined, onDelete: m.senderId === props.userId && !m.readAt && state.messageActionsAvailable ? () => action('delete') : undefined };
556
+ }, inlineVoice: () => !!message.current, inlineRecorder: () => !!message.current && !!current.current.client.getSnapshot().settings?.features.voice, controller: controller, audio: audioSession, appearance: props.appearance ?? state.settings?.appearance ?? defaultMessagingSettings().appearance })] }) });
557
+ }
package/dist/expo.d.ts CHANGED
@@ -1,42 +1,2 @@
1
- export { createExpoMessagingClient } from './expo-outbox.js';
2
- import React from 'react';
3
- import { type MessagingAppearance } from '@robono/messaging';
4
- import { type MessagingScreenProps } from './index.js';
5
- import { MessagingMediaController, type LocalMessagingMedia, type MessagingMediaStorage } from './media-controller.js';
6
- import { type MediaReadyStage } from './media-readiness.js';
7
- import { type MessagingAudioSession } from './expo-media.js';
8
- export { createExpoMessagingAudioSession, createExpoMessagingDevice, createExpoMessagingStorage } from './expo-media.js';
9
- export type { MessagingAudioSession } from './expo-media.js';
10
- export type { LocalMessagingMedia, MessagingMediaStorage, MediaTransferOptions } from './media-controller.js';
11
- export { MessagingMediaController } from './media-controller.js';
12
- export declare function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice, inlineRecorder, viewerActions, onDismiss }: {
13
- onDismiss?: () => void;
14
- viewerActions?: () => {
15
- onForward?: () => void;
16
- onDelete?: () => void;
17
- };
18
- controller: MessagingMediaController;
19
- audio: MessagingAudioSession;
20
- appearance: MessagingAppearance;
21
- inlineVoice?: boolean | (() => boolean);
22
- inlineRecorder?: boolean | (() => boolean);
23
- }): React.JSX.Element | null;
24
- export interface ExpoMessagingScreenProps extends Omit<MessagingScreenProps, 'pickAttachment' | 'openAttachment'> {
25
- storage: MessagingMediaStorage;
26
- audioSession: MessagingAudioSession;
27
- /** Optional timing callback from native image/frame/playback readiness, measured from opening. */
28
- onMediaReady?: (event: {
29
- messageId: string;
30
- kind: string;
31
- stage: MediaReadyStage;
32
- elapsedMs: number;
33
- }) => void;
34
- /** Preload a bounded set of small recent attachments while this conversation is open. Default true. */
35
- preloadRecentMedia?: boolean;
36
- /** Compose a conversation with the supplied media adapters. */
37
- renderConversation?: (props: MessagingScreenProps) => React.ReactNode;
38
- /** Resolve an authorized recent attachment to an adapter-owned cache copy, never a library original. */
39
- resolveAttachmentChoice?: (id: string, signal: AbortSignal) => Promise<LocalMessagingMedia | null>;
40
- }
41
- /** Optional ready-made recording, preview, upload and download tools for both mobile platforms. */
42
- export declare function ExpoMessagingScreen({ storage, audioSession, renderConversation, preloadRecentMedia, ...props }: ExpoMessagingScreenProps): React.JSX.Element;
1
+ export * from './expo-screen.js';
2
+ export { ExpoMessenger, createExpoMessenger, type ExpoMessengerProps } from './expo-messenger.js';