@robono/messaging-react-native 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +130 -0
- package/README.md +165 -2
- package/SBOM.spdx.json +1127 -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-media.d.ts +19 -0
- package/dist/expo-media.js +388 -0
- package/dist/expo.d.ts +36 -0
- package/dist/expo.js +429 -0
- package/dist/icons.d.ts +7 -0
- package/dist/icons.js +10 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +433 -0
- package/dist/media-controller.d.ts +98 -0
- package/dist/media-controller.js +257 -0
- package/dist/media-readiness.d.ts +4 -0
- package/dist/media-readiness.js +4 -0
- package/dist/media-ui.d.ts +51 -0
- package/dist/media-ui.js +42 -0
- package/dist/polls.d.ts +21 -0
- package/dist/polls.js +54 -0
- package/dist/recent-media.d.ts +21 -0
- package/dist/recent-media.js +122 -0
- package/dist/safety.d.ts +11 -0
- package/dist/safety.js +47 -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 +20 -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 +110 -4
- package/plugin/preserve-call-audio.cjs +22 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { validateMessageMedia } from '@robono/messaging';
|
|
2
|
+
/** Single attachment lifecycle shared by iOS and Android. Cancellation invalidates every late result. */
|
|
3
|
+
export class MessagingMediaController {
|
|
4
|
+
options;
|
|
5
|
+
state = { phase: 'idle', kind: 'voice', file: null, elapsedMs: 0, maxDurationMs: 60000, progress: 0, error: null, retryUpload: false };
|
|
6
|
+
listeners = new Set();
|
|
7
|
+
abort = null;
|
|
8
|
+
intent = 0;
|
|
9
|
+
generation = 0;
|
|
10
|
+
resolve = null;
|
|
11
|
+
timer;
|
|
12
|
+
stopping = null;
|
|
13
|
+
settings = null;
|
|
14
|
+
conversationId = '';
|
|
15
|
+
uploadId = '';
|
|
16
|
+
disposed = false;
|
|
17
|
+
closing = null;
|
|
18
|
+
stageRecording = false;
|
|
19
|
+
openedMessage = null;
|
|
20
|
+
prepared = new Map();
|
|
21
|
+
getPrepared = (assetId) => this.prepared.get(assetId);
|
|
22
|
+
async releasePrepared(assetId) { const file = this.prepared.get(assetId); this.prepared.delete(assetId); if (file)
|
|
23
|
+
await this.options.device.remove(file).catch(() => { }); }
|
|
24
|
+
constructor(options) {
|
|
25
|
+
this.options = options;
|
|
26
|
+
}
|
|
27
|
+
getSnapshot = () => this.state;
|
|
28
|
+
subscribe = (f) => { this.listeners.add(f); return () => { this.listeners.delete(f); }; };
|
|
29
|
+
update(p) { this.state = { ...this.state, ...p }; this.listeners.forEach(f => f()); }
|
|
30
|
+
isCurrent(g) { return !this.disposed && this.generation === g && !this.abort?.signal.aborted; }
|
|
31
|
+
async pick(kind, source, selectionIntent = "preview") {
|
|
32
|
+
const intent = ++this.intent;
|
|
33
|
+
await this.closeCurrent();
|
|
34
|
+
if (intent !== this.intent)
|
|
35
|
+
return null;
|
|
36
|
+
if (this.disposed)
|
|
37
|
+
throw Error('Messaging media is closed.');
|
|
38
|
+
const settings = this.options.getSettings();
|
|
39
|
+
if (!settings.enabled || !settings.features[kind])
|
|
40
|
+
throw Error('This attachment type is unavailable.');
|
|
41
|
+
if (source === 'camera' && (!['image', 'video'].includes(kind) || !settings.appearance.showCamera))
|
|
42
|
+
throw Error('Camera capture is unavailable.');
|
|
43
|
+
if (source === 'call-video' && (kind !== 'video' || !settings.enabled || !settings.features.video || settings.unansweredCalls?.video !== 'video'))
|
|
44
|
+
throw Error('Video messages after a call are unavailable.');
|
|
45
|
+
this.stageRecording = kind === 'voice' && selectionIntent === 'attach';
|
|
46
|
+
this.settings = settings;
|
|
47
|
+
this.conversationId = this.options.getConversationId();
|
|
48
|
+
this.uploadId = this.options.createId();
|
|
49
|
+
const g = ++this.generation;
|
|
50
|
+
const abort = this.abort = new AbortController();
|
|
51
|
+
const result = new Promise(resolve => { this.resolve = resolve; });
|
|
52
|
+
const maximum = (settings.voice?.maxDurationSeconds ?? 60) * 1000;
|
|
53
|
+
this.update({ phase: kind === 'voice' ? 'preparing' : 'choosing', kind, file: null, error: null, progress: 0, retryUpload: false, elapsedMs: 0, waveform: [], maxDurationMs: maximum });
|
|
54
|
+
void (async () => {
|
|
55
|
+
try {
|
|
56
|
+
if (kind === 'voice') {
|
|
57
|
+
await this.options.device.startRecording(maximum, abort.signal);
|
|
58
|
+
if (!this.isCurrent(g))
|
|
59
|
+
return;
|
|
60
|
+
this.update({ phase: 'recording' });
|
|
61
|
+
this.timer = setInterval(() => {
|
|
62
|
+
if (!this.isCurrent(g) || this.state.phase !== 'recording')
|
|
63
|
+
return;
|
|
64
|
+
try {
|
|
65
|
+
const status = this.options.device.recordingStatus();
|
|
66
|
+
this.update({ elapsedMs: status.durationMs, ...(typeof status.level === 'number' && Number.isFinite(status.level) ? { waveform: [...(this.state.waveform ?? []), Math.max(0, Math.min(1, status.level))].slice(-56) } : {}) });
|
|
67
|
+
if (status.durationMs >= maximum || !status.recording)
|
|
68
|
+
void this.finishRecording(!status.recording && status.durationMs < maximum && !status.limitReached);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
void this.finishRecording(true);
|
|
72
|
+
}
|
|
73
|
+
}, 100);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
const file = await this.options.device.pick(kind, settings, abort.signal, source);
|
|
77
|
+
if (!this.isCurrent(g)) {
|
|
78
|
+
if (file)
|
|
79
|
+
await this.options.device.remove(file);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (!file) {
|
|
83
|
+
await this.cancel();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
this.update({ file, ...(source === 'camera' ? { kind: file.mimeType.startsWith('video/') ? 'video' : 'image' } : {}) });
|
|
87
|
+
this.check(file);
|
|
88
|
+
this.update({ phase: 'review' });
|
|
89
|
+
if (selectionIntent === 'send' && !source)
|
|
90
|
+
await this.send();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
if (this.isCurrent(g))
|
|
95
|
+
this.update({ phase: 'failed', error: this.error(e) });
|
|
96
|
+
}
|
|
97
|
+
})();
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
check(file) {
|
|
101
|
+
validateMessageMedia(this.state.kind, { ...file, assetId: 'pending-upload' }, this.options.getSettings());
|
|
102
|
+
if (this.options.getConversationId() !== this.conversationId)
|
|
103
|
+
throw Error('The conversation changed. Choose the attachment again.');
|
|
104
|
+
}
|
|
105
|
+
async finishRecording(interrupted = false) {
|
|
106
|
+
if (this.stopping)
|
|
107
|
+
return this.stopping;
|
|
108
|
+
if (this.state.phase !== 'recording')
|
|
109
|
+
return;
|
|
110
|
+
clearInterval(this.timer);
|
|
111
|
+
this.timer = undefined;
|
|
112
|
+
const g = this.generation;
|
|
113
|
+
this.update({ phase: 'preparing' });
|
|
114
|
+
const work = (async () => {
|
|
115
|
+
try {
|
|
116
|
+
const file = await this.options.device.stopRecording();
|
|
117
|
+
if (!this.isCurrent(g)) {
|
|
118
|
+
if (file)
|
|
119
|
+
await this.options.device.remove(file);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (!file)
|
|
123
|
+
throw Error('No recording was captured. Cancel and record again.');
|
|
124
|
+
this.update({ file, elapsedMs: file.durationMs ?? 0 });
|
|
125
|
+
this.check(file);
|
|
126
|
+
this.update({ phase: 'review', error: interrupted ? 'Recording stopped. Listen before sending.' : null });
|
|
127
|
+
if (this.stageRecording)
|
|
128
|
+
await this.send();
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
if (this.isCurrent(g))
|
|
132
|
+
this.update({ phase: 'failed', error: this.error(e) });
|
|
133
|
+
}
|
|
134
|
+
})();
|
|
135
|
+
this.stopping = work;
|
|
136
|
+
try {
|
|
137
|
+
await work;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
if (this.stopping === work)
|
|
141
|
+
this.stopping = null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async send() {
|
|
145
|
+
if (!['review', 'failed'].includes(this.state.phase) || !this.state.file || !this.resolve)
|
|
146
|
+
return;
|
|
147
|
+
const file = this.state.file, g = this.generation, abort = this.abort, uploadId = this.uploadId;
|
|
148
|
+
let retryUpload = false;
|
|
149
|
+
try {
|
|
150
|
+
this.check(file);
|
|
151
|
+
this.update({ phase: 'uploading', progress: 0, error: null, retryUpload: false });
|
|
152
|
+
retryUpload = true;
|
|
153
|
+
const media = await this.options.storage.upload(file, { uploadId, conversationId: this.conversationId, kind: this.state.kind, signal: abort.signal, onProgress: f => { if (this.isCurrent(g))
|
|
154
|
+
this.update({ progress: Math.max(0, Math.min(1, f)) }); } });
|
|
155
|
+
retryUpload = false;
|
|
156
|
+
if (!this.isCurrent(g)) {
|
|
157
|
+
await this.options.storage.discard(uploadId);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
validateMessageMedia(this.state.kind, media, this.options.getSettings());
|
|
161
|
+
if (this.options.getConversationId() !== this.conversationId)
|
|
162
|
+
throw Error('The conversation changed. Choose the attachment again.');
|
|
163
|
+
const resolve = this.resolve;
|
|
164
|
+
this.resolve = null;
|
|
165
|
+
this.uploadId = '';
|
|
166
|
+
this.abort = null;
|
|
167
|
+
if (this.stageRecording)
|
|
168
|
+
this.prepared.set(media.assetId, file);
|
|
169
|
+
this.update({ phase: 'idle', file: null, error: null });
|
|
170
|
+
resolve?.(media);
|
|
171
|
+
if (!this.stageRecording)
|
|
172
|
+
await this.options.device.remove(file).catch(() => { });
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
if (this.isCurrent(g))
|
|
176
|
+
this.update({ phase: 'failed', error: this.error(e), retryUpload });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
open(message) { return this.openMedia(message); }
|
|
180
|
+
/** Recover a failed/expired native stream once through fresh authorized download. */
|
|
181
|
+
fallbackStream(uri, positionMs, autoPlay) {
|
|
182
|
+
if (!this.state.file?.stream || this.state.file.uri !== uri || !this.openedMessage)
|
|
183
|
+
return Promise.resolve();
|
|
184
|
+
return this.openMedia(this.openedMessage, { download: true, positionMs, autoPlay });
|
|
185
|
+
}
|
|
186
|
+
async openMedia(message, recovery) {
|
|
187
|
+
const intent = ++this.intent;
|
|
188
|
+
await this.closeCurrent();
|
|
189
|
+
if (intent !== this.intent)
|
|
190
|
+
return;
|
|
191
|
+
if (this.disposed)
|
|
192
|
+
throw Error('Messaging media is closed.');
|
|
193
|
+
if (!message.media || message.kind === 'text' || message.kind === 'poll')
|
|
194
|
+
return;
|
|
195
|
+
const g = ++this.generation, abort = this.abort = new AbortController();
|
|
196
|
+
this.conversationId = message.conversationId;
|
|
197
|
+
this.openedMessage = message;
|
|
198
|
+
this.update({ phase: 'opening', kind: message.kind, file: null, error: null, progress: 0 });
|
|
199
|
+
try {
|
|
200
|
+
const context = { signal: abort.signal, onProgress: f => { if (this.isCurrent(g))
|
|
201
|
+
this.update({ progress: Math.max(0, Math.min(1, f)) }); } };
|
|
202
|
+
const remote = !recovery?.download && ['voice', 'video'].includes(message.kind) ? await this.options.storage.stream?.(message, context) : null;
|
|
203
|
+
if (!this.isCurrent(g))
|
|
204
|
+
return;
|
|
205
|
+
const file = remote ?? await this.options.storage.download(message, context);
|
|
206
|
+
if (!this.isCurrent(g) || this.options.getConversationId() !== message.conversationId) {
|
|
207
|
+
await this.options.device.remove(file);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
this.update({ phase: 'viewing', file: recovery ? { ...file, resumeAtMs: Number.isFinite(recovery.positionMs) ? Math.max(0, Math.min(recovery.positionMs, file.durationMs ?? recovery.positionMs)) : 0, autoPlay: recovery.autoPlay } : file });
|
|
211
|
+
}
|
|
212
|
+
catch (e) {
|
|
213
|
+
if (this.isCurrent(g))
|
|
214
|
+
this.update({ phase: 'failed', error: this.error(e) });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
cancel() { ++this.intent; return this.closeCurrent(); }
|
|
218
|
+
closeCurrent() {
|
|
219
|
+
if (this.closing)
|
|
220
|
+
return this.closing;
|
|
221
|
+
const wasRecording = ['recording', 'preparing'].includes(this.state.phase) && this.state.kind === 'voice';
|
|
222
|
+
const file = this.state.file, id = this.uploadId, resolve = this.resolve, stopping = this.stopping;
|
|
223
|
+
this.openedMessage = null;
|
|
224
|
+
++this.generation;
|
|
225
|
+
this.abort?.abort();
|
|
226
|
+
this.abort = null;
|
|
227
|
+
this.resolve = null;
|
|
228
|
+
this.uploadId = '';
|
|
229
|
+
clearInterval(this.timer);
|
|
230
|
+
this.timer = undefined;
|
|
231
|
+
this.update({ phase: 'idle', file: null, error: null });
|
|
232
|
+
resolve?.(null);
|
|
233
|
+
const work = (async () => {
|
|
234
|
+
if (stopping)
|
|
235
|
+
await stopping;
|
|
236
|
+
else if (wasRecording) {
|
|
237
|
+
const recorded = await this.options.device.stopRecording().catch(() => null);
|
|
238
|
+
if (recorded)
|
|
239
|
+
await this.options.device.remove(recorded).catch(() => { });
|
|
240
|
+
}
|
|
241
|
+
if (file)
|
|
242
|
+
await this.options.device.remove(file).catch(() => { });
|
|
243
|
+
if (id)
|
|
244
|
+
await this.options.storage.discard(id).catch(() => { });
|
|
245
|
+
})();
|
|
246
|
+
this.closing = work;
|
|
247
|
+
void work.finally(() => { if (this.closing === work)
|
|
248
|
+
this.closing = null; });
|
|
249
|
+
return work;
|
|
250
|
+
}
|
|
251
|
+
async interrupt() { if (this.state.phase === 'recording')
|
|
252
|
+
await this.finishRecording(true);
|
|
253
|
+
else if (this.state.phase === 'preparing')
|
|
254
|
+
await this.cancel(); }
|
|
255
|
+
async dispose() { this.disposed = true; await this.cancel(); await Promise.all([...this.prepared.keys()].map(id => this.releasePrepared(id))); this.listeners.clear(); }
|
|
256
|
+
error(e) { return e instanceof Error ? e.message : 'The attachment could not be opened. Try again.'; }
|
|
257
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type MediaReadyStage = 'image-loaded' | 'video-frame' | 'audio-playing';
|
|
2
|
+
export declare const MediaReadiness: import("react").Context<(stage: MediaReadyStage, uri: string) => void>;
|
|
3
|
+
/** Internal recovery channel; URLs are matched in memory, never logged. */
|
|
4
|
+
export declare const StreamRecovery: import("react").Context<(uri: string, positionMs: number, autoPlay: boolean) => void>;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React, { type ReactNode } from 'react';
|
|
2
|
+
import type { MessagingMessage } from '@robono/messaging';
|
|
3
|
+
export interface VoiceBubbleProps {
|
|
4
|
+
message: MessagingMessage;
|
|
5
|
+
ink: string;
|
|
6
|
+
accent: string;
|
|
7
|
+
waveform?: number[];
|
|
8
|
+
footer?: ReactNode;
|
|
9
|
+
onOpen: () => void;
|
|
10
|
+
onLongPress?: () => void;
|
|
11
|
+
}
|
|
12
|
+
export interface PlaybackProps {
|
|
13
|
+
playing?: boolean;
|
|
14
|
+
loading?: boolean;
|
|
15
|
+
positionMs?: number;
|
|
16
|
+
durationMs?: number;
|
|
17
|
+
rate?: number;
|
|
18
|
+
error?: string;
|
|
19
|
+
onToggle: () => void;
|
|
20
|
+
onSeek?: (ms: number) => void;
|
|
21
|
+
onRate?: () => void;
|
|
22
|
+
}
|
|
23
|
+
/** Same chat controls on web, iOS and Android. Audio engines supply state, not a different UI. */
|
|
24
|
+
export declare function VoicePlayback({ ink, accent, waveform, footer, onLongPress, playing, loading, positionMs, durationMs, error, onToggle, onSeek }: PlaybackProps & {
|
|
25
|
+
ink: string;
|
|
26
|
+
accent: string;
|
|
27
|
+
waveform?: number[];
|
|
28
|
+
footer?: ReactNode;
|
|
29
|
+
onLongPress?: () => void;
|
|
30
|
+
}): React.JSX.Element;
|
|
31
|
+
export declare function MediaPlaybackControls({ playing, loading, positionMs, durationMs, error, onToggle, onSeek }: PlaybackProps): React.JSX.Element;
|
|
32
|
+
/** A full-size photo surface. The same tap-to-zoom, pinch and pan handling runs in the preview and native app. */
|
|
33
|
+
export declare function ZoomablePhoto({ uri, label }: {
|
|
34
|
+
uri: string;
|
|
35
|
+
label?: string;
|
|
36
|
+
}): React.JSX.Element;
|
|
37
|
+
export declare function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview }: {
|
|
38
|
+
title: string;
|
|
39
|
+
subtitle?: string;
|
|
40
|
+
kind: 'image' | 'video' | 'document' | 'voice';
|
|
41
|
+
uri?: string;
|
|
42
|
+
fileName?: string;
|
|
43
|
+
byteSize?: number;
|
|
44
|
+
children?: ReactNode;
|
|
45
|
+
loading?: boolean;
|
|
46
|
+
error?: string;
|
|
47
|
+
onClose: () => void;
|
|
48
|
+
onFileAction?: () => void;
|
|
49
|
+
reviewAction?: string;
|
|
50
|
+
onReview?: () => void;
|
|
51
|
+
}): React.JSX.Element;
|
package/dist/media-ui.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useContext, useState } from 'react';
|
|
3
|
+
import { ActivityIndicator, Image, PanResponder, Platform, Pressable, Text, View } from 'react-native';
|
|
4
|
+
import { MediaReadiness } from './media-readiness.js';
|
|
5
|
+
import { MessageIcon, duration, fileSize } from './icons.js';
|
|
6
|
+
/** Same chat controls on web, iOS and Android. Audio engines supply state, not a different UI. */
|
|
7
|
+
export function VoicePlayback({ ink, accent, waveform, footer, onLongPress, playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek }) {
|
|
8
|
+
const width = useRef(1), progress = durationMs ? Math.max(0, Math.min(1, positionMs / durationMs)) : 0;
|
|
9
|
+
return _jsxs(View, { style: { width: 252, maxWidth: '100%' }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 9 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: playing ? 'Pause voice message' : 'Play voice message', accessibilityState: { busy: loading }, disabled: loading, onPress: onToggle, onLongPress: onLongPress, style: { width: 44, height: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center' }, children: loading ? _jsx(ActivityIndicator, { size: "small", color: ink }) : _jsx(MessageIcon, { name: playing ? 'pause' : 'play', color: ink + 'aa', size: 24 }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Pressable, { accessibilityRole: "adjustable", accessibilityLabel: "Voice waveform", accessibilityValue: { min: 0, max: Math.ceil(durationMs / 1000), now: Math.floor(positionMs / 1000) }, accessibilityActions: [{ name: 'increment', label: 'Forward five seconds' }, { name: 'decrement', label: 'Back five seconds' }], onAccessibilityAction: e => onSeek?.(Math.max(0, Math.min(durationMs, positionMs + (e.nativeEvent.actionName === 'increment' ? 5000 : -5000)))), onLayout: e => { width.current = e.nativeEvent.layout.width; }, onPress: e => { const bounds = Platform.OS === "web" ? e.currentTarget.getBoundingClientRect() : null; const x = bounds ? e.nativeEvent.clientX - bounds.left : e.nativeEvent.locationX; const total = bounds?.width ?? width.current; if (total > 0 && Number.isFinite(x))
|
|
10
|
+
onSeek?.(Math.max(0, Math.min(1, x / total)) * durationMs); }, style: { minHeight: 36, flexDirection: 'row', alignItems: 'center', gap: 2 }, children: Array.from({ length: 36 }, (_, i) => { const sample = waveform?.[Math.floor(i * (waveform.length || 36) / 36)]; return _jsx(View, { pointerEvents: "none", style: { flex: 1, borderRadius: 2, backgroundColor: i / 36 < progress ? accent : ink, opacity: i / 36 < progress ? 1 : 0.34, height: 4 + (typeof sample === 'number' && Number.isFinite(sample) ? Math.min(1, Math.max(0, sample)) * 23 : 0) } }, i); }) }), _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 6 }, children: [_jsx(Text, { style: { color: ink, fontSize: 11, opacity: 0.7, fontVariant: ['tabular-nums'] }, children: duration(playing || positionMs > 0 ? Math.max(0, durationMs - positionMs) : durationMs) }), footer] })] })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: ink, fontSize: 12, marginTop: 6 }, children: error })] });
|
|
11
|
+
}
|
|
12
|
+
export function MediaPlaybackControls({ playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek }) {
|
|
13
|
+
const width = useRef(1);
|
|
14
|
+
return _jsxs(View, { style: { paddingHorizontal: 18, paddingVertical: 10, gap: 6, backgroundColor: '#111316' }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: playing ? 'Pause video' : 'Play video', disabled: loading, onPress: onToggle, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: loading ? _jsx(ActivityIndicator, { color: "#fff" }) : _jsx(MessageIcon, { name: playing ? 'pause' : 'play', color: "#fff" }) }), _jsx(Text, { style: { color: '#ddd', fontSize: 12, fontVariant: ['tabular-nums'] }, children: duration(positionMs) }), _jsx(Pressable, { accessibilityRole: "adjustable", accessibilityLabel: "Video position", accessibilityValue: { min: 0, max: Math.ceil(durationMs / 1000), now: Math.floor(positionMs / 1000) }, accessibilityActions: [{ name: 'increment' }, { name: 'decrement' }], onAccessibilityAction: e => onSeek?.(Math.max(0, Math.min(durationMs, positionMs + (e.nativeEvent.actionName === 'increment' ? 5000 : -5000)))), onLayout: e => { width.current = e.nativeEvent.layout.width; }, onPress: e => onSeek?.(Math.max(0, Math.min(1, e.nativeEvent.locationX / width.current)) * durationMs), style: { flex: 1, height: 44, justifyContent: 'center' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#ffffff44' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#fff', width: `${durationMs ? Math.min(100, positionMs / durationMs * 100) : 0}%` } }) }) }), _jsx(Text, { style: { color: '#bbb', fontSize: 12, fontVariant: ['tabular-nums'] }, children: duration(durationMs) })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', fontSize: 13 }, children: error })] });
|
|
15
|
+
}
|
|
16
|
+
/** A full-size photo surface. The same tap-to-zoom, pinch and pan handling runs in the preview and native app. */
|
|
17
|
+
export function ZoomablePhoto({ uri, label = 'Photo' }) {
|
|
18
|
+
const mediaReady = useContext(MediaReadiness);
|
|
19
|
+
const [zoom, setZoom] = useState(1), [offset, setOffset] = useState({ x: 0, y: 0 }), [error, setError] = useState(false);
|
|
20
|
+
const latest = useRef({ zoom, offset, width: 1, height: 1 });
|
|
21
|
+
latest.current = { ...latest.current, zoom, offset };
|
|
22
|
+
const start = useRef({ distance: 0, zoom: 1, x: 0, y: 0 });
|
|
23
|
+
const pan = useRef(PanResponder.create({
|
|
24
|
+
onStartShouldSetPanResponder: e => e.nativeEvent.touches.length > 1,
|
|
25
|
+
onMoveShouldSetPanResponder: (e, g) => e.nativeEvent.touches.length > 1 || (latest.current.zoom > 1 && (Math.abs(g.dx) > 5 || Math.abs(g.dy) > 5)),
|
|
26
|
+
onPanResponderGrant: e => { const t = e.nativeEvent.touches; start.current = { distance: t.length > 1 ? Math.hypot(t[0].pageX - t[1].pageX, t[0].pageY - t[1].pageY) : 0, zoom: latest.current.zoom, x: latest.current.offset.x, y: latest.current.offset.y }; },
|
|
27
|
+
onPanResponderMove: (e, g) => { const t = e.nativeEvent.touches, s = latest.current; if (t.length > 1 && start.current.distance) {
|
|
28
|
+
setZoom(Math.max(1, Math.min(4, start.current.zoom * Math.hypot(t[0].pageX - t[1].pageX, t[0].pageY - t[1].pageY) / start.current.distance)));
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
const x = s.width * (s.zoom - 1) / 2, y = s.height * (s.zoom - 1) / 2;
|
|
32
|
+
setOffset({ x: Math.max(-x, Math.min(x, start.current.x + g.dx)), y: Math.max(-y, Math.min(y, start.current.y + g.dy)) });
|
|
33
|
+
} },
|
|
34
|
+
onPanResponderRelease: () => { if (latest.current.zoom <= 1)
|
|
35
|
+
setOffset({ x: 0, y: 0 }); },
|
|
36
|
+
})).current;
|
|
37
|
+
const toggle = () => { setZoom(zoom === 1 ? 2 : 1); setOffset({ x: 0, y: 0 }); };
|
|
38
|
+
return _jsxs(View, { style: { flex: 1, minHeight: 0, overflow: 'hidden' }, onLayout: e => { latest.current = { ...latest.current, width: e.nativeEvent.layout.width, height: e.nativeEvent.layout.height }; }, children: [_jsx(Pressable, { ...pan.panHandlers, accessibilityRole: "button", accessibilityLabel: zoom === 1 ? 'Zoom photo' : 'Fit photo', onPress: toggle, style: { flex: 1, overflow: 'hidden' }, children: _jsx(Image, { accessibilityLabel: label, source: { uri }, resizeMode: "contain", onLoad: () => mediaReady('image-loaded', uri), onError: () => setError(true), style: { width: '100%', height: '100%', transform: [{ translateX: offset.x }, { translateY: offset.y }, { scale: zoom }] } }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: zoom === 1 ? 'Zoom in' : 'Zoom out', onPress: toggle, style: { position: 'absolute', right: 16, bottom: 16, width: 44, height: 44, borderRadius: 22, backgroundColor: '#222a', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "zoom", color: "#fff" }) }), error && _jsx(Text, { accessibilityRole: "alert", style: { position: 'absolute', top: '50%', alignSelf: 'center', color: '#fff' }, children: "This photo could not be opened." })] });
|
|
39
|
+
}
|
|
40
|
+
export function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview }) {
|
|
41
|
+
return _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1, minHeight: 0, backgroundColor: '#0c0e10' }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 12, flexShrink: 0, paddingHorizontal: 8, paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#ffffff12' }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close media", onPress: onClose, hitSlop: 8, style: { width: 44, height: 44, flexShrink: 0, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "back", color: "#fff" }) }), _jsxs(View, { style: { flex: 1, paddingRight: 12, gap: 3 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: '#fff', fontSize: 16, fontWeight: '600' }, children: title }), !!subtitle && _jsx(Text, { numberOfLines: 1, style: { color: '#a4abb3', fontSize: 12 }, children: subtitle })] })] }), loading ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }, children: [_jsx(ActivityIndicator, { color: "#fff" }), _jsx(Text, { style: { color: '#c1c7ce' }, children: "Opening\u2026" })] }) : kind === 'image' && uri ? _jsx(ZoomablePhoto, { uri: uri, label: fileName }) : kind === 'document' ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 20 }, children: [_jsx(View, { style: { width: 88, height: 106, borderRadius: 16, backgroundColor: '#ffffff12', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "document", color: "#fff", size: 40 }) }), _jsx(Text, { style: { color: '#fff', fontSize: 19, fontWeight: '600', textAlign: 'center' }, children: fileName }), _jsxs(Text, { style: { color: '#9ba3ad', fontSize: 13 }, children: [fileName?.split('.').pop()?.toUpperCase(), " \u00B7 ", fileSize(byteSize)] }), onFileAction && _jsx(Pressable, { accessibilityRole: "button", onPress: onFileAction, style: { borderRadius: 24, paddingHorizontal: 28, paddingVertical: 14, backgroundColor: '#fff' }, children: _jsx(Text, { style: { color: '#15191e', fontWeight: '600' }, children: "Open file" }) })] }) : children, !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', padding: 16 }, children: error }), reviewAction && onReview && _jsx(View, { style: { padding: 16, borderTopWidth: 1, borderTopColor: '#ffffff15' }, children: _jsx(Pressable, { accessibilityRole: "button", onPress: onReview, style: { minHeight: 48, borderRadius: 24, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontWeight: '600', color: '#121619' }, children: reviewAction }) }) })] });
|
|
42
|
+
}
|
package/dist/polls.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type MessagingAppearance, type MessagingMessage, type MessagePoll } from '@robono/messaging';
|
|
3
|
+
/** Optional reusable poll content. Votes are submitted as a complete selection, not a toggle request. */
|
|
4
|
+
export declare function PollMessage({ message, userId, ink, accent, enabled, onVote, onClose, resolveVoterName, background, foreground }: {
|
|
5
|
+
message: MessagingMessage;
|
|
6
|
+
userId: string;
|
|
7
|
+
ink: string;
|
|
8
|
+
accent: string;
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
onVote: (options: number[]) => Promise<void>;
|
|
11
|
+
onClose: () => Promise<void>;
|
|
12
|
+
resolveVoterName?: (userId: string) => string;
|
|
13
|
+
background?: string;
|
|
14
|
+
foreground?: string;
|
|
15
|
+
}): React.JSX.Element | null;
|
|
16
|
+
export declare function PollComposer({ appearance, onSend, onCancel, retrySafe }: {
|
|
17
|
+
retrySafe?: boolean;
|
|
18
|
+
appearance: MessagingAppearance;
|
|
19
|
+
onSend: (poll: MessagePoll) => Promise<void>;
|
|
20
|
+
onCancel: () => void;
|
|
21
|
+
}): React.JSX.Element;
|
package/dist/polls.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { ActivityIndicator, KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, Switch, Text, TextInput, View } from 'react-native';
|
|
4
|
+
import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
|
|
5
|
+
import { validatePoll } from '@robono/messaging';
|
|
6
|
+
/** Optional reusable poll content. Votes are submitted as a complete selection, not a toggle request. */
|
|
7
|
+
export function PollMessage({ message, userId, ink, accent, enabled, onVote, onClose, resolveVoterName, background = '#101010', foreground = '#fff' }) {
|
|
8
|
+
const [busy, setBusy] = useState(false), [error, setError] = useState(''), [showVotes, setShowVotes] = useState(false);
|
|
9
|
+
const poll = message.poll;
|
|
10
|
+
if (!poll)
|
|
11
|
+
return null;
|
|
12
|
+
const votes = message.pollState?.votes ?? [], selected = votes.find(v => v.userId === userId)?.options ?? [], closed = !!message.pollState?.closed;
|
|
13
|
+
async function act(work) { if (busy)
|
|
14
|
+
return; setBusy(true); setError(''); try {
|
|
15
|
+
await work();
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
setError('The poll could not be updated. Try again.');
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
setBusy(false);
|
|
22
|
+
} }
|
|
23
|
+
return _jsxs(View, { style: { width: 270, maxWidth: '100%', gap: 2, padding: 0 }, children: [_jsx(Text, { style: { fontSize: 17, lineHeight: 23, fontWeight: '600', color: ink }, children: poll.question }), _jsx(Text, { style: { fontSize: 12, color: ink, opacity: .7 }, children: closed ? 'Poll closed' : poll.multiple ? 'Choose one or more' : 'Choose one' }), poll.options.map((label, index) => {
|
|
24
|
+
const count = votes.filter(v => v.options.includes(index)).length, checked = selected.includes(index);
|
|
25
|
+
return _jsxs(Pressable, { accessibilityRole: poll.multiple ? 'checkbox' : 'radio', accessibilityLabel: `${label}, ${count} ${count === 1 ? 'vote' : 'votes'}`, "aria-checked": checked, accessibilityState: { checked, disabled: !enabled || closed || busy }, disabled: !enabled || closed || busy, onPress: () => void act(() => onVote(checked ? selected.filter(x => x !== index) : poll.multiple ? [...selected, index] : [index])), style: { minHeight: 44, paddingVertical: 5, gap: 3 }, children: [_jsxs(View, { style: { flexDirection: 'row', gap: 10, alignItems: 'center' }, children: [_jsx(View, { style: { width: 20, height: 20, borderRadius: poll.multiple ? 4 : 10, borderWidth: 2, borderColor: checked ? accent : ink, alignItems: 'center', justifyContent: 'center' }, children: checked && _jsx(View, { style: { width: 10, height: 10, borderRadius: poll.multiple ? 2 : 5, backgroundColor: accent } }) }), _jsx(Text, { style: { flex: 1, color: ink, fontSize: 16 }, children: label }), _jsx(Text, { style: { color: ink, fontSize: 13 }, children: count })] }), _jsx(View, { pointerEvents: "none", style: { height: 3, marginLeft: 30, backgroundColor: ink + '20', borderRadius: 2, overflow: 'hidden' }, children: _jsx(View, { style: { height: 3, width: `${votes.length ? count / votes.length * 100 : 0}%`, backgroundColor: accent } }) })] }, index);
|
|
26
|
+
}), _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: "View votes", onPress: () => setShowVotes(true), style: { minHeight: 44, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 }, children: [_jsx(Text, { style: { color: ink, fontSize: 14 }, children: "View votes" }), _jsxs(Text, { style: { color: ink, opacity: .7, fontSize: 12 }, children: [votes.length, " ", votes.length === 1 ? 'vote' : 'votes'] })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: ink }, children: error }), showVotes && _jsx(Modal, { visible: true, animationType: "slide", onRequestClose: () => setShowVotes(false), children: _jsx(SafeAreaProvider, { children: _jsxs(SafeAreaView, { style: { flex: 1, backgroundColor: background }, children: [_jsxs(View, { style: { height: 56, paddingHorizontal: 20, flexDirection: 'row', alignItems: 'center', gap: 16 }, children: [_jsx(Text, { accessibilityRole: "header", style: { color: foreground, fontSize: 22, fontWeight: '600', flex: 1 }, children: "Votes" }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close votes", onPress: () => setShowVotes(false), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 17 }, children: "Done" }) })] }), _jsxs(ScrollView, { contentContainerStyle: { padding: 16, gap: 14 }, children: [_jsx(Text, { style: { fontSize: 20, lineHeight: 27, fontWeight: '600', color: foreground }, children: poll.question }), poll.options.map((option, index) => { const people = votes.filter(v => v.options.includes(index)); return _jsxs(View, { style: { gap: 8 }, children: [_jsx(Text, { style: { color: foreground, fontSize: 17, fontWeight: '600' }, children: option }), people.length ? people.map(v => _jsx(Text, { style: { color: foreground, fontSize: 16, paddingVertical: 3 }, children: v.userId === userId ? 'You' : resolveVoterName?.(v.userId) || 'Chat member' }, v.userId)) : _jsx(Text, { style: { color: foreground, opacity: .6, fontSize: 15 }, children: "No votes yet" })] }, index); }), message.senderId === userId && !closed && enabled && _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => void act(onClose), style: { minHeight: 48, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 17 }, children: "End poll" }) }), closed && _jsx(Text, { style: { color: foreground, opacity: .7 }, children: "Poll closed" }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: foreground }, children: error })] })] }) }) })] });
|
|
27
|
+
}
|
|
28
|
+
export function PollComposer({ appearance, onSend, onCancel, retrySafe = false }) {
|
|
29
|
+
const [question, setQuestion] = useState(''), [options, setOptions] = useState(['', '']), [multiple, setMultiple] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(''), [submitted, setSubmitted] = useState(false);
|
|
30
|
+
const c = appearance.colors;
|
|
31
|
+
async function send() { if (busy || submitted)
|
|
32
|
+
return; let poll; try {
|
|
33
|
+
poll = validatePoll({ question, options, multiple });
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
setError(e.message);
|
|
37
|
+
return;
|
|
38
|
+
} setBusy(true); setSubmitted(true); try {
|
|
39
|
+
await onSend(poll);
|
|
40
|
+
onCancel();
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
if (retrySafe)
|
|
44
|
+
setSubmitted(false);
|
|
45
|
+
setError(retrySafe ? 'The poll was not sent. Try again.' : 'The poll was not sent. Check the conversation for a retry.');
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
setBusy(false);
|
|
49
|
+
} }
|
|
50
|
+
const inputStyle = { fontSize: 17, color: c.foreground, minHeight: 48, borderBottomWidth: 1, borderBottomColor: c.secondary + '40', paddingVertical: 12 };
|
|
51
|
+
const ready = question.trim().length > 0 && options.filter(o => o.trim()).length >= 2;
|
|
52
|
+
return _jsx(Modal, { visible: true, animationType: "slide", onRequestClose: () => { if (!busy)
|
|
53
|
+
onCancel(); }, children: _jsx(SafeAreaProvider, { children: _jsxs(SafeAreaView, { style: { flex: 1, backgroundColor: c.background }, children: [_jsxs(View, { style: { height: 56, paddingHorizontal: 16, flexDirection: 'row', alignItems: 'center', gap: 16 }, children: [_jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: onCancel, style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Cancel" }) }), _jsx(Text, { accessibilityRole: "header", style: { flex: 1, fontSize: 20, fontWeight: '600', color: c.foreground }, children: "New poll" }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Send poll", disabled: busy || submitted || !ready, onPress: () => void send(), style: { minHeight: 44, justifyContent: 'center', opacity: ready ? 1 : .4 }, children: busy ? _jsx(ActivityIndicator, { color: c.accent }) : _jsx(Text, { style: { color: c.accent, fontSize: 17, fontWeight: '600' }, children: "Send" }) })] }), _jsx(KeyboardAvoidingView, { style: { flex: 1 }, behavior: Platform.OS === 'ios' ? 'padding' : 'height', children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", keyboardDismissMode: "on-drag", contentContainerStyle: { padding: 16, paddingBottom: 20, gap: 4 }, children: [_jsx(Text, { style: { color: c.secondary, fontSize: 14, marginTop: 12 }, children: "Question" }), _jsx(TextInput, { accessibilityLabel: "Poll question", placeholder: "Ask a question", placeholderTextColor: c.secondary, maxLength: 300, multiline: true, value: question, onChangeText: setQuestion, editable: !busy, style: inputStyle }), _jsx(Text, { style: { color: c.secondary, fontSize: 14, marginTop: 16 }, children: "Answers" }), options.map((option, i) => _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsx(TextInput, { accessibilityLabel: `Option ${i + 1}`, placeholder: "Add an option", placeholderTextColor: c.secondary, maxLength: 100, value: option, onChangeText: v => setOptions(items => items.map((x, n) => n === i ? v : x)), editable: !busy, style: { ...inputStyle, flex: 1 } }), options.length > 2 && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: `Remove option ${i + 1}`, disabled: busy, onPress: () => setOptions(items => items.filter((_, n) => n !== i)), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.secondary, fontSize: 26 }, children: "\u00D7" }) })] }, i)), options.length < 12 && _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => setOptions(items => [...items, '']), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 16 }, children: "Add option" }) }), _jsxs(View, { style: { minHeight: 48, flexDirection: 'row', gap: 12, alignItems: 'center', marginTop: 8 }, children: [_jsx(Text, { style: { color: c.foreground, fontSize: 16, flex: 1 }, children: "Allow multiple answers" }), _jsx(Switch, { accessibilityLabel: "Allow multiple answers", disabled: busy, value: multiple, onValueChange: setMultiple, trackColor: { true: c.accent } })] }), _jsx(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19 }, children: "Everyone in the chat can see the votes." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error })] }) })] }) }) });
|
|
54
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { MessagingMessage } from '@robono/messaging';
|
|
2
|
+
import type { LocalMessagingMedia, MessagingMediaStorage } from './media-controller.js';
|
|
3
|
+
/** Small, short-lived conversation cache. Every original is obtained through the
|
|
4
|
+
* authenticated storage adapter. Viewers receive their own disposable copy. */
|
|
5
|
+
export declare class RecentMessagingMedia {
|
|
6
|
+
private source;
|
|
7
|
+
private files;
|
|
8
|
+
private now;
|
|
9
|
+
private entries;
|
|
10
|
+
private running;
|
|
11
|
+
constructor(source: MessagingMediaStorage, files: {
|
|
12
|
+
copy: (file: LocalMessagingMedia) => Promise<LocalMessagingMedia>;
|
|
13
|
+
remove: (file: LocalMessagingMedia) => Promise<void>;
|
|
14
|
+
}, now?: () => number);
|
|
15
|
+
readonly storage: MessagingMediaStorage;
|
|
16
|
+
sync(messages: MessagingMessage[]): void;
|
|
17
|
+
clear(): void;
|
|
18
|
+
private drop;
|
|
19
|
+
private pump;
|
|
20
|
+
private start;
|
|
21
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const key = (m) => JSON.stringify([m.conversationId, m.id, m.media]);
|
|
2
|
+
/** Small, short-lived conversation cache. Every original is obtained through the
|
|
3
|
+
* authenticated storage adapter. Viewers receive their own disposable copy. */
|
|
4
|
+
export class RecentMessagingMedia {
|
|
5
|
+
source;
|
|
6
|
+
files;
|
|
7
|
+
now;
|
|
8
|
+
entries = new Map();
|
|
9
|
+
running = 0;
|
|
10
|
+
constructor(source, files, now = Date.now) {
|
|
11
|
+
this.source = source;
|
|
12
|
+
this.files = files;
|
|
13
|
+
this.now = now;
|
|
14
|
+
}
|
|
15
|
+
storage = {
|
|
16
|
+
stream: (message, context) => this.source.stream?.(message, context) ?? Promise.resolve(null),
|
|
17
|
+
upload: (file, context) => this.source.upload(file, context), discard: id => this.source.discard(id),
|
|
18
|
+
download: async (message, context) => {
|
|
19
|
+
const id = key(message), entry = this.entries.get(id);
|
|
20
|
+
if (!entry)
|
|
21
|
+
return this.source.download(message, context);
|
|
22
|
+
if (context.signal.aborted)
|
|
23
|
+
throw Error('Cancelled.');
|
|
24
|
+
if (entry.file && entry.expires <= this.now()) {
|
|
25
|
+
this.drop(id, entry);
|
|
26
|
+
return this.source.download(message, context);
|
|
27
|
+
}
|
|
28
|
+
let file;
|
|
29
|
+
try {
|
|
30
|
+
file = await this.start(id, entry);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
if (context.signal.aborted || this.entries.get(id) !== entry)
|
|
34
|
+
throw Error('Cancelled.');
|
|
35
|
+
// A failed speculative transfer must not make the user's explicit retry fail.
|
|
36
|
+
this.drop(id, entry);
|
|
37
|
+
return this.source.download(message, context);
|
|
38
|
+
}
|
|
39
|
+
if (context.signal.aborted || this.entries.get(id) !== entry)
|
|
40
|
+
throw Error('Cancelled.');
|
|
41
|
+
const copy = await this.files.copy(file);
|
|
42
|
+
if (context.signal.aborted || this.entries.get(id) !== entry) {
|
|
43
|
+
await this.files.remove(copy);
|
|
44
|
+
throw Error('Cancelled.');
|
|
45
|
+
}
|
|
46
|
+
context.onProgress(1);
|
|
47
|
+
return copy;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
sync(messages) {
|
|
51
|
+
// At most six recent photos/voice clips/small videos, 2 MiB each / 6 MiB total.
|
|
52
|
+
// Large videos and documents remain tap-to-download. Never preload all history.
|
|
53
|
+
const wanted = new Map();
|
|
54
|
+
let bytes = 0;
|
|
55
|
+
for (const m of [...messages].reverse()) {
|
|
56
|
+
if (wanted.size >= 6)
|
|
57
|
+
break;
|
|
58
|
+
const size = m.media?.byteSize;
|
|
59
|
+
if (this.source.stream && ['voice', 'video'].includes(m.kind))
|
|
60
|
+
continue;
|
|
61
|
+
if (m.deleted || !['image', 'voice', 'video'].includes(m.kind) || !size || size > 2 * 1048576 || bytes + size > 6 * 1048576)
|
|
62
|
+
continue;
|
|
63
|
+
wanted.set(key(m), m);
|
|
64
|
+
bytes += size;
|
|
65
|
+
}
|
|
66
|
+
for (const [id, entry] of this.entries)
|
|
67
|
+
if (!wanted.has(id))
|
|
68
|
+
this.drop(id, entry);
|
|
69
|
+
for (const [id, message] of wanted) {
|
|
70
|
+
const entry = this.entries.get(id);
|
|
71
|
+
if (!entry)
|
|
72
|
+
this.entries.set(id, { message, abort: new AbortController(), expires: 0, attempted: false });
|
|
73
|
+
// The client replaces message objects when it receives refreshed server history.
|
|
74
|
+
// A re-render with the same objects must not extend this access window.
|
|
75
|
+
else if (entry.message !== message) {
|
|
76
|
+
entry.message = message;
|
|
77
|
+
if (entry.file)
|
|
78
|
+
entry.expires = this.now() + 60000;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
this.pump();
|
|
82
|
+
}
|
|
83
|
+
clear() { for (const [id, entry] of this.entries)
|
|
84
|
+
this.drop(id, entry); }
|
|
85
|
+
drop(id, entry) {
|
|
86
|
+
if (this.entries.get(id) === entry)
|
|
87
|
+
this.entries.delete(id);
|
|
88
|
+
entry.abort.abort();
|
|
89
|
+
if (entry.file) {
|
|
90
|
+
void this.files.remove(entry.file).catch(() => { });
|
|
91
|
+
entry.file = undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
pump() {
|
|
95
|
+
for (const [id, entry] of this.entries) {
|
|
96
|
+
if (this.running >= 2)
|
|
97
|
+
break;
|
|
98
|
+
if (!entry.attempted) {
|
|
99
|
+
this.running++;
|
|
100
|
+
void this.start(id, entry).catch(() => { }).finally(() => { this.running--; this.pump(); });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
start(id, entry) {
|
|
105
|
+
if (entry.file)
|
|
106
|
+
return Promise.resolve(entry.file);
|
|
107
|
+
if (entry.work)
|
|
108
|
+
return entry.work;
|
|
109
|
+
entry.attempted = true;
|
|
110
|
+
entry.work = (async () => {
|
|
111
|
+
const file = await this.source.download(entry.message, { signal: entry.abort.signal, onProgress: () => { } });
|
|
112
|
+
if (entry.abort.signal.aborted || this.entries.get(id) !== entry) {
|
|
113
|
+
await this.files.remove(file);
|
|
114
|
+
throw Error('Cancelled.');
|
|
115
|
+
}
|
|
116
|
+
entry.file = file;
|
|
117
|
+
entry.expires = this.now() + 60000;
|
|
118
|
+
return file;
|
|
119
|
+
})();
|
|
120
|
+
return entry.work;
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/safety.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { MessagingClient, MessagingAppearance, MessagingState } from '@robono/messaging';
|
|
3
|
+
/** Uses the authenticated messaging client's durable safety operations, or the host can keep its own UI. */
|
|
4
|
+
export declare function MessagingSafety({ client, state, appearance, name, messageId, onClose }: {
|
|
5
|
+
client: MessagingClient;
|
|
6
|
+
state: MessagingState;
|
|
7
|
+
appearance: MessagingAppearance;
|
|
8
|
+
name: string;
|
|
9
|
+
messageId?: string;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
}): React.JSX.Element;
|