@robono/messaging-react-native 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +160 -8
  3. package/SBOM.spdx.json +1985 -209
  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-download.d.ts +3 -0
  13. package/dist/expo-download.js +43 -0
  14. package/dist/expo-history.d.ts +3 -0
  15. package/dist/expo-history.js +59 -0
  16. package/dist/expo-media.d.ts +1 -0
  17. package/dist/expo-media.js +69 -43
  18. package/dist/expo-messenger.d.ts +35 -0
  19. package/dist/expo-messenger.js +39 -0
  20. package/dist/expo-notifications.d.ts +31 -0
  21. package/dist/expo-notifications.js +92 -0
  22. package/dist/expo-outbox.d.ts +13 -0
  23. package/dist/expo-outbox.js +82 -0
  24. package/dist/expo-privacy.d.ts +4 -0
  25. package/dist/expo-privacy.js +7 -0
  26. package/dist/expo-saved.d.ts +24 -0
  27. package/dist/expo-saved.js +76 -0
  28. package/dist/expo-screen.d.ts +42 -0
  29. package/dist/expo-screen.js +557 -0
  30. package/dist/expo-social.d.ts +3 -0
  31. package/dist/expo-social.js +21 -0
  32. package/dist/expo-thumbnails.d.ts +9 -0
  33. package/dist/expo-thumbnails.js +68 -0
  34. package/dist/expo.d.ts +2 -36
  35. package/dist/expo.js +2 -429
  36. package/dist/extras-lifecycle.d.ts +2 -0
  37. package/dist/extras-lifecycle.js +27 -0
  38. package/dist/extras.d.ts +49 -0
  39. package/dist/extras.js +49 -0
  40. package/dist/forward.d.ts +13 -0
  41. package/dist/forward.js +62 -0
  42. package/dist/group-editor.d.ts +18 -0
  43. package/dist/group-editor.js +98 -0
  44. package/dist/header-calls.d.ts +18 -0
  45. package/dist/header-calls.js +45 -0
  46. package/dist/icons.d.ts +1 -1
  47. package/dist/inbox-search.d.ts +11 -0
  48. package/dist/inbox-search.js +42 -0
  49. package/dist/inbox.d.ts +19 -0
  50. package/dist/inbox.js +52 -0
  51. package/dist/index.d.ts +76 -3
  52. package/dist/index.js +648 -167
  53. package/dist/media-controller.d.ts +21 -0
  54. package/dist/media-controller.js +157 -3
  55. package/dist/media-readiness.d.ts +1 -1
  56. package/dist/media-ui.d.ts +27 -5
  57. package/dist/media-ui.js +95 -29
  58. package/dist/message-links.d.ts +5 -0
  59. package/dist/message-links.js +30 -0
  60. package/dist/message-menu.d.ts +28 -0
  61. package/dist/message-menu.js +24 -0
  62. package/dist/message-order.d.ts +7 -0
  63. package/dist/message-order.js +27 -0
  64. package/dist/messenger-video-view.d.ts +8 -0
  65. package/dist/messenger-video-view.js +46 -0
  66. package/dist/native-outbox.d.ts +55 -0
  67. package/dist/native-outbox.js +53 -0
  68. package/dist/photo-gesture.d.ts +20 -0
  69. package/dist/photo-gesture.js +52 -0
  70. package/dist/pollVoteQueue.d.ts +3 -0
  71. package/dist/pollVoteQueue.js +29 -0
  72. package/dist/polls.d.ts +7 -2
  73. package/dist/polls.js +89 -20
  74. package/dist/popup.d.ts +51 -0
  75. package/dist/popup.js +73 -0
  76. package/dist/quick-send.d.ts +15 -0
  77. package/dist/quick-send.js +26 -0
  78. package/dist/recent-media.js +1 -0
  79. package/dist/safety.d.ts +10 -1
  80. package/dist/safety.js +29 -8
  81. package/dist/saved.d.ts +10 -0
  82. package/dist/saved.js +51 -0
  83. package/dist/search.d.ts +9 -0
  84. package/dist/search.js +59 -0
  85. package/dist/social.d.ts +25 -0
  86. package/dist/social.js +31 -0
  87. package/dist/translation-settings.d.ts +12 -0
  88. package/dist/translation-settings.js +16 -0
  89. package/dist/use-quote-gesture.d.ts +5 -1
  90. package/dist/use-quote-gesture.js +35 -17
  91. package/dist/visual/RobonoIcon.js +2 -1
  92. package/dist/visual/WeatherArtwork.d.ts +9 -0
  93. package/dist/visual/WeatherArtwork.js +43 -0
  94. package/dist/visual/weather-artwork.d.ts +17 -0
  95. package/dist/visual/weather-artwork.js +86 -0
  96. package/dist/visual/weather-conditions.d.ts +5 -0
  97. package/dist/visual/weather-conditions.js +2 -0
  98. package/dist/voice-composer.js +2 -2
  99. package/dist/voice-waveform.d.ts +2 -0
  100. package/dist/voice-waveform.js +21 -0
  101. package/package.json +41 -5
