@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.
Files changed (46) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +130 -0
  3. package/README.md +165 -2
  4. package/SBOM.spdx.json +1127 -0
  5. package/app.plugin.cjs +5 -0
  6. package/dist/call-message.d.ts +6 -0
  7. package/dist/call-message.js +109 -0
  8. package/dist/calling.d.ts +15 -0
  9. package/dist/calling.js +22 -0
  10. package/dist/expo-calling.d.ts +6 -0
  11. package/dist/expo-calling.js +7 -0
  12. package/dist/expo-media.d.ts +19 -0
  13. package/dist/expo-media.js +388 -0
  14. package/dist/expo.d.ts +36 -0
  15. package/dist/expo.js +429 -0
  16. package/dist/icons.d.ts +7 -0
  17. package/dist/icons.js +10 -0
  18. package/dist/index.d.ts +101 -0
  19. package/dist/index.js +433 -0
  20. package/dist/media-controller.d.ts +98 -0
  21. package/dist/media-controller.js +257 -0
  22. package/dist/media-readiness.d.ts +4 -0
  23. package/dist/media-readiness.js +4 -0
  24. package/dist/media-ui.d.ts +51 -0
  25. package/dist/media-ui.js +42 -0
  26. package/dist/polls.d.ts +21 -0
  27. package/dist/polls.js +54 -0
  28. package/dist/recent-media.d.ts +21 -0
  29. package/dist/recent-media.js +122 -0
  30. package/dist/safety.d.ts +11 -0
  31. package/dist/safety.js +47 -0
  32. package/dist/use-quote-gesture.d.ts +24 -0
  33. package/dist/use-quote-gesture.js +127 -0
  34. package/dist/visual/RobonoIcon.d.ts +232 -0
  35. package/dist/visual/RobonoIcon.js +20 -0
  36. package/dist/visual/icon-nodes.d.ts +3 -0
  37. package/dist/visual/icon-nodes.js +2 -0
  38. package/dist/visual/icon-paths.d.ts +221 -0
  39. package/dist/visual/icon-paths.js +221 -0
  40. package/dist/voice-composer.d.ts +37 -0
  41. package/dist/voice-composer.js +20 -0
  42. package/licenses/robono-icons-lucide-license.txt +43 -0
  43. package/licenses/robono-icons-weather-attribution.txt +1 -0
  44. package/licenses/robono-icons-weather-license.txt +21 -0
  45. package/package.json +110 -4
  46. package/plugin/preserve-call-audio.cjs +22 -0
