@robono/messaging-react-native 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +130 -0
- package/README.md +240 -2
- package/SBOM.spdx.json +1847 -0
- package/app.plugin.cjs +5 -0
- package/dist/call-message.d.ts +6 -0
- package/dist/call-message.js +109 -0
- package/dist/calling.d.ts +15 -0
- package/dist/calling.js +22 -0
- package/dist/expo-calling.d.ts +6 -0
- package/dist/expo-calling.js +7 -0
- package/dist/expo-download.d.ts +3 -0
- package/dist/expo-download.js +43 -0
- package/dist/expo-media.d.ts +19 -0
- package/dist/expo-media.js +411 -0
- package/dist/expo-outbox.d.ts +13 -0
- package/dist/expo-outbox.js +81 -0
- package/dist/expo-saved.d.ts +24 -0
- package/dist/expo-saved.js +76 -0
- package/dist/expo-social.d.ts +3 -0
- package/dist/expo-social.js +21 -0
- package/dist/expo-thumbnails.d.ts +9 -0
- package/dist/expo-thumbnails.js +68 -0
- package/dist/expo.d.ts +42 -0
- package/dist/expo.js +549 -0
- package/dist/forward.d.ts +13 -0
- package/dist/forward.js +61 -0
- package/dist/icons.d.ts +7 -0
- package/dist/icons.js +10 -0
- package/dist/inbox.d.ts +16 -0
- package/dist/inbox.js +48 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.js +696 -0
- package/dist/media-controller.d.ts +116 -0
- package/dist/media-controller.js +372 -0
- package/dist/media-readiness.d.ts +4 -0
- package/dist/media-readiness.js +4 -0
- package/dist/media-ui.d.ts +62 -0
- package/dist/media-ui.js +46 -0
- package/dist/message-links.d.ts +5 -0
- package/dist/message-links.js +30 -0
- package/dist/message-order.d.ts +4 -0
- package/dist/message-order.js +17 -0
- package/dist/native-outbox.d.ts +49 -0
- package/dist/native-outbox.js +51 -0
- package/dist/pollVoteQueue.d.ts +3 -0
- package/dist/pollVoteQueue.js +29 -0
- package/dist/polls.d.ts +25 -0
- package/dist/polls.js +121 -0
- package/dist/popup.d.ts +9 -0
- package/dist/popup.js +7 -0
- package/dist/recent-media.d.ts +21 -0
- package/dist/recent-media.js +123 -0
- package/dist/safety.d.ts +12 -0
- package/dist/safety.js +53 -0
- package/dist/saved.d.ts +10 -0
- package/dist/saved.js +46 -0
- package/dist/search.d.ts +9 -0
- package/dist/search.js +41 -0
- package/dist/social.d.ts +19 -0
- package/dist/social.js +35 -0
- package/dist/use-quote-gesture.d.ts +24 -0
- package/dist/use-quote-gesture.js +127 -0
- package/dist/visual/RobonoIcon.d.ts +232 -0
- package/dist/visual/RobonoIcon.js +21 -0
- package/dist/visual/icon-nodes.d.ts +3 -0
- package/dist/visual/icon-nodes.js +2 -0
- package/dist/visual/icon-paths.d.ts +221 -0
- package/dist/visual/icon-paths.js +221 -0
- package/dist/voice-composer.d.ts +37 -0
- package/dist/voice-composer.js +20 -0
- package/licenses/robono-icons-lucide-license.txt +43 -0
- package/licenses/robono-icons-weather-attribution.txt +1 -0
- package/licenses/robono-icons-weather-license.txt +21 -0
- package/package.json +126 -4
- package/plugin/preserve-call-audio.cjs +22 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { AudioModule, RecordingPresets, createAudioPlayer, setAudioModeAsync } from 'expo-audio';
|
|
2
|
+
import * as Picker from 'expo-image-picker';
|
|
3
|
+
import * as Documents from 'expo-document-picker';
|
|
4
|
+
import * as FS from 'expo-file-system/legacy';
|
|
5
|
+
import * as Crypto from 'expo-crypto';
|
|
6
|
+
import { defaultAttachmentLimits } from '@robono/messaging';
|
|
7
|
+
let audioOwner = false;
|
|
8
|
+
/** Pass your app's call-state guard and restore mode, or supply your existing audio-session coordinator. */
|
|
9
|
+
export function createExpoMessagingAudioSession(options = {}) {
|
|
10
|
+
return { isAvailable: (purpose = 'playback') => purpose === 'playback' || !options.isCallActive?.(), async acquire(purpose) {
|
|
11
|
+
if (audioOwner)
|
|
12
|
+
throw Error('Finish the current recording first.');
|
|
13
|
+
if (purpose === 'recording' && options.isCallActive?.())
|
|
14
|
+
throw Error('Finish the current call before recording.');
|
|
15
|
+
audioOwner = true;
|
|
16
|
+
// The call owns its microphone, mode and output route. A playback lease
|
|
17
|
+
// borrows that session without changing any of them. Outside our call,
|
|
18
|
+
// mix rather than requesting exclusive focus from another calling app.
|
|
19
|
+
const borrowed = purpose === 'playback' && !!options.isCallActive?.();
|
|
20
|
+
try {
|
|
21
|
+
if (!borrowed)
|
|
22
|
+
await setAudioModeAsync({ allowsRecording: purpose === 'recording', playsInSilentMode: true, shouldPlayInBackground: false, shouldRouteThroughEarpiece: false, interruptionMode: purpose === 'recording' ? 'doNotMix' : 'mixWithOthers' });
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
audioOwner = false;
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
let released = false;
|
|
29
|
+
return async () => {
|
|
30
|
+
if (released)
|
|
31
|
+
return;
|
|
32
|
+
released = true;
|
|
33
|
+
try {
|
|
34
|
+
if (!borrowed && !options.isCallActive?.())
|
|
35
|
+
await setAudioModeAsync(options.restoreMode ?? { allowsRecording: false, playsInSilentMode: true, shouldPlayInBackground: false, shouldRouteThroughEarpiece: false, interruptionMode: 'mixWithOthers' });
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
audioOwner = false;
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
} };
|
|
42
|
+
}
|
|
43
|
+
function check(signal) { if (signal.aborted)
|
|
44
|
+
throw Error('Cancelled.'); }
|
|
45
|
+
const cacheRoot = () => { if (!FS.cacheDirectory)
|
|
46
|
+
throw Error('Private device storage is unavailable.'); return FS.cacheDirectory + 'robono-messaging/'; };
|
|
47
|
+
const ownedDirectories = new Set();
|
|
48
|
+
async function cacheDirectory() { const dir = cacheRoot() + Crypto.randomUUID() + '/'; await FS.makeDirectoryAsync(dir, { intermediates: true }); ownedDirectories.add(dir); return dir; }
|
|
49
|
+
const cacheName = (name) => name.replace(/[^a-zA-Z0-9._-]/g, '_').slice(-90) || 'Attachment';
|
|
50
|
+
async function cachePath(name) { return (await cacheDirectory()) + cacheName(name); }
|
|
51
|
+
async function removeUri(uri) {
|
|
52
|
+
if (!uri.startsWith(cacheRoot()))
|
|
53
|
+
return;
|
|
54
|
+
const dir = uri.slice(0, uri.lastIndexOf('/') + 1);
|
|
55
|
+
// Each owned directory contains exactly one private copy. Remove it in one
|
|
56
|
+
// native operation rather than making the next open wait for two deletions.
|
|
57
|
+
if (ownedDirectories.has(dir)) {
|
|
58
|
+
await FS.deleteAsync(dir, { idempotent: true });
|
|
59
|
+
ownedDirectories.delete(dir);
|
|
60
|
+
}
|
|
61
|
+
else
|
|
62
|
+
await FS.deleteAsync(uri, { idempotent: true });
|
|
63
|
+
}
|
|
64
|
+
async function remove(file) { await removeUri(file.uri); }
|
|
65
|
+
async function pickedCopy(uri, fileName, mimeType, durationMs) {
|
|
66
|
+
try {
|
|
67
|
+
return await copied(uri, fileName, mimeType, durationMs);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
if (FS.cacheDirectory && uri.startsWith(FS.cacheDirectory))
|
|
71
|
+
await FS.deleteAsync(uri, { idempotent: true }).catch(() => { });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function copyMessagingCache(file) { return copied(file.uri, file.fileName, file.mimeType, file.durationMs); }
|
|
75
|
+
async function copied(uri, fileName, mimeType, durationMs) {
|
|
76
|
+
const target = await cachePath(fileName);
|
|
77
|
+
try {
|
|
78
|
+
await FS.copyAsync({ from: uri, to: target });
|
|
79
|
+
const info = await FS.getInfoAsync(target);
|
|
80
|
+
if (!info.exists || info.isDirectory)
|
|
81
|
+
throw Error('The file could not be read.');
|
|
82
|
+
return { uri: target, fileName, mimeType, byteSize: info.size, ...(durationMs === undefined ? {} : { durationMs: Math.ceil(durationMs) }) };
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
await removeUri(target).catch(() => { });
|
|
86
|
+
throw e;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function recordingDuration(uri) {
|
|
90
|
+
// Android resets recorder status to zero after automatic stop. Read the finished file.
|
|
91
|
+
const player = createAudioPlayer(uri);
|
|
92
|
+
try {
|
|
93
|
+
const deadline = Date.now() + 5000;
|
|
94
|
+
while (!player.isLoaded && Date.now() < deadline)
|
|
95
|
+
await new Promise(r => setTimeout(r, 40));
|
|
96
|
+
const ms = Math.ceil(player.duration * 1000);
|
|
97
|
+
if (!player.isLoaded || !Number.isFinite(ms) || ms < 1)
|
|
98
|
+
throw Error('The recording length could not be read. Record again.');
|
|
99
|
+
return ms;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
player.remove();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const extensionMimes = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', heic: 'image/heic', heif: 'image/heif', avif: 'image/avif', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', '3gp': 'video/3gpp', pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', zip: 'application/zip', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' };
|
|
106
|
+
function mime(value, name) { return value?.toLowerCase() || extensionMimes[name.split('.').at(-1)?.toLowerCase() ?? ''] || 'application/octet-stream'; }
|
|
107
|
+
export function createExpoMessagingDevice(audio) {
|
|
108
|
+
let recorder = null, release = null;
|
|
109
|
+
let recordingDeadline = 0;
|
|
110
|
+
let starting = null;
|
|
111
|
+
let stopping = null;
|
|
112
|
+
return {
|
|
113
|
+
async pick(kind, settings, signal, source) {
|
|
114
|
+
if (source === 'camera' && (!['image', 'video'].includes(kind) || !settings.appearance.showCamera))
|
|
115
|
+
throw Error('Camera capture is unavailable.');
|
|
116
|
+
if (source === 'call-video' && (kind !== 'video' || !settings.enabled || !settings.features.video || settings.unansweredCalls?.video !== 'video'))
|
|
117
|
+
throw Error('Video messages after a call are unavailable.');
|
|
118
|
+
const limits = (settings.attachments ?? defaultAttachmentLimits())[kind];
|
|
119
|
+
if (kind === 'document') {
|
|
120
|
+
const result = await Documents.getDocumentAsync({ type: limits.allowedMimeTypes, multiple: false, copyToCacheDirectory: true });
|
|
121
|
+
if (signal.aborted) {
|
|
122
|
+
for (const a of result.assets ?? [])
|
|
123
|
+
if (FS.cacheDirectory && a.uri.startsWith(FS.cacheDirectory))
|
|
124
|
+
await FS.deleteAsync(a.uri, { idempotent: true }).catch(() => { });
|
|
125
|
+
check(signal);
|
|
126
|
+
}
|
|
127
|
+
if (result.canceled)
|
|
128
|
+
return null;
|
|
129
|
+
const asset = result.assets[0];
|
|
130
|
+
if (!asset)
|
|
131
|
+
return null;
|
|
132
|
+
return pickedCopy(asset.uri, asset.name, mime(asset.mimeType, asset.name));
|
|
133
|
+
}
|
|
134
|
+
if ((source === 'camera' || source === 'call-video')) {
|
|
135
|
+
const permission = await Picker.requestCameraPermissionsAsync();
|
|
136
|
+
check(signal);
|
|
137
|
+
if (!permission.granted)
|
|
138
|
+
throw Error('Camera permission is needed to take photos or record videos.');
|
|
139
|
+
}
|
|
140
|
+
const result = await ((source === 'camera' || source === 'call-video') ? Picker.launchCameraAsync : Picker.launchImageLibraryAsync)({ mediaTypes: source === 'call-video' ? ['videos'] : (source === 'camera' || source === 'call-video') ? ([...((settings.features?.image ?? kind === 'image') ? ['images'] : []), ...(settings.features?.video ? ['videos'] : [])]) : kind === 'image' ? ['images'] : ['videos'], ...((source === 'camera' || source === 'call-video') ? { videoMaxDuration: settings.attachments?.video?.maxDurationSeconds ?? 60 } : {}), allowsMultipleSelection: false, allowsEditing: false, quality: 1, preferredAssetRepresentationMode: Picker.UIImagePickerPreferredAssetRepresentationMode.Current });
|
|
141
|
+
if (signal.aborted) {
|
|
142
|
+
for (const a of result.assets ?? [])
|
|
143
|
+
if (FS.cacheDirectory && a.uri.startsWith(FS.cacheDirectory))
|
|
144
|
+
await FS.deleteAsync(a.uri, { idempotent: true }).catch(() => { });
|
|
145
|
+
check(signal);
|
|
146
|
+
}
|
|
147
|
+
if (result.canceled)
|
|
148
|
+
return null;
|
|
149
|
+
const asset = result.assets[0];
|
|
150
|
+
if (!asset)
|
|
151
|
+
return null;
|
|
152
|
+
const video = (source === 'camera' || source === 'call-video') ? (asset.type === 'video' || asset.mimeType?.startsWith('video/')) : kind === 'video';
|
|
153
|
+
const name = asset.fileName ?? asset.uri.split('/').at(-1) ?? (kind === 'image' ? 'Photo' : 'Video');
|
|
154
|
+
if (video && (!asset.duration || !Number.isFinite(asset.duration))) {
|
|
155
|
+
if (FS.cacheDirectory && asset.uri.startsWith(FS.cacheDirectory))
|
|
156
|
+
await FS.deleteAsync(asset.uri, { idempotent: true }).catch(() => { });
|
|
157
|
+
throw Error('The video length could not be read. Choose another video.');
|
|
158
|
+
}
|
|
159
|
+
return pickedCopy(asset.uri, name, mime(asset.mimeType, name), video ? asset.duration : undefined);
|
|
160
|
+
},
|
|
161
|
+
async startRecording(maximum, signal) {
|
|
162
|
+
if (starting || recorder)
|
|
163
|
+
throw Error('A recording is already in progress.');
|
|
164
|
+
const work = (async () => {
|
|
165
|
+
const permission = await AudioModule.requestRecordingPermissionsAsync();
|
|
166
|
+
check(signal);
|
|
167
|
+
if (!permission.granted)
|
|
168
|
+
throw Error('Microphone permission is needed to record a voice message.');
|
|
169
|
+
release = await audio.acquire('recording');
|
|
170
|
+
try {
|
|
171
|
+
check(signal);
|
|
172
|
+
recorder = new AudioModule.AudioRecorder({ ...RecordingPresets.HIGH_QUALITY, isMeteringEnabled: true });
|
|
173
|
+
await recorder.prepareToRecordAsync({ ...RecordingPresets.HIGH_QUALITY, isMeteringEnabled: true });
|
|
174
|
+
check(signal); // Leave room for AAC encoder/container padding; the backend checks final file duration.
|
|
175
|
+
const captureMs = Math.max(100, maximum - 100);
|
|
176
|
+
recorder.record({ forDuration: captureMs / 1000 });
|
|
177
|
+
recordingDeadline = Date.now() + captureMs;
|
|
178
|
+
}
|
|
179
|
+
catch (e) {
|
|
180
|
+
if (recorder) {
|
|
181
|
+
await recorder.stop().catch(() => { });
|
|
182
|
+
const uri = recorder.uri;
|
|
183
|
+
recorder.release();
|
|
184
|
+
recorder = null;
|
|
185
|
+
if (uri?.startsWith(FS.cacheDirectory ?? 'file://unavailable/'))
|
|
186
|
+
await FS.deleteAsync(uri, { idempotent: true }).catch(() => { });
|
|
187
|
+
}
|
|
188
|
+
await release?.();
|
|
189
|
+
release = null;
|
|
190
|
+
throw e;
|
|
191
|
+
}
|
|
192
|
+
})();
|
|
193
|
+
starting = work;
|
|
194
|
+
try {
|
|
195
|
+
await work;
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
if (starting === work)
|
|
199
|
+
starting = null;
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
recordingStatus() { const state = recorder?.getStatus(); return { durationMs: state?.durationMillis ?? 0, level: typeof state?.metering === 'number' ? Math.pow(10, state.metering / 40) : undefined, recording: !!state?.isRecording && audio.isAvailable?.('recording') !== false, limitReached: !state?.isRecording && Date.now() >= recordingDeadline }; },
|
|
203
|
+
async stopRecording() {
|
|
204
|
+
if (stopping)
|
|
205
|
+
return stopping;
|
|
206
|
+
const work = (async () => {
|
|
207
|
+
if (starting)
|
|
208
|
+
await starting.catch(() => { });
|
|
209
|
+
const current = recorder;
|
|
210
|
+
recorder = null;
|
|
211
|
+
if (!current)
|
|
212
|
+
return null;
|
|
213
|
+
let original = null;
|
|
214
|
+
try {
|
|
215
|
+
const before = current.getStatus();
|
|
216
|
+
if (before.isRecording)
|
|
217
|
+
await current.stop();
|
|
218
|
+
original = current.uri;
|
|
219
|
+
if (!original)
|
|
220
|
+
return null;
|
|
221
|
+
const duration = await recordingDuration(original);
|
|
222
|
+
return await copied(original, 'Voice message.m4a', 'audio/mp4', duration);
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
current.release();
|
|
226
|
+
if (original?.startsWith(FS.cacheDirectory ?? 'file://unavailable/'))
|
|
227
|
+
await FS.deleteAsync(original, { idempotent: true }).catch(() => { });
|
|
228
|
+
await release?.();
|
|
229
|
+
release = null;
|
|
230
|
+
}
|
|
231
|
+
})();
|
|
232
|
+
stopping = work;
|
|
233
|
+
try {
|
|
234
|
+
return await work;
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
if (stopping === work)
|
|
238
|
+
stopping = null;
|
|
239
|
+
}
|
|
240
|
+
}, remove,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function https(value) { const url = new URL(value); if (url.protocol !== 'https:' || url.username || url.password)
|
|
244
|
+
throw Error('A secure media URL is required.'); return url.href; }
|
|
245
|
+
/** Private-storage transport. The endpoint is YOUR authenticated backend, never a Robono API key on the phone. */
|
|
246
|
+
export function createExpoMessagingStorage(options) {
|
|
247
|
+
const endpoint = https(options.url);
|
|
248
|
+
async function request(operation, input, signal) {
|
|
249
|
+
const token = await options.getAccessToken();
|
|
250
|
+
if (!token)
|
|
251
|
+
throw Error('Sign in to continue.');
|
|
252
|
+
const bounded = new AbortController();
|
|
253
|
+
const cancel = () => bounded.abort();
|
|
254
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
255
|
+
if (signal?.aborted)
|
|
256
|
+
bounded.abort();
|
|
257
|
+
const timeout = setTimeout(cancel, operation === 'complete' ? 120000 : 15000);
|
|
258
|
+
try {
|
|
259
|
+
const response = await fetch(endpoint, { method: 'POST', redirect: 'error', signal: bounded.signal, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ ...input, operation }) });
|
|
260
|
+
const result = await response.json();
|
|
261
|
+
if (!response.ok) {
|
|
262
|
+
const error = Object.assign(Error(result?.error?.message ?? 'The file could not be transferred. Try again.'), { status: response.status });
|
|
263
|
+
throw error;
|
|
264
|
+
}
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
clearTimeout(timeout);
|
|
269
|
+
signal?.removeEventListener('abort', cancel);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
async function playbackPlan(message, signal) {
|
|
273
|
+
check(signal);
|
|
274
|
+
if (message.deleted)
|
|
275
|
+
throw Error('This attachment is no longer available.');
|
|
276
|
+
const grant = message.attachmentDownload;
|
|
277
|
+
const granted = grant && grant.messageId === message.id && grant.conversationId === message.conversationId && grant.assetId === message.media?.assetId && Number.isFinite(grant.expiresAt) && grant.expiresAt > Date.now() + 5000 && grant.expiresAt <= Date.now() + 60000;
|
|
278
|
+
const plan = granted ? { url: grant.url, media: message.media, headers: undefined } : await request('download', { conversationId: message.conversationId, messageId: message.id }, signal);
|
|
279
|
+
check(signal);
|
|
280
|
+
const media = plan.media;
|
|
281
|
+
if (!media || media.assetId !== message.media?.assetId || !Number.isSafeInteger(media.byteSize) || media.byteSize < 1 || media.byteSize > 104857600)
|
|
282
|
+
throw Error('Invalid attachment size or identity.');
|
|
283
|
+
return { ...plan, url: https(plan.url), media };
|
|
284
|
+
}
|
|
285
|
+
async function cancellable(task, context, run) {
|
|
286
|
+
check(context.signal);
|
|
287
|
+
const cancel = () => { void task.cancelAsync().catch(() => { }); };
|
|
288
|
+
context.signal.addEventListener('abort', cancel, { once: true });
|
|
289
|
+
try {
|
|
290
|
+
const result = await run();
|
|
291
|
+
check(context.signal);
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
finally {
|
|
295
|
+
context.signal.removeEventListener('abort', cancel);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
async stream(message, context) {
|
|
300
|
+
check(context.signal);
|
|
301
|
+
if (message.deleted)
|
|
302
|
+
throw Error('This attachment is no longer available.');
|
|
303
|
+
if (options.streaming === false || !['voice', 'video'].includes(message.kind) || !message.media?.assetId.startsWith('robono-r2:'))
|
|
304
|
+
return null;
|
|
305
|
+
const plan = await playbackPlan(message, context.signal);
|
|
306
|
+
// The native player requests byte ranges directly from the same private,
|
|
307
|
+
// metered gateway. No file copy or thumbnail precedes playback.
|
|
308
|
+
const { assetId, ...media } = plan.media;
|
|
309
|
+
return { ...media, uri: plan.url, stream: { headers: plan.headers } };
|
|
310
|
+
},
|
|
311
|
+
async upload(file, context) {
|
|
312
|
+
const { uri, ...metadata } = file;
|
|
313
|
+
const plan = await request('prepare', { uploadId: context.uploadId, conversationId: context.conversationId, kind: context.kind, file: metadata }, context.signal);
|
|
314
|
+
check(context.signal);
|
|
315
|
+
if (plan.media)
|
|
316
|
+
return plan.media;
|
|
317
|
+
const task = FS.createUploadTask(https(plan.url), uri, { httpMethod: 'PUT', uploadType: FS.FileSystemUploadType.BINARY_CONTENT, headers: plan.headers ?? {}, sessionType: FS.FileSystemSessionType.FOREGROUND }, p => context.onProgress(p.totalBytesExpectedToSend > 0 ? p.totalBytesSent / p.totalBytesExpectedToSend : 0));
|
|
318
|
+
const result = await cancellable(task, context, () => task.uploadAsync());
|
|
319
|
+
// An immutable PUT may already have succeeded before its response was lost.
|
|
320
|
+
// A 412 is not success by itself: complete still verifies ownership and bytes.
|
|
321
|
+
if (!result || result.status !== 412 && (result.status < 200 || result.status >= 300))
|
|
322
|
+
throw Error('Upload failed. Try again.');
|
|
323
|
+
// Verification is repeatable for the same upload. A transient inspector
|
|
324
|
+
// failure must not upload the bytes again or create a second attachment.
|
|
325
|
+
for (let attempt = 0;; attempt++) {
|
|
326
|
+
check(context.signal);
|
|
327
|
+
try {
|
|
328
|
+
const complete = await request('complete', { uploadId: context.uploadId, conversationId: context.conversationId }, context.signal);
|
|
329
|
+
return complete.media;
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
if (attempt >= 2 || ![502, 503, 504].includes(error.status ?? 0))
|
|
333
|
+
throw error;
|
|
334
|
+
check(context.signal);
|
|
335
|
+
await new Promise((resolve, reject) => {
|
|
336
|
+
const cancel = () => { clearTimeout(timer); context.signal.removeEventListener('abort', cancel); reject(Error('Cancelled')); };
|
|
337
|
+
const timer = setTimeout(() => { context.signal.removeEventListener('abort', cancel); resolve(); }, 1000 * (attempt + 1));
|
|
338
|
+
context.signal.addEventListener('abort', cancel, { once: true });
|
|
339
|
+
if (context.signal.aborted)
|
|
340
|
+
cancel();
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
},
|
|
345
|
+
async thumbnail(message, context) {
|
|
346
|
+
check(context.signal);
|
|
347
|
+
const plan = await request('thumbnail', { conversationId: message.conversationId, messageId: message.id }, context.signal);
|
|
348
|
+
if (!plan)
|
|
349
|
+
return null;
|
|
350
|
+
const media = plan.media;
|
|
351
|
+
if (media?.assetId !== message.media?.assetId || media?.mimeType !== 'image/jpeg' || !Number.isInteger(media.byteSize) || media.byteSize < 1 || media.byteSize > 131072)
|
|
352
|
+
throw Error('Invalid attachment preview.');
|
|
353
|
+
const path = await cachePath('preview.jpg');
|
|
354
|
+
try {
|
|
355
|
+
const task = FS.createDownloadResumable(https(plan.url), path, { headers: plan.headers ?? {}, sessionType: FS.FileSystemSessionType.FOREGROUND }, progress => { if (progress.totalBytesWritten > media.byteSize)
|
|
356
|
+
void task.cancelAsync().catch(() => { }); });
|
|
357
|
+
const result = await cancellable(task, context, () => task.downloadAsync());
|
|
358
|
+
const info = await FS.getInfoAsync(path);
|
|
359
|
+
if (!result || result.status !== 200 || !info.exists || info.isDirectory || info.size !== media.byteSize)
|
|
360
|
+
throw Error('Attachment preview is incomplete.');
|
|
361
|
+
return { uri: path, mimeType: 'image/jpeg', fileName: 'preview.jpg', byteSize: media.byteSize };
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
await removeUri(path).catch(() => { });
|
|
365
|
+
throw error;
|
|
366
|
+
}
|
|
367
|
+
},
|
|
368
|
+
async download(message, context) {
|
|
369
|
+
check(context.signal);
|
|
370
|
+
if (message.deleted)
|
|
371
|
+
throw Error('This attachment is no longer available.');
|
|
372
|
+
// Local directory preparation needs no media access. Run it alongside
|
|
373
|
+
// authorization, but never start the transfer until both have succeeded.
|
|
374
|
+
const [authorized, prepared] = await Promise.allSettled([
|
|
375
|
+
playbackPlan(message, context.signal),
|
|
376
|
+
cacheDirectory(),
|
|
377
|
+
]);
|
|
378
|
+
const dir = prepared.status === 'fulfilled' ? prepared.value : null;
|
|
379
|
+
let path = null;
|
|
380
|
+
try {
|
|
381
|
+
if (authorized.status === 'rejected')
|
|
382
|
+
throw authorized.reason;
|
|
383
|
+
if (prepared.status === 'rejected')
|
|
384
|
+
throw prepared.reason;
|
|
385
|
+
check(context.signal);
|
|
386
|
+
const plan = authorized.value, media = plan.media;
|
|
387
|
+
if (!media || !Number.isSafeInteger(media.byteSize) || media.byteSize < 1 || media.byteSize > 104857600)
|
|
388
|
+
throw Error('Invalid attachment size.');
|
|
389
|
+
path = prepared.value + cacheName(media.fileName);
|
|
390
|
+
const task = FS.createDownloadResumable(https(plan.url), path, { headers: plan.headers ?? {}, sessionType: FS.FileSystemSessionType.FOREGROUND }, p => {
|
|
391
|
+
if (p.totalBytesWritten > media.byteSize)
|
|
392
|
+
void task.cancelAsync().catch(() => { });
|
|
393
|
+
context.onProgress(p.totalBytesWritten / media.byteSize);
|
|
394
|
+
});
|
|
395
|
+
const result = await cancellable(task, context, () => task.downloadAsync());
|
|
396
|
+
if (!result || result.status !== 200)
|
|
397
|
+
throw Error('Download failed. Try again.');
|
|
398
|
+
const info = await FS.getInfoAsync(path);
|
|
399
|
+
if (!info.exists || info.isDirectory || info.size !== media.byteSize)
|
|
400
|
+
throw Error('The attachment could not be verified.');
|
|
401
|
+
return { uri: path, mimeType: media.mimeType, fileName: media.fileName, byteSize: media.byteSize, ...(media.durationMs ? { durationMs: media.durationMs } : {}) };
|
|
402
|
+
}
|
|
403
|
+
catch (e) {
|
|
404
|
+
if (dir)
|
|
405
|
+
await removeUri(path ?? dir + 'Attachment').catch(() => { });
|
|
406
|
+
throw e;
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
async discard(uploadId) { await request('discard', { uploadId }); },
|
|
410
|
+
};
|
|
411
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { MessagingClient, type MessagingRequest } from '@robono/messaging';
|
|
2
|
+
import { NativeMessagingOutbox } from './native-outbox.js';
|
|
3
|
+
export declare const expoOutbox: (client: MessagingClient) => NativeMessagingOutbox | undefined;
|
|
4
|
+
/** Ready-made authenticated mobile client with private, per-account outgoing drafts. */
|
|
5
|
+
export declare function createExpoMessagingClient(options: {
|
|
6
|
+
url: string;
|
|
7
|
+
userId: string;
|
|
8
|
+
getAccessToken: () => Promise<string>;
|
|
9
|
+
fetch?: typeof fetch;
|
|
10
|
+
persistence?: false;
|
|
11
|
+
namespace?: string;
|
|
12
|
+
transformRequest?: MessagingRequest;
|
|
13
|
+
}): MessagingClient;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import * as FS from 'expo-file-system/legacy';
|
|
2
|
+
import * as Crypto from 'expo-crypto';
|
|
3
|
+
import { MessagingClient, createMessagingTransport } from '@robono/messaging';
|
|
4
|
+
import { NativeMessagingOutbox, emptyNativeDraft } from './native-outbox.js';
|
|
5
|
+
import { ExpoSavedMessages, bindExpoSaved } from './expo-saved.js';
|
|
6
|
+
const stores = new WeakMap();
|
|
7
|
+
export const expoOutbox = (client) => stores.get(client);
|
|
8
|
+
const digest = (value) => Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, value);
|
|
9
|
+
const root = async (scope) => { if (!FS.documentDirectory)
|
|
10
|
+
throw Error('Private app storage is unavailable.'); return FS.documentDirectory + 'robono-outgoing/' + await digest(scope) + '/'; };
|
|
11
|
+
const folder = async (scope, cid) => (await root(scope)) + await digest(cid) + '/';
|
|
12
|
+
const asset = async (scope, cid, id) => (await folder(scope, cid)) + 'file-' + await digest(id);
|
|
13
|
+
const files = {
|
|
14
|
+
async read(scope, cid) {
|
|
15
|
+
const dir = await folder(scope, cid);
|
|
16
|
+
if (!(await FS.getInfoAsync(dir)).exists)
|
|
17
|
+
return emptyNativeDraft();
|
|
18
|
+
const names = (await FS.readDirectoryAsync(dir)).filter(name => /^state-\d+\.json$/.test(name)).sort((a, b) => Number(b.slice(6, -5)) - Number(a.slice(6, -5)));
|
|
19
|
+
for (const name of names) {
|
|
20
|
+
try {
|
|
21
|
+
const state = JSON.parse(await FS.readAsStringAsync(dir + name));
|
|
22
|
+
if (typeof state.draft === 'string' && Array.isArray(state.messages) && state.files && typeof state.files === 'object')
|
|
23
|
+
return state;
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
}
|
|
27
|
+
if (names.length)
|
|
28
|
+
throw Error('Saved messages could not be read. Your files have been kept.');
|
|
29
|
+
return emptyNativeDraft();
|
|
30
|
+
},
|
|
31
|
+
async write(scope, cid, state) {
|
|
32
|
+
const dir = await folder(scope, cid);
|
|
33
|
+
await FS.makeDirectoryAsync(dir, { intermediates: true });
|
|
34
|
+
const names = (await FS.readDirectoryAsync(dir)).filter(name => /^state-\d+\.json$/.test(name)).sort((a, b) => Number(b.slice(6, -5)) - Number(a.slice(6, -5)));
|
|
35
|
+
const sequence = names.length ? Number(names[0].slice(6, -5)) + 1 : 1, temp = dir + 'pending-' + Crypto.randomUUID() + '.tmp';
|
|
36
|
+
try {
|
|
37
|
+
await FS.writeAsStringAsync(temp, JSON.stringify(state));
|
|
38
|
+
await FS.moveAsync({ from: temp, to: dir + 'state-' + sequence + '.json' });
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await FS.deleteAsync(temp, { idempotent: true }).catch(() => { });
|
|
42
|
+
}
|
|
43
|
+
// Keep the preceding complete snapshot for interrupted writes; no full app copies.
|
|
44
|
+
await Promise.all(names.slice(1).map(name => FS.deleteAsync(dir + name, { idempotent: true }).catch(() => { })));
|
|
45
|
+
},
|
|
46
|
+
async copy(scope, cid, id, source) {
|
|
47
|
+
if (!source.uri.startsWith('file://') || source.stream)
|
|
48
|
+
throw Error('Choose a local attachment.');
|
|
49
|
+
const dir = await folder(scope, cid), target = await asset(scope, cid, id);
|
|
50
|
+
await FS.makeDirectoryAsync(dir, { intermediates: true });
|
|
51
|
+
const existing = await FS.getInfoAsync(target);
|
|
52
|
+
if (existing.exists && !existing.isDirectory && existing.size === source.byteSize)
|
|
53
|
+
return target;
|
|
54
|
+
const temporary = target + '-' + Crypto.randomUUID() + '.tmp';
|
|
55
|
+
try {
|
|
56
|
+
await FS.copyAsync({ from: source.uri, to: temporary });
|
|
57
|
+
const info = await FS.getInfoAsync(temporary);
|
|
58
|
+
if (!info.exists || info.isDirectory || info.size !== source.byteSize)
|
|
59
|
+
throw Error('The attachment was not saved completely.');
|
|
60
|
+
await FS.moveAsync({ from: temporary, to: target });
|
|
61
|
+
return target;
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
await FS.deleteAsync(temporary, { idempotent: true }).catch(() => { });
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async file(scope, cid, id) { const target = await asset(scope, cid, id); return (await FS.getInfoAsync(target)).exists ? target : null; },
|
|
68
|
+
async remove(scope, cid, id) { await FS.deleteAsync(await asset(scope, cid, id), { idempotent: true }); },
|
|
69
|
+
async clear(scope, cid) { await FS.deleteAsync(cid === undefined ? await root(scope) : await folder(scope, cid), { idempotent: true }); },
|
|
70
|
+
};
|
|
71
|
+
/** Ready-made authenticated mobile client with private, per-account outgoing drafts. */
|
|
72
|
+
export function createExpoMessagingClient(options) {
|
|
73
|
+
const request = createMessagingTransport(options), store = options.persistence === false ? undefined : new NativeMessagingOutbox(options.url, options.userId, files, options.namespace);
|
|
74
|
+
const saved = store ? new ExpoSavedMessages(options.url, options.userId, options.namespace) : undefined;
|
|
75
|
+
const client = new MessagingClient({ request, userId: options.userId, createMessageId: Crypto.randomUUID, outbox: store, saved, transformRequest: options.transformRequest });
|
|
76
|
+
if (store)
|
|
77
|
+
stores.set(client, store);
|
|
78
|
+
if (saved)
|
|
79
|
+
bindExpoSaved(client, saved);
|
|
80
|
+
return client;
|
|
81
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type MessagingClient, type MessagingMessage, type MessagingSavedStore, type SavedMessage } from '@robono/messaging';
|
|
2
|
+
import type { LocalMessagingMedia } from './media-controller.js';
|
|
3
|
+
export declare const expoSaved: (client: MessagingClient) => ExpoSavedMessages | undefined;
|
|
4
|
+
export declare const bindExpoSaved: (client: MessagingClient, store: ExpoSavedMessages) => WeakMap<MessagingClient, ExpoSavedMessages>;
|
|
5
|
+
/** Explicit saved copies in the OS-private app directory, never cache/signed URLs. */
|
|
6
|
+
export declare class ExpoSavedMessages implements MessagingSavedStore {
|
|
7
|
+
private generation;
|
|
8
|
+
private scope;
|
|
9
|
+
private download;
|
|
10
|
+
constructor(endpoint: string, userId: string, namespace?: string);
|
|
11
|
+
bindDownload(download: NonNullable<ExpoSavedMessages['download']>): () => void;
|
|
12
|
+
private serial;
|
|
13
|
+
private directory;
|
|
14
|
+
private read;
|
|
15
|
+
private write;
|
|
16
|
+
list(): Promise<SavedMessage[]>;
|
|
17
|
+
prepare(message: MessagingMessage): Promise<{
|
|
18
|
+
commit: (record: SavedMessage) => Promise<void>;
|
|
19
|
+
discard: () => Promise<void>;
|
|
20
|
+
}>;
|
|
21
|
+
remove(key: string): Promise<void>;
|
|
22
|
+
clear(): Promise<void>;
|
|
23
|
+
file(record: SavedMessage): Promise<LocalMessagingMedia>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import * as FS from 'expo-file-system/legacy';
|
|
2
|
+
import * as Crypto from 'expo-crypto';
|
|
3
|
+
import { savedMessageKey } from '@robono/messaging';
|
|
4
|
+
const stores = new WeakMap();
|
|
5
|
+
export const expoSaved = (client) => stores.get(client);
|
|
6
|
+
export const bindExpoSaved = (client, store) => stores.set(client, store);
|
|
7
|
+
const queues = new Map(), epochs = new Map();
|
|
8
|
+
/** Explicit saved copies in the OS-private app directory, never cache/signed URLs. */
|
|
9
|
+
export class ExpoSavedMessages {
|
|
10
|
+
generation = 0;
|
|
11
|
+
scope;
|
|
12
|
+
download;
|
|
13
|
+
constructor(endpoint, userId, namespace = 'default') { this.scope = JSON.stringify([endpoint, namespace, userId]); }
|
|
14
|
+
bindDownload(download) { this.download = download; return () => { if (this.download === download)
|
|
15
|
+
this.download = undefined; }; }
|
|
16
|
+
serial(work) { const promise = (queues.get(this.scope) ?? Promise.resolve()).catch(() => { }).then(work); queues.set(this.scope, promise); void promise.finally(() => { if (queues.get(this.scope) === promise)
|
|
17
|
+
queues.delete(this.scope); }).catch(() => { }); return promise; }
|
|
18
|
+
async directory() { if (!FS.documentDirectory)
|
|
19
|
+
throw Error('Private app storage is unavailable.'); return FS.documentDirectory + 'robono-saved/' + await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, this.scope) + '/'; }
|
|
20
|
+
async read() { const dir = await this.directory(); if (!(await FS.getInfoAsync(dir)).exists)
|
|
21
|
+
return []; const names = (await FS.readDirectoryAsync(dir)).filter(n => /^state-\d+\.json$/.test(n)).sort((a, b) => Number(b.slice(6, -5)) - Number(a.slice(6, -5))); for (const name of names) {
|
|
22
|
+
try {
|
|
23
|
+
const rows = JSON.parse(await FS.readAsStringAsync(dir + name));
|
|
24
|
+
if (Array.isArray(rows) && rows.every(r => r.record?.key && (!r.file || /^file-[\da-f-]+$/i.test(r.file))))
|
|
25
|
+
return rows;
|
|
26
|
+
}
|
|
27
|
+
catch { }
|
|
28
|
+
} if (names.length)
|
|
29
|
+
throw Error('Saved messages could not be read. Your files have been kept.'); return []; }
|
|
30
|
+
async write(rows) { const dir = await this.directory(); await FS.makeDirectoryAsync(dir, { intermediates: true }); const names = (await FS.readDirectoryAsync(dir)).filter(n => /^state-\d+\.json$/.test(n)).sort((a, b) => Number(b.slice(6, -5)) - Number(a.slice(6, -5))); const sequence = names.length ? Number(names[0].slice(6, -5)) + 1 : 1, temp = dir + 'pending-' + Crypto.randomUUID(); try {
|
|
31
|
+
await FS.writeAsStringAsync(temp, JSON.stringify(rows));
|
|
32
|
+
await FS.moveAsync({ from: temp, to: dir + 'state-' + sequence + '.json' });
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await FS.deleteAsync(temp, { idempotent: true }).catch(() => { });
|
|
36
|
+
} await Promise.all(names.map(n => FS.deleteAsync(dir + n, { idempotent: true }).catch(() => { }))); }
|
|
37
|
+
list() { return this.serial(async () => (await this.read()).map(r => r.record).sort((a, b) => b.savedAt.localeCompare(a.savedAt))); }
|
|
38
|
+
async prepare(message) {
|
|
39
|
+
const generation = this.generation, epoch = epochs.get(this.scope) ?? 0;
|
|
40
|
+
let file, committed = false;
|
|
41
|
+
const active = () => { if (generation !== this.generation || epoch !== (epochs.get(this.scope) ?? 0))
|
|
42
|
+
throw Error('The account was signed out.'); };
|
|
43
|
+
if (message.media) {
|
|
44
|
+
if (!this.download)
|
|
45
|
+
throw Error('Open media tools before saving attachments.');
|
|
46
|
+
const source = await this.download(message);
|
|
47
|
+
try {
|
|
48
|
+
active();
|
|
49
|
+
if (source.file.stream || !source.file.uri.startsWith('file://'))
|
|
50
|
+
throw Error('A local attachment is required.');
|
|
51
|
+
file = 'file-' + Crypto.randomUUID();
|
|
52
|
+
const dir = await this.directory();
|
|
53
|
+
await this.serial(async () => { active(); await FS.makeDirectoryAsync(dir, { intermediates: true }); await FS.copyAsync({ from: source.file.uri, to: dir + file }); const info = await FS.getInfoAsync(dir + file); if (!info.exists || info.isDirectory || info.size !== message.media.byteSize)
|
|
54
|
+
throw Error('The saved file is incomplete.'); });
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (file)
|
|
58
|
+
await FS.deleteAsync((await this.directory()) + file, { idempotent: true }).catch(() => { });
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await source.release();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { commit: (record) => this.serial(async () => { active(); if (record.key !== savedMessageKey(message))
|
|
66
|
+
throw Error('The saved message changed.'); const rows = await this.read(), old = rows.find(r => r.record.key === record.key); active(); await this.write([{ record, file }, ...rows.filter(r => r.record.key !== record.key)]); committed = true; if (old?.file && old.file !== file)
|
|
67
|
+
await FS.deleteAsync((await this.directory()) + old.file, { idempotent: true }).catch(() => { }); }), discard: async () => { if (file && !committed)
|
|
68
|
+
await FS.deleteAsync((await this.directory()) + file, { idempotent: true }).catch(() => { }); } };
|
|
69
|
+
}
|
|
70
|
+
remove(key) { return this.serial(async () => { const rows = await this.read(), old = rows.find(r => r.record.key === key); await this.write(rows.filter(r => r.record.key !== key)); if (old?.file)
|
|
71
|
+
await FS.deleteAsync((await this.directory()) + old.file, { idempotent: true }).catch(() => { }); }); }
|
|
72
|
+
clear() { this.generation++; epochs.set(this.scope, (epochs.get(this.scope) ?? 0) + 1); return this.serial(async () => { await FS.deleteAsync(await this.directory(), { idempotent: true }); }); }
|
|
73
|
+
file(record) { return this.serial(async () => { const row = (await this.read()).find(r => r.record.key === record.key); if (!row?.file || !row.record.message.media)
|
|
74
|
+
throw Error('This saved attachment is unavailable.'); const uri = (await this.directory()) + row.file, info = await FS.getInfoAsync(uri); if (!info.exists || info.isDirectory || info.size !== row.record.message.media.byteSize)
|
|
75
|
+
throw Error('This saved attachment is missing.'); return { ...row.record.message.media, uri }; }); }
|
|
76
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { Linking, Text, UIManager } from 'react-native';
|
|
3
|
+
// Expo builds include this native module when the documented peer is installed.
|
|
4
|
+
const available = !!UIManager.getViewManagerConfig('RNCWebView');
|
|
5
|
+
const WebView = available ? require('react-native-webview').WebView : null;
|
|
6
|
+
export function ExpoSocialPlayer({ html, origin, onError }) {
|
|
7
|
+
if (!WebView)
|
|
8
|
+
return _jsx(Text, { children: "Update the app to play this preview." });
|
|
9
|
+
const open = (url) => { try {
|
|
10
|
+
const u = new URL(url);
|
|
11
|
+
if (u.protocol === 'https:' && !u.username && !u.password)
|
|
12
|
+
void Linking.openURL(url).catch(onError);
|
|
13
|
+
}
|
|
14
|
+
catch { } };
|
|
15
|
+
return _jsx(WebView, { source: { html, baseUrl: origin + '/' }, originWhitelist: ['https://*'], javaScriptEnabled: true, domStorageEnabled: true, allowsInlineMediaPlayback: true, mediaPlaybackRequiresUserAction: true, allowsFullscreenVideo: true, sharedCookiesEnabled: false, thirdPartyCookiesEnabled: true, mixedContentMode: "never", allowFileAccess: false, allowFileAccessFromFileURLs: false, allowUniversalAccessFromFileURLs: false, setSupportMultipleWindows: true, onOpenWindow: event => open(event.nativeEvent.targetUrl), onShouldStartLoadWithRequest: request => { if (request.url === 'about:blank' || request.url === origin + '/')
|
|
16
|
+
return true; if (!request.url.startsWith('https://'))
|
|
17
|
+
return false; if (request.isTopFrame && request.navigationType === 'click') {
|
|
18
|
+
open(request.url);
|
|
19
|
+
return false;
|
|
20
|
+
} return true; }, onError: onError, style: { flex: 1 } });
|
|
21
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { MessagingMessage } from '@robono/messaging';
|
|
2
|
+
import type { LocalMessagingMedia, MessagingMediaStorage } from './media-controller.js';
|
|
3
|
+
/** Small local previews, with bounded downloads and memory. Large originals stay
|
|
4
|
+
* tap-to-open until the storage service supplies a dedicated thumbnail. */
|
|
5
|
+
export declare function createExpoThumbnails(storage: MessagingMediaStorage, local: (id: string) => LocalMessagingMedia | undefined, remove: (file: LocalMessagingMedia) => Promise<void>): {
|
|
6
|
+
preview: (message: MessagingMessage) => Promise<{}>;
|
|
7
|
+
clear: () => void;
|
|
8
|
+
alias: (from: string, to: string) => void;
|
|
9
|
+
};
|