@@ -0,0 +1,68 @@
1
+ import * as ImageManipulator from 'expo-image-manipulator';
2
+ import * as VideoThumbnails from 'expo-video-thumbnails';
3
+ import * as FS from 'expo-file-system/legacy';
4
+ /** Small local previews, with bounded downloads and memory. Large originals stay
5
+ * tap-to-open until the storage service supplies a dedicated thumbnail. */
6
+ export function createExpoThumbnails(storage, local, remove) {
7
+ let generation = 0;
8
+ const cache = new Map(), abort = new Set();
9
+ let queue = Promise.resolve();
10
+ function clear() { generation++; cache.clear(); for (const c of abort)
11
+ c.abort(); abort.clear(); }
12
+ function preview(message) {
13
+ const media = message.media;
14
+ if (!media || message.deleted)
15
+ return Promise.resolve({});
16
+ if (message.kind === 'voice')
17
+ return Promise.resolve({ waveform: media.waveform });
18
+ if (!['image', 'video'].includes(message.kind))
19
+ return Promise.resolve({});
20
+ const id = media.assetId, existing = cache.get(id);
21
+ if (existing)
22
+ return existing;
23
+ const g = generation;
24
+ const work = queue.then(async () => {
25
+ if (g !== generation)
26
+ return {};
27
+ let file = local(id), downloaded = false, poster, thumbnail;
28
+ const controller = new AbortController();
29
+ abort.add(controller);
30
+ try {
31
+ let dedicated = false;
32
+ if (!file && storage.thumbnail) {
33
+ file = await storage.thumbnail(message, { signal: controller.signal, onProgress: () => { } }).catch(() => null) ?? undefined;
34
+ dedicated = !!file;
35
+ downloaded = !!file;
36
+ }
37
+ if (!file) {
38
+ if (media.byteSize > 2 * 1024 * 1024)
39
+ return {};
40
+ file = await storage.download(message, { signal: controller.signal, onProgress: () => { } });
41
+ downloaded = true;
42
+ }
43
+ if (g !== generation)
44
+ return {};
45
+ if (message.kind === 'video' && !dedicated)
46
+ poster = (await VideoThumbnails.getThumbnailAsync(file.uri, { time: 0, quality: .6 })).uri;
47
+ const result = await ImageManipulator.manipulateAsync(poster ?? file.uri, [{ resize: { width: 480 } }], { compress: .7, format: ImageManipulator.SaveFormat.JPEG, base64: true });
48
+ thumbnail = result.uri;
49
+ return g === generation && result.base64 ? { uri: 'data:image/jpeg;base64,' + result.base64 } : {};
50
+ }
51
+ finally {
52
+ abort.delete(controller);
53
+ if (downloaded && file)
54
+ await remove(file).catch(() => { });
55
+ for (const uri of [poster, thumbnail])
56
+ if (uri && FS.cacheDirectory && uri.startsWith(FS.cacheDirectory))
57
+ await FS.deleteAsync(uri, { idempotent: true }).catch(() => { });
58
+ }
59
+ }).catch(() => ({}));
60
+ queue = work.then(() => { });
61
+ cache.set(id, work);
62
+ if (cache.size > 100)
63
+ cache.delete(cache.keys().next().value);
64
+ return work;
65
+ }
66
+ return { preview, clear, alias: (from, to) => { const value = cache.get(from); if (value)
67
+ cache.set(to, value); } };
68
+ }
package/dist/expo.d.ts CHANGED
@@ -1,36 +1,2 @@
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;
1
+ export * from './expo-screen.js';
2
+ export { ExpoMessenger, createExpoMessenger, type ExpoMessengerProps } from './expo-messenger.js';
package/dist/expo.js CHANGED
@@ -1,429 +1,2 @@
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
- }
1
+ export * from './expo-screen.js';
2
+ export { ExpoMessenger, createExpoMessenger } from './expo-messenger.js';
@@ -0,0 +1,2 @@
1
+ import type { MessagingExtrasClient } from '@robono/messaging';
2
+ export declare function retainExtrasLifecycle(extras: MessagingExtrasClient): () => void;
@@ -0,0 +1,27 @@
1
+ import { AppState } from 'react-native';
2
+ // Keep foreground shares alive while the user changes chats or visits the inbox.
3
+ // The last screen may leave; background/logout still stop capture immediately.
4
+ const clients = new WeakMap();
5
+ export function retainExtrasLifecycle(extras) {
6
+ let entry = clients.get(extras);
7
+ if (!entry) {
8
+ const next = { users: 0, release: () => { } };
9
+ const subscription = AppState.addEventListener('change', state => {
10
+ if (state !== 'active')
11
+ extras.pause();
12
+ else if (next.users)
13
+ void extras.refresh();
14
+ });
15
+ const cleanup = () => { if (!next.users && !extras.hasActiveTracking()) {
16
+ subscription.remove();
17
+ unsubscribe();
18
+ clients.delete(extras);
19
+ } };
20
+ const unsubscribe = extras.subscribe(cleanup);
21
+ next.release = () => { next.users--; cleanup(); };
22
+ clients.set(extras, next);
23
+ entry = next;
24
+ }
25
+ entry.users++;
26
+ return entry.release;
27
+ }
@@ -0,0 +1,49 @@
1
+ import React, { type ReactNode } from 'react';
2
+ import { type ChatExtra, type ChatTracker, type ChatDestination, type MessagingExtrasClient } from '@robono/messaging';
3
+ /** Render every supplied tracker with an avatar pin and a speed/ETA callout.
4
+ * Use height for the available map viewport and imperial/now for synchronized labels. */
5
+ export interface TrackingMapProps {
6
+ destination: ChatDestination;
7
+ trackers: ChatTracker[];
8
+ accent: string;
9
+ height?: number;
10
+ imperial?: boolean;
11
+ now?: number;
12
+ selectedUserId?: string;
13
+ onSelect?: (id: string) => void;
14
+ }
15
+ export interface PrivacyLayerProps {
16
+ children: ReactNode;
17
+ enabled: boolean;
18
+ background: string;
19
+ }
20
+ export declare const privacyReadingStripHeight = 74;
21
+ /** Messenger's fixed 74px reading strip. Scrolling moves messages through it. */
22
+ export declare function MessagingPrivacyLayer({ children, enabled, background }: PrivacyLayerProps): React.JSX.Element;
23
+ export declare function openChatAddress(address: string): Promise<void>;
24
+ export declare function ExtraPopup({ title, onClose, background, foreground, children, expanded }: {
25
+ title: string;
26
+ onClose: () => void;
27
+ background: string;
28
+ foreground: string;
29
+ children: ReactNode;
30
+ expanded?: boolean;
31
+ }): React.JSX.Element;
32
+ export declare const MessageExtra: React.ForwardRefExoticComponent<{
33
+ addressBubbleHeight?: number;
34
+ extra: ChatExtra;
35
+ messageId: string;
36
+ userId: string;
37
+ extras: MessagingExtrasClient;
38
+ ink: string;
39
+ accent: string;
40
+ background: string;
41
+ foreground: string;
42
+ footer?: ReactNode;
43
+ renderAddress?: (action: ReactNode, participants: ReactNode) => ReactNode;
44
+ Map?: React.ComponentType<TrackingMapProps>;
45
+ onLongPress?: () => void;
46
+ onCall?: (kind: "voice" | "video") => void;
47
+ } & React.RefAttributes<{
48
+ open: () => void;
49
+ }>>;