package/dist/expo.js ADDED
@@ -0,0 +1,429 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
+ import { AppState, Image, Modal, Platform, Pressable, ScrollView, Text, View } from 'react-native';
4
+ import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
5
+ import MaterialIcons from "./visual/RobonoIcon";
6
+ import { useAudioPlayer } from 'expo-audio';
7
+ import { useVideoPlayer, VideoView } from 'expo-video';
8
+ import * as Sharing from 'expo-sharing';
9
+ import * as Crypto from 'expo-crypto';
10
+ import { defaultMessagingSettings } from '@robono/messaging';
11
+ import { MessagingScreen, useMessagingUI } from './index.js';
12
+ import { VoiceMessageComposer, VoiceDraftPlayback } from './voice-composer.js';
13
+ import { VoicePlayback, MediaViewer, MediaPlaybackControls } from './media-ui.js';
14
+ import { MessagingMediaController } from './media-controller.js';
15
+ import { MediaReadiness, StreamRecovery } from './media-readiness.js';
16
+ import { RecentMessagingMedia } from './recent-media.js';
17
+ import { copyMessagingCache, createExpoMessagingDevice } from './expo-media.js';
18
+ export { createExpoMessagingAudioSession, createExpoMessagingDevice, createExpoMessagingStorage } from './expo-media.js';
19
+ export { MessagingMediaController } from './media-controller.js';
20
+ const size = (bytes) => bytes < 1024 ? `${bytes} bytes` : bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(2)} MB`;
21
+ const time = (ms) => `${Math.floor(ms / 60000)}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
22
+ function Control({ label, icon, onPress, disabled = false, color }) {
23
+ 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 })] });
24
+ }
25
+ const voiceStops = new Set();
26
+ const stopVoicePlayers = async (except) => { await Promise.all([...voiceStops].filter(stop => stop !== except).map(stop => stop())); };
27
+ const voiceScope = () => ({ alive: true, busy: false, epoch: 0, release: null, acquiring: null, releasing: Promise.resolve() });
28
+ function releaseVoice(scope) {
29
+ const release = scope.release;
30
+ scope.release = null;
31
+ if (release)
32
+ scope.releasing = scope.releasing.then(release).catch(() => { });
33
+ return scope.releasing;
34
+ }
35
+ async function acquireVoice(scope, audio) {
36
+ const epoch = scope.epoch;
37
+ const work = (async () => {
38
+ await scope.releasing;
39
+ if (!scope.alive || scope.epoch !== epoch)
40
+ return;
41
+ const release = await audio.acquire('playback');
42
+ if (!scope.alive || scope.epoch !== epoch) {
43
+ await release();
44
+ return;
45
+ }
46
+ scope.release = release;
47
+ })();
48
+ scope.acquiring = work;
49
+ try {
50
+ await work;
51
+ }
52
+ finally {
53
+ if (scope.acquiring === work)
54
+ scope.acquiring = null;
55
+ }
56
+ return scope.alive && scope.epoch === epoch && !!scope.release;
57
+ }
58
+ function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
59
+ const [playing, setPlaying] = useState(false), [position, setPosition] = useState(0), [error, setError] = useState('');
60
+ const source = useMemo(() => file.stream ? { uri: file.uri, headers: file.stream.headers } : file.uri, [file]);
61
+ const player = useAudioPlayer(source, { updateInterval: 100, downloadFirst: false, preferredForwardBufferDuration: 5, keepAudioSessionActive: true });
62
+ const mediaReady = useContext(MediaReadiness), recover = useContext(StreamRecovery);
63
+ const recovered = useRef(false), lastPosition = useRef(file.resumeAtMs ?? 0), playIntent = useRef(file.autoPlay ?? autoPlay);
64
+ const [loaded, setLoaded] = useState(false);
65
+ autoPlay = file.autoPlay ?? autoPlay;
66
+ const current = useRef(voiceScope());
67
+ const playbackDisabled = useRef(false);
68
+ playbackDisabled.current = !!draft?.disabled;
69
+ useEffect(() => { if (draft?.disabled) {
70
+ player.pause();
71
+ current.current.epoch++;
72
+ void releaseVoice(current.current);
73
+ } }, [draft?.disabled, player]);
74
+ const width = useRef(1);
75
+ const c = design.colors;
76
+ const recoverStream = () => {
77
+ if (!file.stream || recovered.current || !current.current.alive)
78
+ return false;
79
+ recovered.current = true;
80
+ const resume = lastPosition.current, wanted = playIntent.current;
81
+ player.pause();
82
+ const scope = current.current;
83
+ scope.epoch++;
84
+ void releaseVoice(scope).then(() => { if (scope.alive)
85
+ recover(file.uri, resume, wanted); });
86
+ return true;
87
+ };
88
+ useEffect(() => {
89
+ const scope = voiceScope();
90
+ current.current = scope;
91
+ let started = false, reported = false, cancelled = false, autoPreparing = autoPlay;
92
+ let startupTimeout;
93
+ const stop = async () => { scope.epoch++; cancelled = true; if (autoPreparing) {
94
+ autoPreparing = false;
95
+ scope.busy = false;
96
+ } clearTimeout(startupTimeout); playIntent.current = false; try {
97
+ player.pause();
98
+ }
99
+ catch { } await releaseVoice(scope); await scope.acquiring?.catch(() => { }); await releaseVoice(scope); };
100
+ const fail = (e) => { cancelled = true; autoPreparing = false; scope.busy = false; clearTimeout(startupTimeout); if (scope.alive)
101
+ setError(e instanceof Error ? e.message : 'Playback failed.'); scope.epoch++; void releaseVoice(scope); };
102
+ // Native file loading and audio-session preparation are independent. Start
103
+ // both now; playback still requires both and an active app.
104
+ const begin = () => {
105
+ if (!scope.alive || recovered.current || !autoPlay || started || cancelled || !scope.release || !player.isLoaded)
106
+ return;
107
+ if (AppState.currentState !== 'active' || audio.isAvailable?.() === false) {
108
+ void stop();
109
+ return;
110
+ }
111
+ started = true;
112
+ autoPreparing = false;
113
+ clearTimeout(startupTimeout);
114
+ void (async () => { if (file.resumeAtMs)
115
+ await player.seekTo(file.resumeAtMs / 1000); if (!scope.alive || cancelled || AppState.currentState !== 'active')
116
+ return; playIntent.current = true; player.play(); setPlaying(player.playing); })().catch(e => { if (!recoverStream())
117
+ fail(e); }).finally(() => { scope.busy = false; });
118
+ };
119
+ const restorePaused = () => { if (!scope.alive || !player.isLoaded || autoPlay || started || !file.resumeAtMs)
120
+ return; started = true; void player.seekTo(file.resumeAtMs / 1000).then(() => { if (scope.alive)
121
+ setPosition(file.resumeAtMs); }).catch(() => { if (!recoverStream())
122
+ fail(Error('Could not resume the recording.')); }); };
123
+ setLoaded(player.isLoaded);
124
+ restorePaused();
125
+ const subscription = player.addListener('playbackStatusUpdate', status => { if (!scope.alive)
126
+ return; setLoaded(status.isLoaded); if (status.error) {
127
+ if (!recoverStream())
128
+ fail(Error('The recording could not be played. Try again.'));
129
+ return;
130
+ } restorePaused(); begin(); });
131
+ voiceStops.add(stop);
132
+ if (autoPlay) {
133
+ scope.busy = true;
134
+ startupTimeout = setTimeout(() => { if (!recoverStream())
135
+ fail(Error('The recording could not start. Try again.')); }, 15000);
136
+ void (async () => {
137
+ await stopVoicePlayers(stop);
138
+ if (!scope.alive || cancelled || AppState.currentState !== 'active') {
139
+ cancelled = true;
140
+ return;
141
+ }
142
+ if (!await acquireVoice(scope, audio))
143
+ return;
144
+ if (!scope.alive || recovered.current || cancelled || AppState.currentState !== 'active' || audio.isAvailable?.() === false) {
145
+ cancelled = true;
146
+ await releaseVoice(scope);
147
+ return;
148
+ }
149
+ begin();
150
+ })().catch(fail).finally(() => { if (cancelled || !scope.alive) {
151
+ scope.busy = false;
152
+ clearTimeout(startupTimeout);
153
+ } });
154
+ }
155
+ const timer = setInterval(() => {
156
+ if (!scope.alive)
157
+ return;
158
+ if (audio.isAvailable?.() === false)
159
+ void stop();
160
+ if (player.currentTime > 0)
161
+ lastPosition.current = player.currentTime * 1000;
162
+ setPosition(player.currentTime * 1000);
163
+ setPlaying(player.playing);
164
+ if (!reported && player.playing && player.currentTime > 0) {
165
+ reported = true;
166
+ mediaReady('audio-playing', file.uri);
167
+ }
168
+ if (!player.playing && player.duration > 0 && player.currentTime >= player.duration - .05 && scope.release && !scope.busy) {
169
+ playIntent.current = false;
170
+ void releaseVoice(scope);
171
+ }
172
+ }, 150);
173
+ const app = AppState.addEventListener('change', state => { if (state !== 'active')
174
+ void stop(); });
175
+ return () => { scope.alive = false; cancelled = true; subscription.remove(); clearTimeout(startupTimeout); clearInterval(timer); app.remove(); void stop().finally(() => voiceStops.delete(stop)); };
176
+ }, [player]);
177
+ async function toggle() {
178
+ const scope = current.current;
179
+ if (scope.busy)
180
+ return;
181
+ scope.busy = true;
182
+ setError('');
183
+ try {
184
+ if (player.playing) {
185
+ playIntent.current = false;
186
+ player.pause();
187
+ scope.epoch++;
188
+ await releaseVoice(scope);
189
+ }
190
+ else {
191
+ if (!player.isLoaded)
192
+ throw Error('The recording is still loading. Try again.');
193
+ await stopVoicePlayers();
194
+ if (!await acquireVoice(scope, audio))
195
+ return;
196
+ if (!scope.alive || playbackDisabled.current || AppState.currentState !== 'active') {
197
+ await releaseVoice(scope);
198
+ return;
199
+ }
200
+ if (player.currentTime >= player.duration - .05)
201
+ await player.seekTo(0);
202
+ playIntent.current = true;
203
+ player.play();
204
+ }
205
+ }
206
+ catch (e) {
207
+ if (scope.alive)
208
+ setError(e instanceof Error ? e.message : 'Playback failed.');
209
+ await releaseVoice(scope);
210
+ }
211
+ finally {
212
+ scope.busy = false;
213
+ if (scope.alive)
214
+ setPlaying(player.playing);
215
+ }
216
+ }
217
+ const duration = file.durationMs ?? player.duration * 1000;
218
+ if (draft)
219
+ 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 });
220
+ return _jsx(VoicePlayback, { ink: inline?.ink ?? c.foreground, accent: inline?.accent ?? c.accent, waveform: inline?.waveform, 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())
221
+ setError('Could not seek. Try again.'); }); } });
222
+ }
223
+ function VideoPlayer({ file, audio }) {
224
+ const mediaReady = useContext(MediaReadiness), recover = useContext(StreamRecovery);
225
+ const source = useMemo(() => file.stream ? { uri: file.uri, headers: file.stream.headers, contentType: 'progressive', useCaching: false } : file.uri, [file]);
226
+ const player = useVideoPlayer(source, p => { p.loop = false; p.audioMixingMode = 'mixWithOthers'; if (file.stream)
227
+ p.bufferOptions = { preferredForwardBufferDuration: 5, minBufferForPlayback: 0.5 }; });
228
+ const [ready, setReady] = useState(false), [error, setError] = useState(''), [position, setPosition] = useState(0), [playing, setPlaying] = useState(false);
229
+ const desired = useRef(file.autoPlay ?? false), lastPosition = useRef(file.resumeAtMs ?? 0);
230
+ useEffect(() => {
231
+ let alive = true, recovered = false, audioReady = false, resumed = false, release;
232
+ const recoverStream = () => {
233
+ if (!alive || recovered || !file.stream)
234
+ return false;
235
+ recovered = true;
236
+ player.pause();
237
+ const done = release;
238
+ release = undefined;
239
+ void Promise.resolve(done?.()).catch(() => { }).then(() => { if (alive)
240
+ recover(file.uri, lastPosition.current, desired.current); });
241
+ return true;
242
+ };
243
+ const begin = () => { if (!alive || recovered || !audioReady || resumed || player.status !== 'readyToPlay')
244
+ return; resumed = true; if (file.resumeAtMs)
245
+ player.currentTime = file.resumeAtMs / 1000; if (desired.current && AppState.currentState === 'active' && audio.isAvailable?.() !== false)
246
+ player.play(); };
247
+ void audio.acquire('playback').then(r => { if (!alive || recovered) {
248
+ void r().catch(() => { });
249
+ return;
250
+ } release = r; audioReady = true; setReady(true); begin(); }).catch(e => { if (alive)
251
+ setError(e.message); });
252
+ const timer = setInterval(() => { if (!alive)
253
+ return; if (player.currentTime > 0)
254
+ lastPosition.current = player.currentTime * 1000; setPosition(player.currentTime * 1000); setPlaying(player.playing); if (audio.isAvailable?.() === false) {
255
+ desired.current = false;
256
+ player.pause();
257
+ setReady(false);
258
+ setError('Playback paused for a call. Close and reopen the video to continue.');
259
+ void release?.().catch(() => { });
260
+ release = undefined;
261
+ } }, 100);
262
+ const listener = player.addListener('statusChange', s => { if (s.status === 'error') {
263
+ if (!recoverStream())
264
+ setError('This video could not be played on this phone.');
265
+ }
266
+ else
267
+ begin(); });
268
+ if (player.status === 'error')
269
+ recoverStream();
270
+ const timeout = setTimeout(() => { if (alive && desired.current && player.currentTime === 0 && player.status !== 'readyToPlay' && !recoverStream())
271
+ setError('This video could not be loaded. Try again.'); }, 15000);
272
+ const app = AppState.addEventListener('change', s => { if (s !== 'active') {
273
+ desired.current = false;
274
+ player.pause();
275
+ } });
276
+ return () => { alive = false; clearTimeout(timeout); clearInterval(timer); listener.remove(); app.remove(); try {
277
+ player.pause();
278
+ }
279
+ catch { } void release?.().catch(() => { }); };
280
+ }, [player, audio]);
281
+ return _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(View, { style: { flex: 1, minHeight: 0 }, children: _jsx(VideoView, { onFirstFrameRender: () => mediaReady('video-frame', file.uri), player: player, nativeControls: false, contentFit: "contain", style: { height: '100%', width: '100%' } }) }), _jsx(MediaPlaybackControls, { playing: playing, loading: !ready && !error, positionMs: position, durationMs: file.durationMs ?? player.duration * 1000, error: error, onToggle: () => { if (!ready)
282
+ return; desired.current = !player.playing; if (player.playing)
283
+ player.pause();
284
+ else {
285
+ if (player.currentTime >= player.duration - .05)
286
+ player.currentTime = 0;
287
+ player.play();
288
+ } }, onSeek: ms => { if (ready) {
289
+ lastPosition.current = ms;
290
+ player.currentTime = ms / 1000;
291
+ } } })] });
292
+ }
293
+ function FileViewer({ file, kind, audio, design }) {
294
+ const [error, setError] = useState('');
295
+ if (kind === 'voice')
296
+ return _jsx(VoicePlayer, { file: file, audio: audio, design: design });
297
+ if (kind === 'video')
298
+ return _jsx(VideoPlayer, { file: file, audio: audio, design: design });
299
+ if (kind === 'image')
300
+ 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 })] });
301
+ return _jsxs(View, { children: [_jsx(Control, { label: "File options", icon: "open-in-new", color: design.colors.accent, onPress: () => {
302
+ void (async () => { try {
303
+ if (!await Sharing.isAvailableAsync())
304
+ throw Error('No file-opening menu is available.');
305
+ await Sharing.shareAsync(file.uri, { mimeType: file.mimeType, dialogTitle: file.fileName });
306
+ }
307
+ catch (e) {
308
+ setError(e instanceof Error ? e.message : 'The file could not be opened.');
309
+ } })();
310
+ } }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: design.colors.foreground }, children: error })] });
311
+ }
312
+ export function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice = false, inlineRecorder = false }) {
313
+ const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
314
+ const c = appearance.colors;
315
+ const [fileError, setFileError] = useState('');
316
+ const review = ['review', 'failed'].includes(state.phase) && !!state.file;
317
+ if (inlineRecorder && state.kind === 'voice')
318
+ return null;
319
+ if (inlineVoice && state.kind === 'voice' && ['opening', 'viewing', 'failed'].includes(state.phase) && !review)
320
+ return null;
321
+ const viewer = state.file && (review || state.phase === 'viewing') ? _jsx(SafeAreaView, { style: { flex: 1, backgroundColor: '#0c0e10' }, children: _jsx(MediaViewer, { 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, onFileAction: () => { void (async () => { try {
322
+ if (!await Sharing.isAvailableAsync())
323
+ throw Error('File opening is unavailable.');
324
+ await Sharing.shareAsync(state.file.uri, { mimeType: state.file.mimeType, dialogTitle: state.file.fileName });
325
+ }
326
+ catch (e) {
327
+ setFileError(e instanceof Error ? e.message : 'Could not open the file.');
328
+ } })(); }, 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;
329
+ // A native modal has its own window; measure its safe area inside that window.
330
+ return _jsx(Modal, { visible: state.phase !== 'idle', animationType: "none", onRequestClose: () => void controller.cancel(), children: _jsx(SafeAreaProvider, { style: { flex: 1 }, children: viewer ?? (_jsx(SafeAreaView, { style: { flex: 1, backgroundColor: c.background }, children: _jsxs(ScrollView, { contentContainerStyle: { padding: 24, gap: 20, flexGrow: 1 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsx(Text, { accessibilityRole: "header", style: { color: c.foreground, fontSize: 24, fontWeight: '600' }, children: state.kind === 'voice' ? 'Voice message' : state.kind === 'image' ? 'Photo' : state.kind === 'video' ? 'Video' : 'File' }), _jsx(Control, { label: state.phase === 'viewing' ? 'Close' : 'Cancel', icon: "close", color: c.foreground, onPress: () => 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() })] }) })) }) });
331
+ }
332
+ /** Optional ready-made recording, preview, upload and download tools for both mobile platforms. */
333
+ export function ExpoMessagingScreen({ storage, audioSession, renderConversation, preloadRecentMedia = true, ...props }) {
334
+ useEffect(() => { void props.client.reportUsage({ platform: Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'other', components: ['media-tools'] }).catch(() => { }); }, [props.client]);
335
+ const current = useRef(props);
336
+ current.current = props;
337
+ const message = useRef(null);
338
+ const recent = useMemo(() => new RecentMessagingMedia(storage, { copy: copyMessagingCache, remove: createExpoMessagingDevice(audioSession).remove }), [storage, audioSession, props.client]);
339
+ const openingIntent = useRef(0);
340
+ const opened = useRef(null);
341
+ const chosen = useRef(undefined);
342
+ const controller = useMemo(() => {
343
+ const device = createExpoMessagingDevice(audioSession);
344
+ return new MessagingMediaController({ device: { ...device, pick: async (kind, settings, signal, source) => {
345
+ const id = chosen.current;
346
+ chosen.current = undefined;
347
+ if (id) {
348
+ if (!current.current.resolveAttachmentChoice)
349
+ throw Error('This attachment is unavailable. Choose it from Photos or Files.');
350
+ return current.current.resolveAttachmentChoice(id, signal);
351
+ }
352
+ return device.pick(kind, settings, signal, source);
353
+ } }, storage: recent.storage, createId: Crypto.randomUUID,
354
+ getSettings: () => current.current.client.getSnapshot().settings ?? defaultMessagingSettings(), getConversationId: () => current.current.client.getSnapshot().conversationId });
355
+ }, [recent, audioSession, props.client]);
356
+ const recorder = useMemo(() => { const device = createExpoMessagingDevice(audioSession); return new MessagingMediaController({ device: { ...device, startRecording: async (maximum, signal) => { await stopVoicePlayers(); return device.startRecording(maximum, signal); } }, storage, createId: Crypto.randomUUID, getSettings: () => current.current.client.getSnapshot().settings ?? defaultMessagingSettings(), getConversationId: () => current.current.client.getSnapshot().conversationId }); }, [storage, audioSession, props.client]);
357
+ const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
358
+ const cacheDisposal = useRef(new Map());
359
+ useEffect(() => {
360
+ clearTimeout(cacheDisposal.current.get(recent));
361
+ cacheDisposal.current.delete(recent);
362
+ const sync = () => { const snapshot = props.client.getSnapshot(); if (preloadRecentMedia && current.current.active !== false && snapshot.settings?.enabled && !snapshot.error && AppState.currentState === 'active')
363
+ recent.sync(snapshot.messages.filter(m => snapshot.settings?.features[m.kind]));
364
+ else
365
+ recent.clear(); };
366
+ sync();
367
+ const unsubscribe = props.client.subscribe(sync);
368
+ const app = AppState.addEventListener('change', s => { if (s === 'active')
369
+ sync();
370
+ else
371
+ recent.clear(); });
372
+ return () => { unsubscribe(); app.remove(); cacheDisposal.current.set(recent, setTimeout(() => { recent.clear(); cacheDisposal.current.delete(recent); }, 0)); };
373
+ }, [recent, props.client, props.active, preloadRecentMedia]);
374
+ const InlineVoice = useMemo(() => function InlineVoice(bubble) {
375
+ const media = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
376
+ const { design } = useMessagingUI();
377
+ const selected = message.current?.id === bubble.message.id && message.current?.conversationId === bubble.message.conversationId && media.kind === 'voice';
378
+ if (selected && media.phase === 'viewing' && media.file)
379
+ return _jsx(VoicePlayer, { file: media.file, audio: audioSession, design: design, inline: bubble, autoPlay: true }, media.file.uri);
380
+ return _jsx(VoicePlayback, { ...bubble, durationMs: bubble.message.media?.durationMs, loading: selected && media.phase === 'opening', error: selected ? media.error ?? undefined : undefined, onToggle: bubble.onOpen });
381
+ }, [controller, audioSession]);
382
+ const InlineComposer = useMemo(() => function InlineComposer(ui) {
383
+ const media = useSyncExternalStore(recorder.subscribe, recorder.getSnapshot, recorder.getSnapshot);
384
+ const capturing = media.kind === 'voice' && !['opening', 'viewing'].includes(media.phase);
385
+ const phase = capturing && media.phase !== 'choosing' && media.phase !== 'opening' && media.phase !== 'viewing' ? media.phase : 'idle';
386
+ const remove = () => { void recorder.cancel(); };
387
+ 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 });
388
+ }, [recorder, audioSession]);
389
+ const VoiceAttachment = useMemo(() => function VoiceAttachment(ui) { const file = recorder.getPrepared(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]);
390
+ const conversation = useRef(state.conversationId);
391
+ const disposal = useRef(new Map());
392
+ useEffect(() => {
393
+ if (conversation.current !== state.conversationId || props.active === false || !state.settings?.enabled || message.current && !state.messages.some(m => m.id === message.current.id && !m.deleted)) {
394
+ ++openingIntent.current;
395
+ message.current = null;
396
+ void controller.cancel();
397
+ void recorder.cancel();
398
+ }
399
+ conversation.current = state.conversationId;
400
+ }, [state, props.active, controller, recorder]);
401
+ useEffect(() => {
402
+ const controllers = [controller, recorder];
403
+ for (const item of controllers) {
404
+ const pending = disposal.current.get(item);
405
+ if (pending) {
406
+ clearTimeout(pending);
407
+ disposal.current.delete(item);
408
+ }
409
+ }
410
+ const sub = AppState.addEventListener('change', s => { if (s !== 'active')
411
+ void recorder.interrupt(); });
412
+ return () => { ++openingIntent.current; sub.remove(); for (const item of controllers)
413
+ disposal.current.set(item, setTimeout(() => { disposal.current.delete(item); void item.dispose(); }, 0)); };
414
+ }, [controller, recorder]);
415
+ const screenProps = { ...props,
416
+ components: { VoiceMessage: InlineVoice, VoiceComposer: InlineComposer, VoiceAttachment, ...props.components },
417
+ releaseAttachment: media => { void recorder.releasePrepared(media.assetId); void props.releaseAttachment?.(media); },
418
+ pickAttachment: (kind, options) => { if (kind === 'voice')
419
+ return recorder.pick('voice', undefined, 'attach'); message.current = null; chosen.current = options.attachmentId; return controller.pick(kind, options.source, options.intent); },
420
+ openAttachment: async (m) => { const intent = ++openingIntent.current; await stopVoicePlayers(); if (intent !== openingIntent.current)
421
+ return; opened.current = { messageId: m.id, kind: m.kind, started: Date.now(), reported: false }; message.current = m; await controller.open(m); },
422
+ };
423
+ const mediaReady = (stage, uri) => { const event = opened.current; if (!event || event.reported || controller.getSnapshot().file?.uri !== uri)
424
+ return; event.reported = true; try {
425
+ current.current.onMediaReady?.({ messageId: event.messageId, kind: event.kind, stage, elapsedMs: Date.now() - event.started });
426
+ }
427
+ catch { } };
428
+ 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, { inlineVoice: true, inlineRecorder: !!state.settings?.features.voice, controller: controller, audio: audioSession, appearance: props.appearance ?? state.settings?.appearance ?? defaultMessagingSettings().appearance })] }) });
429
+ }
@@ -0,0 +1,7 @@
1
+ import React from "react";
2
+ import { type RobonoIconProps } from "./visual/RobonoIcon.js";
3
+ export declare function MessageIcon(props: RobonoIconProps): React.JSX.Element;
4
+ export declare function duration(ms?: number): string;
5
+ export declare function fileSize(bytes?: number): string;
6
+ /** Neutral app chrome stays legible for light and dark customer palettes. */
7
+ export declare function chatSurface(background: string): "#ffffff" | "#1e262b";
package/dist/icons.js ADDED
@@ -0,0 +1,10 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { RobonoIcon } from "./visual/RobonoIcon.js";
3
+ export function MessageIcon(props) { return _jsx(RobonoIcon, { ...props }); }
4
+ export function duration(ms = 0) { const seconds = Math.ceil(ms / 1000); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; }
5
+ export function fileSize(bytes = 0) { return bytes >= 1048576 ? `${(bytes / 1048576).toFixed(1)} MB` : bytes >= 1024 ? `${Math.ceil(bytes / 1024)} KB` : `${bytes} B`; }
6
+ /** Neutral app chrome stays legible for light and dark customer palettes. */
7
+ export function chatSurface(background) {
8
+ const rgb = background.slice(1).match(/../g)?.map(v => parseInt(v, 16)) ?? [255, 255, 255];
9
+ return rgb[0] * 0.299 + rgb[1] * 0.587 + rgb[2] * 0.114 < 128 ? "#1e262b" : "#ffffff";
10
+ }
@@ -0,0 +1,101 @@
1
+ export { MessagingSafety } from './safety.js';
2
+ export { PollMessage, PollComposer } from "./polls.js";
3
+ import React, { type ComponentType, type ReactNode } from "react";
4
+ import { type StyleProp, type ViewStyle } from "react-native";
5
+ import { type VoiceComposerProps } from "./voice-composer.js";
6
+ export { VoiceMessageComposer, VoiceDraftPlayback } from "./voice-composer.js";
7
+ export type { VoiceComposerProps } from "./voice-composer.js";
8
+ import { type VoiceBubbleProps } from "./media-ui.js";
9
+ export { VoicePlayback, MediaViewer, MediaPlaybackControls, ZoomablePhoto } from "./media-ui.js";
10
+ export type { VoiceBubbleProps } from "./media-ui.js";
11
+ import { type MessageKind, type MessageMedia, type MessagingAppearance, type MessagingClient, type MessagingMessage, type MessagingState } from "@robono/messaging";
12
+ export interface VoiceAttachmentProps {
13
+ media: MessageMedia;
14
+ appearance: MessagingAppearance;
15
+ disabled: boolean;
16
+ onRemove: () => void;
17
+ }
18
+ export interface MessagingComponents {
19
+ VoiceAttachment?: ComponentType<VoiceAttachmentProps>;
20
+ Header?: ComponentType;
21
+ Message?: ComponentType<{
22
+ message: MessagingMessage;
23
+ }>;
24
+ /** Optional inline audio adapter. The shared VoicePlayback supplies all visible controls. */
25
+ VoiceMessage?: ComponentType<VoiceBubbleProps>;
26
+ VoiceComposer?: ComponentType<VoiceComposerProps>;
27
+ List?: ComponentType;
28
+ Composer?: ComponentType;
29
+ }
30
+ /** Assets supplied by the host's authorized media library; choosing one adds a pending attachment. */
31
+ export interface AttachmentChoice {
32
+ id: string;
33
+ kind: 'image' | 'video' | 'document';
34
+ name: string;
35
+ thumbnailUri?: string;
36
+ durationMs?: number;
37
+ }
38
+ export interface MessagingScreenProps {
39
+ client: MessagingClient;
40
+ /** Target language for this viewer; defaults to the device locale. */
41
+ language?: string;
42
+ userId: string;
43
+ contact: {
44
+ name: string;
45
+ avatarUrl?: string;
46
+ };
47
+ /** Resolve group member names through your app's authorized contact directory. */
48
+ participants?: Record<string, {
49
+ name: string;
50
+ avatarUrl?: string;
51
+ }>;
52
+ appearance?: MessagingAppearance;
53
+ components?: MessagingComponents;
54
+ style?: StyleProp<ViewStyle>;
55
+ /** Set false when this conversation is covered by another route. */
56
+ active?: boolean;
57
+ onBack?: () => void;
58
+ /** Optional override for the supplied contact safety menu; use your existing safety flow. */
59
+ onContactOptions?: () => void;
60
+ onMessageOptions?: (message: MessagingMessage) => void;
61
+ /** Optional recent media shown beneath the attachment buttons. */
62
+ attachmentChoices?: AttachmentChoice[];
63
+ /** Pick, capture or record, then upload to your private store. Return an opaque asset reference. */
64
+ pickAttachment?: (kind: Exclude<MessageKind, "text" | "poll">, options: {
65
+ maxDurationMs?: number;
66
+ maxFileBytes?: number;
67
+ allowedMimeTypes?: string[];
68
+ source?: "camera" | "call-video";
69
+ attachmentId?: string;
70
+ intent?: "send" | "preview" | "attach";
71
+ }) => Promise<MessageMedia | null>;
72
+ /** Release local draft resources after removal or submission; keep uploaded assets needed by retries. */
73
+ releaseAttachment?: (media: MessageMedia) => void | Promise<void>;
74
+ /** Resolve an authorized thumbnail and/or normalized waveform. Never put signed URLs in message data. */
75
+ attachmentPreview?: (message: MessagingMessage) => Promise<{
76
+ uri?: string;
77
+ waveform?: number[];
78
+ }>;
79
+ /** Authorize the asset and open it using your app's media player/viewer. */
80
+ openAttachment?: (message: MessagingMessage) => Promise<void>;
81
+ }
82
+ interface MessagingContext extends MessagingScreenProps {
83
+ state: MessagingState;
84
+ design: MessagingAppearance;
85
+ reply: MessagingMessage | null;
86
+ setReply: (message: MessagingMessage | null) => void;
87
+ }
88
+ export declare function useMessagingUI(): MessagingContext;
89
+ export declare function MessagingProvider({ children, ...props }: MessagingScreenProps & {
90
+ children: ReactNode;
91
+ }): React.JSX.Element;
92
+ export declare function MessagingHeader({ children }?: {
93
+ children?: ReactNode;
94
+ }): React.JSX.Element;
95
+ export declare function MessagingBubble({ message }: {
96
+ message: MessagingMessage;
97
+ }): React.JSX.Element;
98
+ export declare function MessagingList(): React.JSX.Element;
99
+ export declare function MessagingComposer(): React.JSX.Element;
100
+ /** Embed inside your app's safe area and navigation. No engine, push or global audio side effects. */
101
+ export declare function MessagingScreen(props: MessagingScreenProps): React.JSX.Element;