@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,92 @@
1
+ import * as Notifications from 'expo-notifications';
2
+ import * as Crypto from 'expo-crypto';
3
+ import * as SecureStore from 'expo-secure-store';
4
+ import { Platform } from 'react-native';
5
+ import { createMessagingNotificationRouter, parseMessagingNotification } from '@robono/messaging';
6
+ /** One restored account. Never prompts on mount, replaces a global handler or handles calling pushes. */
7
+ export async function connectExpoMessagingNotifications(options) {
8
+ if (!['ios', 'android'].includes(Platform.OS))
9
+ throw Error('Mobile notification registration requires iPhone or Android.');
10
+ const endpoint = new URL(options.url);
11
+ if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password)
12
+ throw Error('Use your authenticated HTTPS notification endpoint.');
13
+ let disposed = false, closing = false, registerQueue = Promise.resolve(), accountScope;
14
+ const key = 'robono.messaging.device.' + await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, endpoint.origin + endpoint.pathname);
15
+ let deviceId = await SecureStore.getItemAsync(key);
16
+ if (!deviceId) {
17
+ deviceId = Crypto.randomUUID();
18
+ await SecureStore.setItemAsync(key, deviceId);
19
+ }
20
+ async function request(input) {
21
+ const token = await options.getAccessToken();
22
+ if (disposed)
23
+ throw Error('Notification session has ended.');
24
+ const response = await (options.fetch ?? fetch)(endpoint.href, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(15000), headers: { authorization: 'Bearer ' + token, 'content-type': 'application/json' }, body: JSON.stringify({ ...input, ...(accountScope ? { accountScope } : {}) }) });
25
+ const data = await response.json();
26
+ if (!response.ok)
27
+ throw Error(data?.error?.message ?? 'Notification setup is unavailable. Check your app’s Apple/Google credentials.');
28
+ return data;
29
+ }
30
+ const status = await request({ operation: 'notification-status' });
31
+ if (status.userId !== options.userId || typeof status.accountScope !== 'string' || !/^[a-f\d]{64}$/.test(status.accountScope))
32
+ throw Error('The notification session does not match the signed-in account.');
33
+ accountScope = status.accountScope;
34
+ const router = createMessagingNotificationRouter({ accountScope: status.accountScope, authorize: options.authorize, open: options.open, refresh: options.refresh });
35
+ const register = (token) => {
36
+ const work = registerQueue.then(async () => {
37
+ if (disposed || closing)
38
+ return;
39
+ const data = await request({ operation: 'register-device', deviceId, provider: Platform.OS === 'ios' ? 'apns' : 'fcm', token: String(token.data) });
40
+ if (data.accountScope !== status.accountScope)
41
+ throw Error('The signed-in account changed. Reconnect notifications for the new account.');
42
+ });
43
+ registerQueue = work.catch(() => { });
44
+ return work;
45
+ };
46
+ const tokenListener = Notifications.addPushTokenListener(token => { void register(token).catch(options.onError); });
47
+ const received = Notifications.addNotificationReceivedListener(notification => { void router.handle(notification.request.content.data).catch(options.onError); });
48
+ const response = async (value) => {
49
+ if (disposed || closing || !value || value.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER)
50
+ return;
51
+ if (await router.handle(value.notification.request.content.data, true)) {
52
+ const last = await Notifications.getLastNotificationResponseAsync();
53
+ if (!disposed && last?.notification.request.identifier === value.notification.request.identifier)
54
+ await Notifications.clearLastNotificationResponseAsync();
55
+ }
56
+ };
57
+ const opened = Notifications.addNotificationResponseReceivedListener(value => { void response(value).catch(options.onError); });
58
+ const ready = (async () => {
59
+ if (Platform.OS === 'android')
60
+ await Notifications.setNotificationChannelAsync('robono-messages', { name: 'Messages', importance: Notifications.AndroidImportance.HIGH });
61
+ if ((await Notifications.getPermissionsAsync()).granted)
62
+ await register(await Notifications.getDevicePushTokenAsync());
63
+ await response(await Notifications.getLastNotificationResponseAsync());
64
+ })();
65
+ void ready.catch(options.onError);
66
+ const dispose = () => { disposed = true; router.dispose(); tokenListener.remove(); received.remove(); opened.remove(); };
67
+ return {
68
+ ready,
69
+ /** Call only after the user chooses Enable notifications in your interface. */
70
+ async enable() { if (disposed || closing)
71
+ return false; const permission = await Notifications.requestPermissionsAsync(); if (!permission.granted)
72
+ return false; await register(await Notifications.getDevicePushTokenAsync()); return true; },
73
+ /** Await before revoking your login session, so the old account's token is removed. */
74
+ async signOut() { closing = true; router.dispose(); try {
75
+ await registerQueue;
76
+ await request({ operation: 'remove-device', deviceId });
77
+ }
78
+ finally {
79
+ dispose();
80
+ } },
81
+ dispose,
82
+ };
83
+ }
84
+ /** Compose with the host's existing handler. Suppress only this account's open conversation. */
85
+ export function withMessagingNotificationPresentation(handler, current) {
86
+ return { ...handler, handleNotification: async (notification) => {
87
+ const event = parseMessagingNotification(notification.request.content.data), active = current();
88
+ if (event && (event.accountScope !== active.accountScope || event.conversationId === active.conversationId))
89
+ return { shouldPlaySound: false, shouldSetBadge: false, shouldShowBanner: false, shouldShowList: false };
90
+ return handler.handleNotification(notification);
91
+ } };
92
+ }
@@ -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,82 @@
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 { createExpoMessagingHistory } from './expo-history.js';
6
+ import { ExpoSavedMessages, bindExpoSaved } from './expo-saved.js';
7
+ const stores = new WeakMap();
8
+ export const expoOutbox = (client) => stores.get(client);
9
+ const digest = (value) => Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, value);
10
+ const root = async (scope) => { if (!FS.documentDirectory)
11
+ throw Error('Private app storage is unavailable.'); return FS.documentDirectory + 'robono-outgoing/' + await digest(scope) + '/'; };
12
+ const folder = async (scope, cid) => (await root(scope)) + await digest(cid) + '/';
13
+ const asset = async (scope, cid, id) => (await folder(scope, cid)) + 'file-' + await digest(id);
14
+ const files = {
15
+ async read(scope, cid) {
16
+ const dir = await folder(scope, cid);
17
+ if (!(await FS.getInfoAsync(dir)).exists)
18
+ return emptyNativeDraft();
19
+ 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)));
20
+ for (const name of names) {
21
+ try {
22
+ const state = JSON.parse(await FS.readAsStringAsync(dir + name));
23
+ if (typeof state.draft === 'string' && Array.isArray(state.messages) && state.files && typeof state.files === 'object')
24
+ return state;
25
+ }
26
+ catch { }
27
+ }
28
+ if (names.length)
29
+ throw Error('Saved messages could not be read. Your files have been kept.');
30
+ return emptyNativeDraft();
31
+ },
32
+ async write(scope, cid, state) {
33
+ const dir = await folder(scope, cid);
34
+ await FS.makeDirectoryAsync(dir, { intermediates: true });
35
+ 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)));
36
+ const sequence = names.length ? Number(names[0].slice(6, -5)) + 1 : 1, temp = dir + 'pending-' + Crypto.randomUUID() + '.tmp';
37
+ try {
38
+ await FS.writeAsStringAsync(temp, JSON.stringify(state));
39
+ await FS.moveAsync({ from: temp, to: dir + 'state-' + sequence + '.json' });
40
+ }
41
+ finally {
42
+ await FS.deleteAsync(temp, { idempotent: true }).catch(() => { });
43
+ }
44
+ // Keep the preceding complete snapshot for interrupted writes; no full app copies.
45
+ await Promise.all(names.slice(1).map(name => FS.deleteAsync(dir + name, { idempotent: true }).catch(() => { })));
46
+ },
47
+ async copy(scope, cid, id, source) {
48
+ if (!source.uri.startsWith('file://') || source.stream)
49
+ throw Error('Choose a local attachment.');
50
+ const dir = await folder(scope, cid), target = await asset(scope, cid, id);
51
+ await FS.makeDirectoryAsync(dir, { intermediates: true });
52
+ const existing = await FS.getInfoAsync(target);
53
+ if (existing.exists && !existing.isDirectory && existing.size === source.byteSize)
54
+ return target;
55
+ const temporary = target + '-' + Crypto.randomUUID() + '.tmp';
56
+ try {
57
+ await FS.copyAsync({ from: source.uri, to: temporary });
58
+ const info = await FS.getInfoAsync(temporary);
59
+ if (!info.exists || info.isDirectory || info.size !== source.byteSize)
60
+ throw Error('The attachment was not saved completely.');
61
+ await FS.moveAsync({ from: temporary, to: target });
62
+ return target;
63
+ }
64
+ finally {
65
+ await FS.deleteAsync(temporary, { idempotent: true }).catch(() => { });
66
+ }
67
+ },
68
+ async file(scope, cid, id) { const target = await asset(scope, cid, id); return (await FS.getInfoAsync(target)).exists ? target : null; },
69
+ async remove(scope, cid, id) { await FS.deleteAsync(await asset(scope, cid, id), { idempotent: true }); },
70
+ async clear(scope, cid) { await FS.deleteAsync(cid === undefined ? await root(scope) : await folder(scope, cid), { idempotent: true }); },
71
+ };
72
+ /** Ready-made authenticated mobile client with private, per-account outgoing drafts. */
73
+ export function createExpoMessagingClient(options) {
74
+ const request = createMessagingTransport(options), store = options.persistence === false ? undefined : new NativeMessagingOutbox(options.url, options.userId, files, options.namespace);
75
+ const saved = store ? new ExpoSavedMessages(options.url, options.userId, options.namespace) : undefined;
76
+ const client = new MessagingClient({ request, userId: options.userId, createMessageId: Crypto.randomUUID, outbox: store, saved, historyCache: store ? createExpoMessagingHistory(options.url, options.userId, options.namespace) : undefined, transformRequest: options.transformRequest });
77
+ if (store)
78
+ stores.set(client, store);
79
+ if (saved)
80
+ bindExpoSaved(client, saved);
81
+ return client;
82
+ }
@@ -0,0 +1,4 @@
1
+ import React from 'react';
2
+ import { type PrivacyLayerProps } from './extras.js';
3
+ /** Optional Expo adapter; install expo-blur in the customer app. */
4
+ export declare function ExpoMessagingPrivacyLayer({ children, enabled, background }: PrivacyLayerProps): React.JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useRef } from 'react';
3
+ import { View } from 'react-native';
4
+ import { BlurView, BlurTargetView } from 'expo-blur';
5
+ import { privacyReadingStripHeight } from './extras.js';
6
+ /** Optional Expo adapter; install expo-blur in the customer app. */
7
+ export function ExpoMessagingPrivacyLayer({ children, enabled, background }) { const target = useRef(null); return _jsxs(View, { style: { flex: 1, minHeight: 0, overflow: 'hidden' }, children: [_jsx(BlurTargetView, { ref: target, style: { flex: 1, minHeight: 0 }, children: children }), enabled && _jsx(BlurView, { pointerEvents: "none", testID: "privacy-lens-mask", blurTarget: target, blurMethod: "dimezisBlurView", intensity: 50, tint: background.toLowerCase() === "#ffffff" ? "light" : "dark", style: { position: 'absolute', left: 0, right: 0, top: 0, bottom: privacyReadingStripHeight } })] }); }
@@ -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,42 @@
1
+ export { createExpoMessagingClient } from './expo-outbox.js';
2
+ import React from 'react';
3
+ import { type MessagingAppearance } from '@robono/messaging';
4
+ import { type MessagingScreenProps } from './index.js';
5
+ import { MessagingMediaController, type LocalMessagingMedia, type MessagingMediaStorage } from './media-controller.js';
6
+ import { type MediaReadyStage } from './media-readiness.js';
7
+ import { type MessagingAudioSession } from './expo-media.js';
8
+ export { createExpoMessagingAudioSession, createExpoMessagingDevice, createExpoMessagingStorage } from './expo-media.js';
9
+ export type { MessagingAudioSession } from './expo-media.js';
10
+ export type { LocalMessagingMedia, MessagingMediaStorage, MediaTransferOptions } from './media-controller.js';
11
+ export { MessagingMediaController } from './media-controller.js';
12
+ export declare function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice, inlineRecorder, viewerActions, onDismiss }: {
13
+ onDismiss?: () => void;
14
+ viewerActions?: () => {
15
+ onForward?: () => void;
16
+ onDelete?: () => void;
17
+ };
18
+ controller: MessagingMediaController;
19
+ audio: MessagingAudioSession;
20
+ appearance: MessagingAppearance;
21
+ inlineVoice?: boolean | (() => boolean);
22
+ inlineRecorder?: boolean | (() => boolean);
23
+ }): React.JSX.Element | null;
24
+ export interface ExpoMessagingScreenProps extends Omit<MessagingScreenProps, 'pickAttachment' | 'openAttachment'> {
25
+ storage: MessagingMediaStorage;
26
+ audioSession: MessagingAudioSession;
27
+ /** Optional timing callback from native image/frame/playback readiness, measured from opening. */
28
+ onMediaReady?: (event: {
29
+ messageId: string;
30
+ kind: string;
31
+ stage: MediaReadyStage;
32
+ elapsedMs: number;
33
+ }) => void;
34
+ /** Preload a bounded set of small recent attachments while this conversation is open. Default true. */
35
+ preloadRecentMedia?: boolean;
36
+ /** Compose a conversation with the supplied media adapters. */
37
+ renderConversation?: (props: MessagingScreenProps) => React.ReactNode;
38
+ /** Resolve an authorized recent attachment to an adapter-owned cache copy, never a library original. */
39
+ resolveAttachmentChoice?: (id: string, signal: AbortSignal) => Promise<LocalMessagingMedia | null>;
40
+ }
41
+ /** Optional ready-made recording, preview, upload and download tools for both mobile platforms. */
42
+ export declare function ExpoMessagingScreen({ storage, audioSession, renderConversation, preloadRecentMedia, ...props }: ExpoMessagingScreenProps): React.JSX.Element;