@robono/messaging-react-native 0.1.0 → 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 +14 -0
- package/README.md +79 -4
- package/SBOM.spdx.json +832 -112
- package/dist/calling.js +2 -2
- package/dist/expo-download.d.ts +3 -0
- package/dist/expo-download.js +43 -0
- package/dist/expo-media.js +23 -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 +9 -3
- package/dist/expo.js +143 -23
- package/dist/forward.d.ts +13 -0
- package/dist/forward.js +61 -0
- package/dist/icons.d.ts +1 -1
- package/dist/inbox.d.ts +16 -0
- package/dist/inbox.js +48 -0
- package/dist/index.d.ts +45 -2
- package/dist/index.js +376 -113
- package/dist/media-controller.d.ts +18 -0
- package/dist/media-controller.js +118 -3
- package/dist/media-readiness.d.ts +1 -1
- package/dist/media-ui.d.ts +16 -5
- package/dist/media-ui.js +15 -11
- 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 +6 -2
- package/dist/polls.js +85 -18
- package/dist/popup.d.ts +9 -0
- package/dist/popup.js +7 -0
- package/dist/recent-media.js +1 -0
- package/dist/safety.d.ts +2 -1
- package/dist/safety.js +11 -5
- 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/visual/RobonoIcon.js +2 -1
- package/dist/voice-composer.js +2 -2
- package/package.json +20 -4
package/dist/calling.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
2
2
|
import { createContext, useContext, useSyncExternalStore } from 'react';
|
|
3
3
|
import { Pressable, View, Text } from 'react-native';
|
|
4
4
|
import { RobonoIcon } from './visual/RobonoIcon.js';
|
|
5
|
-
import { MessagingScreen, MessagingProvider, MessagingHeader, MessagingComposer, useMessagingUI } from './index.js';
|
|
5
|
+
import { messagingHeaderColors, MessagingScreen, MessagingProvider, MessagingHeader, MessagingComposer, useMessagingUI } from './index.js';
|
|
6
6
|
import { UnansweredCallMessage } from './call-message.js';
|
|
7
7
|
export { UnansweredCallMessage } from './call-message.js';
|
|
8
8
|
const Action = createContext(null);
|
|
@@ -12,7 +12,7 @@ function CallingMessageComposer() { const follow = useContext(FollowUp); return
|
|
|
12
12
|
export function MessagingCallHeader() {
|
|
13
13
|
const action = useContext(Action), { design, state } = useMessagingUI();
|
|
14
14
|
const allowed = state.conversation?.canSend !== false;
|
|
15
|
-
return _jsxs(MessagingHeader, { children: [allowed && action?.onCall && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: action.label, onPress: action.onCall, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(RobonoIcon, { name: "phone", size: 24, color: design.
|
|
15
|
+
return _jsxs(MessagingHeader, { children: [allowed && action?.onCall && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: action.label, onPress: action.onCall, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(RobonoIcon, { name: "phone", size: 24, color: messagingHeaderColors(design, state.conversation?.kind === 'group').foreground }) }), allowed && action?.onVideoCall && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: action.videoLabel, onPress: action.onVideoCall, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(RobonoIcon, { name: "video", size: 24, color: messagingHeaderColors(design, state.conversation?.kind === 'group').foreground }) })] });
|
|
16
16
|
}
|
|
17
17
|
export function MessagingCallingScreen({ onCall, onVideoCall, videoCallLabel = 'Video call', callLabel = 'Voice call', components, unansweredCallId, onDismissCallMessage, ...props }) {
|
|
18
18
|
const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
import * as FS from 'expo-file-system/legacy';
|
|
3
|
+
import * as Sharing from 'expo-sharing';
|
|
4
|
+
import * as MediaLibrary from 'expo-media-library';
|
|
5
|
+
/** Export an already-authorized viewer copy; never expose its private source URL. */
|
|
6
|
+
export async function saveMessagingAttachment(file, kind) {
|
|
7
|
+
if (kind === 'image' || kind === 'video') {
|
|
8
|
+
const permission = await MediaLibrary.requestPermissionsAsync(true, [kind === 'image' ? 'photo' : 'video']);
|
|
9
|
+
if (!permission.granted)
|
|
10
|
+
throw Error('Allow photo access to save this attachment.');
|
|
11
|
+
await MediaLibrary.saveToLibraryAsync(file.uri);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (kind !== 'document')
|
|
15
|
+
throw Error('This attachment cannot be downloaded.');
|
|
16
|
+
if (Platform.OS !== 'android') {
|
|
17
|
+
if (!await Sharing.isAvailableAsync())
|
|
18
|
+
throw Error('File saving is unavailable.');
|
|
19
|
+
await Sharing.shareAsync(file.uri, { mimeType: file.mimeType, dialogTitle: 'Save ' + file.fileName });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const permission = await FS.StorageAccessFramework.requestDirectoryPermissionsAsync();
|
|
23
|
+
if (!permission.granted)
|
|
24
|
+
return;
|
|
25
|
+
const name = file.fileName.split(/[\\/]/).at(-1)?.replace(/[\u0000-\u001f\u007f]/g, '').trim() || 'Attachment';
|
|
26
|
+
const destination = await FS.StorageAccessFramework.createFileAsync(permission.directoryUri, name, file.mimeType);
|
|
27
|
+
try {
|
|
28
|
+
// Bounded chunks avoid keeping an entire large document and its base64 copy in memory.
|
|
29
|
+
const chunk = 3 * 256 * 1024;
|
|
30
|
+
for (let position = 0; position < file.byteSize; position += chunk) {
|
|
31
|
+
const length = Math.min(chunk, file.byteSize - position);
|
|
32
|
+
const data = await FS.readAsStringAsync(file.uri, { encoding: FS.EncodingType.Base64, position, length });
|
|
33
|
+
const bytes = data.length * 3 / 4 - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0);
|
|
34
|
+
if (bytes !== length)
|
|
35
|
+
throw Error('The attachment could not be read completely.');
|
|
36
|
+
await FS.writeAsStringAsync(destination, data, { encoding: FS.EncodingType.Base64, append: position > 0 });
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
await FS.deleteAsync(destination, { idempotent: true }).catch(() => { });
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
package/dist/expo-media.js
CHANGED
|
@@ -342,6 +342,29 @@ export function createExpoMessagingStorage(options) {
|
|
|
342
342
|
}
|
|
343
343
|
}
|
|
344
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
|
+
},
|
|
345
368
|
async download(message, context) {
|
|
346
369
|
check(context.signal);
|
|
347
370
|
if (message.deleted)
|
|
@@ -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
|
+
};
|
|
@@ -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,3 +1,4 @@
|
|
|
1
|
+
export { createExpoMessagingClient } from './expo-outbox.js';
|
|
1
2
|
import React from 'react';
|
|
2
3
|
import { type MessagingAppearance } from '@robono/messaging';
|
|
3
4
|
import { type MessagingScreenProps } from './index.js';
|
|
@@ -8,12 +9,17 @@ export { createExpoMessagingAudioSession, createExpoMessagingDevice, createExpoM
|
|
|
8
9
|
export type { MessagingAudioSession } from './expo-media.js';
|
|
9
10
|
export type { LocalMessagingMedia, MessagingMediaStorage, MediaTransferOptions } from './media-controller.js';
|
|
10
11
|
export { MessagingMediaController } from './media-controller.js';
|
|
11
|
-
export declare function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice, inlineRecorder }: {
|
|
12
|
+
export declare function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice, inlineRecorder, viewerActions, onDismiss }: {
|
|
13
|
+
onDismiss?: () => void;
|
|
14
|
+
viewerActions?: () => {
|
|
15
|
+
onForward?: () => void;
|
|
16
|
+
onDelete?: () => void;
|
|
17
|
+
};
|
|
12
18
|
controller: MessagingMediaController;
|
|
13
19
|
audio: MessagingAudioSession;
|
|
14
20
|
appearance: MessagingAppearance;
|
|
15
|
-
inlineVoice?: boolean;
|
|
16
|
-
inlineRecorder?: boolean;
|
|
21
|
+
inlineVoice?: boolean | (() => boolean);
|
|
22
|
+
inlineRecorder?: boolean | (() => boolean);
|
|
17
23
|
}): React.JSX.Element | null;
|
|
18
24
|
export interface ExpoMessagingScreenProps extends Omit<MessagingScreenProps, 'pickAttachment' | 'openAttachment'> {
|
|
19
25
|
storage: MessagingMediaStorage;
|