@robono/messaging-react-native 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +130 -0
- package/README.md +240 -2
- package/SBOM.spdx.json +1847 -0
- package/app.plugin.cjs +5 -0
- package/dist/call-message.d.ts +6 -0
- package/dist/call-message.js +109 -0
- package/dist/calling.d.ts +15 -0
- package/dist/calling.js +22 -0
- package/dist/expo-calling.d.ts +6 -0
- package/dist/expo-calling.js +7 -0
- package/dist/expo-download.d.ts +3 -0
- package/dist/expo-download.js +43 -0
- package/dist/expo-media.d.ts +19 -0
- package/dist/expo-media.js +411 -0
- package/dist/expo-outbox.d.ts +13 -0
- package/dist/expo-outbox.js +81 -0
- package/dist/expo-saved.d.ts +24 -0
- package/dist/expo-saved.js +76 -0
- package/dist/expo-social.d.ts +3 -0
- package/dist/expo-social.js +21 -0
- package/dist/expo-thumbnails.d.ts +9 -0
- package/dist/expo-thumbnails.js +68 -0
- package/dist/expo.d.ts +42 -0
- package/dist/expo.js +549 -0
- package/dist/forward.d.ts +13 -0
- package/dist/forward.js +61 -0
- package/dist/icons.d.ts +7 -0
- package/dist/icons.js +10 -0
- package/dist/inbox.d.ts +16 -0
- package/dist/inbox.js +48 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.js +696 -0
- package/dist/media-controller.d.ts +116 -0
- package/dist/media-controller.js +372 -0
- package/dist/media-readiness.d.ts +4 -0
- package/dist/media-readiness.js +4 -0
- package/dist/media-ui.d.ts +62 -0
- package/dist/media-ui.js +46 -0
- package/dist/message-links.d.ts +5 -0
- package/dist/message-links.js +30 -0
- package/dist/message-order.d.ts +4 -0
- package/dist/message-order.js +17 -0
- package/dist/native-outbox.d.ts +49 -0
- package/dist/native-outbox.js +51 -0
- package/dist/pollVoteQueue.d.ts +3 -0
- package/dist/pollVoteQueue.js +29 -0
- package/dist/polls.d.ts +25 -0
- package/dist/polls.js +121 -0
- package/dist/popup.d.ts +9 -0
- package/dist/popup.js +7 -0
- package/dist/recent-media.d.ts +21 -0
- package/dist/recent-media.js +123 -0
- package/dist/safety.d.ts +12 -0
- package/dist/safety.js +53 -0
- package/dist/saved.d.ts +10 -0
- package/dist/saved.js +46 -0
- package/dist/search.d.ts +9 -0
- package/dist/search.js +41 -0
- package/dist/social.d.ts +19 -0
- package/dist/social.js +35 -0
- package/dist/use-quote-gesture.d.ts +24 -0
- package/dist/use-quote-gesture.js +127 -0
- package/dist/visual/RobonoIcon.d.ts +232 -0
- package/dist/visual/RobonoIcon.js +21 -0
- package/dist/visual/icon-nodes.d.ts +3 -0
- package/dist/visual/icon-nodes.js +2 -0
- package/dist/visual/icon-paths.d.ts +221 -0
- package/dist/visual/icon-paths.js +221 -0
- package/dist/voice-composer.d.ts +37 -0
- package/dist/voice-composer.js +20 -0
- package/licenses/robono-icons-lucide-license.txt +43 -0
- package/licenses/robono-icons-weather-attribution.txt +1 -0
- package/licenses/robono-icons-weather-license.txt +21 -0
- package/package.json +126 -4
- package/plugin/preserve-call-audio.cjs +22 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { type MessageKind, type MessageMedia, type MessagingMessage, type MessagingSettings } from '@robono/messaging';
|
|
2
|
+
export type MediaKind = Exclude<MessageKind, 'text' | 'poll'>;
|
|
3
|
+
export interface LocalMessagingMedia extends Omit<MessageMedia, 'assetId'> {
|
|
4
|
+
uri: string;
|
|
5
|
+
/** Ephemeral authorized remote playback source. Never persist it in chat history. */
|
|
6
|
+
stream?: {
|
|
7
|
+
headers?: Record<string, string>;
|
|
8
|
+
};
|
|
9
|
+
resumeAtMs?: number;
|
|
10
|
+
autoPlay?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface MediaTransferOptions {
|
|
13
|
+
signal: AbortSignal;
|
|
14
|
+
onProgress: (fraction: number) => void;
|
|
15
|
+
}
|
|
16
|
+
export interface MessagingMediaStorage {
|
|
17
|
+
/** Keep uploadId stable on retry. Verify bytes/type/duration on the backend before returning. */
|
|
18
|
+
upload(file: LocalMessagingMedia, context: MediaTransferOptions & {
|
|
19
|
+
uploadId: string;
|
|
20
|
+
conversationId: string;
|
|
21
|
+
kind: MediaKind;
|
|
22
|
+
}): Promise<MessageMedia>;
|
|
23
|
+
thumbnail?(message: MessagingMessage, context: MediaTransferOptions): Promise<LocalMessagingMedia | null>;
|
|
24
|
+
download(message: MessagingMessage, context: MediaTransferOptions): Promise<LocalMessagingMedia>;
|
|
25
|
+
/** Optional native streaming for voice/video. Return null to use a verified download. */
|
|
26
|
+
stream?(message: MessagingMessage, context: MediaTransferOptions): Promise<LocalMessagingMedia | null>;
|
|
27
|
+
/** Revoke/delete an unfinished upload. Must be idempotent and safe after an interrupted response. */
|
|
28
|
+
discard(uploadId: string): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export interface MessagingMediaDevice {
|
|
31
|
+
pick(kind: Exclude<MediaKind, 'voice'>, settings: MessagingSettings, signal: AbortSignal, source?: "camera" | "call-video"): Promise<LocalMessagingMedia | null>;
|
|
32
|
+
startRecording(maxDurationMs: number, signal: AbortSignal): Promise<void>;
|
|
33
|
+
recordingStatus(): {
|
|
34
|
+
durationMs: number;
|
|
35
|
+
recording: boolean;
|
|
36
|
+
limitReached?: boolean;
|
|
37
|
+
level?: number;
|
|
38
|
+
};
|
|
39
|
+
stopRecording(): Promise<LocalMessagingMedia | null>;
|
|
40
|
+
/** Delete only cache copies owned by this adapter, never originals in the user's library. */
|
|
41
|
+
remove(file: LocalMessagingMedia): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
export interface MessagingMediaState {
|
|
44
|
+
phase: 'idle' | 'choosing' | 'preparing' | 'recording' | 'review' | 'uploading' | 'opening' | 'viewing' | 'failed';
|
|
45
|
+
kind: MediaKind;
|
|
46
|
+
file: LocalMessagingMedia | null;
|
|
47
|
+
elapsedMs: number;
|
|
48
|
+
maxDurationMs: number;
|
|
49
|
+
progress: number;
|
|
50
|
+
error: string | null;
|
|
51
|
+
retryUpload: boolean;
|
|
52
|
+
waveform?: number[];
|
|
53
|
+
}
|
|
54
|
+
/** Single attachment lifecycle shared by iOS and Android. Cancellation invalidates every late result. */
|
|
55
|
+
export declare class MessagingMediaController {
|
|
56
|
+
private options;
|
|
57
|
+
private state;
|
|
58
|
+
private listeners;
|
|
59
|
+
private abort;
|
|
60
|
+
private intent;
|
|
61
|
+
private generation;
|
|
62
|
+
private resolve;
|
|
63
|
+
private timer;
|
|
64
|
+
private stopping;
|
|
65
|
+
private settings;
|
|
66
|
+
private conversationId;
|
|
67
|
+
private uploadId;
|
|
68
|
+
private disposed;
|
|
69
|
+
private closing;
|
|
70
|
+
private stageRecording;
|
|
71
|
+
private openedMessage;
|
|
72
|
+
private prepared;
|
|
73
|
+
private preparedUploads;
|
|
74
|
+
private preparedAborts;
|
|
75
|
+
getPrepared: (assetId: string) => LocalMessagingMedia | undefined;
|
|
76
|
+
restorePrepared(assetId: string, file: LocalMessagingMedia, kind: MediaKind): void;
|
|
77
|
+
releasePrepared(assetId: string): Promise<void>;
|
|
78
|
+
/** Browser drafts stay local until Send; the original ID survives upload retries. */
|
|
79
|
+
uploadPrepared(assetId: string, kind: MediaKind): Promise<MessageMedia>;
|
|
80
|
+
private forwardUploads;
|
|
81
|
+
/** Download through source authorization and upload a new destination-owned asset. */
|
|
82
|
+
prepareForward(message: MessagingMessage, target: {
|
|
83
|
+
conversationId: string;
|
|
84
|
+
settings: MessagingSettings;
|
|
85
|
+
uploadId: string;
|
|
86
|
+
}): Promise<MessageMedia>;
|
|
87
|
+
constructor(options: {
|
|
88
|
+
device: MessagingMediaDevice;
|
|
89
|
+
storage: MessagingMediaStorage;
|
|
90
|
+
createId: () => string;
|
|
91
|
+
getSettings: () => MessagingSettings;
|
|
92
|
+
getConversationId: () => string;
|
|
93
|
+
stageLocal?: boolean;
|
|
94
|
+
});
|
|
95
|
+
getSnapshot: () => MessagingMediaState;
|
|
96
|
+
subscribe: (f: () => void) => () => void;
|
|
97
|
+
private update;
|
|
98
|
+
private isCurrent;
|
|
99
|
+
pick(kind: MediaKind, source?: "camera" | "call-video", selectionIntent?: "preview" | "send" | "attach"): Promise<MessageMedia | null>;
|
|
100
|
+
private check;
|
|
101
|
+
finishRecording(interrupted?: boolean): Promise<void>;
|
|
102
|
+
send(): Promise<void>;
|
|
103
|
+
/** View an owned cache copy of a draft. Closing never removes the original draft. */
|
|
104
|
+
previewLocal(file: LocalMessagingMedia, kind: MediaKind): Promise<void>;
|
|
105
|
+
open(message: MessagingMessage): Promise<void>;
|
|
106
|
+
/** Save/share a local copy of a playing stream without replacing its playback source. */
|
|
107
|
+
exportFile<T>(save: (file: LocalMessagingMedia) => Promise<T>): Promise<T>;
|
|
108
|
+
/** Recover a failed/expired native stream once through fresh authorized download. */
|
|
109
|
+
fallbackStream(uri: string, positionMs: number, autoPlay: boolean): Promise<void>;
|
|
110
|
+
private openMedia;
|
|
111
|
+
cancel(): Promise<void>;
|
|
112
|
+
private closeCurrent;
|
|
113
|
+
interrupt(): Promise<void>;
|
|
114
|
+
dispose(): Promise<void>;
|
|
115
|
+
private error;
|
|
116
|
+
}
|
|
@@ -0,0 +1,372 @@
|
|
|
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
|
+
preparedUploads = new Map();
|
|
22
|
+
preparedAborts = new Map();
|
|
23
|
+
getPrepared = (assetId) => this.prepared.get(assetId);
|
|
24
|
+
restorePrepared(assetId, file, kind) {
|
|
25
|
+
if (this.disposed)
|
|
26
|
+
throw Error('Messaging media is closed.');
|
|
27
|
+
validateMessageMedia(kind, { ...file, assetId }, this.options.getSettings());
|
|
28
|
+
this.prepared.set(assetId, file);
|
|
29
|
+
}
|
|
30
|
+
async releasePrepared(assetId) { const file = this.prepared.get(assetId); this.prepared.delete(assetId); this.preparedUploads.delete(assetId); this.preparedAborts.get(assetId)?.abort(); this.preparedAborts.delete(assetId); if (file)
|
|
31
|
+
await this.options.device.remove(file).catch(() => { }); }
|
|
32
|
+
/** Browser drafts stay local until Send; the original ID survives upload retries. */
|
|
33
|
+
async uploadPrepared(assetId, kind) {
|
|
34
|
+
const saved = this.preparedUploads.get(assetId);
|
|
35
|
+
if (saved)
|
|
36
|
+
return saved;
|
|
37
|
+
const file = this.prepared.get(assetId);
|
|
38
|
+
if (!file || this.disposed)
|
|
39
|
+
throw Error('Choose the attachment again.');
|
|
40
|
+
const conversationId = this.options.getConversationId(), abort = new AbortController();
|
|
41
|
+
this.preparedAborts.set(assetId, abort);
|
|
42
|
+
try {
|
|
43
|
+
validateMessageMedia(kind, { ...file, assetId }, this.options.getSettings());
|
|
44
|
+
const media = await this.options.storage.upload(file, { uploadId: assetId, conversationId, kind, signal: abort.signal, onProgress: () => { } });
|
|
45
|
+
if (abort.signal.aborted || this.disposed || conversationId !== this.options.getConversationId())
|
|
46
|
+
throw Error('The conversation changed.');
|
|
47
|
+
validateMessageMedia(kind, media, this.options.getSettings());
|
|
48
|
+
this.preparedUploads.set(assetId, media);
|
|
49
|
+
return media;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
if (this.preparedAborts.get(assetId) === abort)
|
|
53
|
+
this.preparedAborts.delete(assetId);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
forwardUploads = new Map();
|
|
57
|
+
/** Download through source authorization and upload a new destination-owned asset. */
|
|
58
|
+
async prepareForward(message, target) {
|
|
59
|
+
if (!message.media || message.kind === 'text' || message.kind === 'poll' || this.disposed)
|
|
60
|
+
throw Error('This attachment cannot be forwarded.');
|
|
61
|
+
validateMessageMedia(message.kind, message.media, target.settings);
|
|
62
|
+
const key = target.conversationId + ':' + target.uploadId, saved = this.forwardUploads.get(key);
|
|
63
|
+
if (saved)
|
|
64
|
+
return saved;
|
|
65
|
+
const source = this.options.getConversationId(), abort = new AbortController();
|
|
66
|
+
this.preparedAborts.set(key, abort);
|
|
67
|
+
let file;
|
|
68
|
+
try {
|
|
69
|
+
file = await this.options.storage.download(message, { signal: abort.signal, onProgress: () => { } });
|
|
70
|
+
if (abort.signal.aborted || this.disposed || source !== this.options.getConversationId())
|
|
71
|
+
throw Error('The conversation changed.');
|
|
72
|
+
const media = await this.options.storage.upload(file, { ...target, kind: message.kind, signal: abort.signal, onProgress: () => { } });
|
|
73
|
+
validateMessageMedia(message.kind, media, target.settings);
|
|
74
|
+
this.forwardUploads.set(key, media);
|
|
75
|
+
if (this.forwardUploads.size > 100)
|
|
76
|
+
this.forwardUploads.delete(this.forwardUploads.keys().next().value);
|
|
77
|
+
return media;
|
|
78
|
+
}
|
|
79
|
+
finally {
|
|
80
|
+
this.preparedAborts.delete(key);
|
|
81
|
+
if (file)
|
|
82
|
+
await this.options.device.remove(file).catch(() => { });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
constructor(options) {
|
|
86
|
+
this.options = options;
|
|
87
|
+
}
|
|
88
|
+
getSnapshot = () => this.state;
|
|
89
|
+
subscribe = (f) => { this.listeners.add(f); return () => { this.listeners.delete(f); }; };
|
|
90
|
+
update(p) { this.state = { ...this.state, ...p }; this.listeners.forEach(f => f()); }
|
|
91
|
+
isCurrent(g) { return !this.disposed && this.generation === g && !this.abort?.signal.aborted; }
|
|
92
|
+
async pick(kind, source, selectionIntent = "preview") {
|
|
93
|
+
const intent = ++this.intent;
|
|
94
|
+
await this.closeCurrent();
|
|
95
|
+
if (intent !== this.intent)
|
|
96
|
+
return null;
|
|
97
|
+
if (this.disposed)
|
|
98
|
+
throw Error('Messaging media is closed.');
|
|
99
|
+
const settings = this.options.getSettings();
|
|
100
|
+
if (!settings.enabled || !settings.features[kind])
|
|
101
|
+
throw Error('This attachment type is unavailable.');
|
|
102
|
+
if (source === 'camera' && (!['image', 'video'].includes(kind) || !settings.appearance.showCamera))
|
|
103
|
+
throw Error('Camera capture is unavailable.');
|
|
104
|
+
if (source === 'call-video' && (kind !== 'video' || !settings.enabled || !settings.features.video || settings.unansweredCalls?.video !== 'video'))
|
|
105
|
+
throw Error('Video messages after a call are unavailable.');
|
|
106
|
+
this.stageRecording = kind === 'voice' && selectionIntent === 'attach';
|
|
107
|
+
this.settings = settings;
|
|
108
|
+
this.conversationId = this.options.getConversationId();
|
|
109
|
+
this.uploadId = this.options.createId();
|
|
110
|
+
const g = ++this.generation;
|
|
111
|
+
const abort = this.abort = new AbortController();
|
|
112
|
+
const result = new Promise(resolve => { this.resolve = resolve; });
|
|
113
|
+
const maximum = (settings.voice?.maxDurationSeconds ?? 60) * 1000;
|
|
114
|
+
this.update({ phase: kind === 'voice' ? 'preparing' : 'choosing', kind, file: null, error: null, progress: 0, retryUpload: false, elapsedMs: 0, waveform: [], maxDurationMs: maximum });
|
|
115
|
+
void (async () => {
|
|
116
|
+
try {
|
|
117
|
+
if (kind === 'voice') {
|
|
118
|
+
await this.options.device.startRecording(maximum, abort.signal);
|
|
119
|
+
if (!this.isCurrent(g))
|
|
120
|
+
return;
|
|
121
|
+
this.update({ phase: 'recording' });
|
|
122
|
+
this.timer = setInterval(() => {
|
|
123
|
+
if (!this.isCurrent(g) || this.state.phase !== 'recording')
|
|
124
|
+
return;
|
|
125
|
+
try {
|
|
126
|
+
const status = this.options.device.recordingStatus();
|
|
127
|
+
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) } : {}) });
|
|
128
|
+
if (status.durationMs >= maximum || !status.recording)
|
|
129
|
+
void this.finishRecording(!status.recording && status.durationMs < maximum && !status.limitReached);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
void this.finishRecording(true);
|
|
133
|
+
}
|
|
134
|
+
}, 100);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
const file = await this.options.device.pick(kind, settings, abort.signal, source);
|
|
138
|
+
if (!this.isCurrent(g)) {
|
|
139
|
+
if (file)
|
|
140
|
+
await this.options.device.remove(file);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!file) {
|
|
144
|
+
await this.cancel();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.update({ file, ...(source === 'camera' ? { kind: file.mimeType.startsWith('video/') ? 'video' : 'image' } : {}) });
|
|
148
|
+
this.check(file);
|
|
149
|
+
this.update({ phase: 'review' });
|
|
150
|
+
if (this.options.stageLocal && selectionIntent === 'attach') {
|
|
151
|
+
const media = { assetId: this.uploadId, mimeType: file.mimeType, fileName: file.fileName, byteSize: file.byteSize, ...(file.durationMs ? { durationMs: file.durationMs } : {}) };
|
|
152
|
+
this.prepared.set(media.assetId, file);
|
|
153
|
+
const resolve = this.resolve;
|
|
154
|
+
this.resolve = null;
|
|
155
|
+
this.uploadId = '';
|
|
156
|
+
this.abort = null;
|
|
157
|
+
this.update({ phase: 'idle', file: null, error: null });
|
|
158
|
+
resolve?.(media);
|
|
159
|
+
}
|
|
160
|
+
else if (selectionIntent === 'send' && !source)
|
|
161
|
+
await this.send();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
catch (e) {
|
|
165
|
+
if (this.isCurrent(g))
|
|
166
|
+
this.update({ phase: 'failed', error: this.error(e) });
|
|
167
|
+
}
|
|
168
|
+
})();
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
check(file) {
|
|
172
|
+
validateMessageMedia(this.state.kind, { ...file, assetId: 'pending-upload' }, this.options.getSettings());
|
|
173
|
+
if (this.options.getConversationId() !== this.conversationId)
|
|
174
|
+
throw Error('The conversation changed. Choose the attachment again.');
|
|
175
|
+
}
|
|
176
|
+
async finishRecording(interrupted = false) {
|
|
177
|
+
if (this.stopping)
|
|
178
|
+
return this.stopping;
|
|
179
|
+
if (this.state.phase !== 'recording')
|
|
180
|
+
return;
|
|
181
|
+
clearInterval(this.timer);
|
|
182
|
+
this.timer = undefined;
|
|
183
|
+
const g = this.generation;
|
|
184
|
+
this.update({ phase: 'preparing' });
|
|
185
|
+
const work = (async () => {
|
|
186
|
+
try {
|
|
187
|
+
const file = await this.options.device.stopRecording();
|
|
188
|
+
if (!this.isCurrent(g)) {
|
|
189
|
+
if (file)
|
|
190
|
+
await this.options.device.remove(file);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (!file)
|
|
194
|
+
throw Error('No recording was captured. Cancel and record again.');
|
|
195
|
+
this.update({ file, elapsedMs: file.durationMs ?? 0 });
|
|
196
|
+
this.check(file);
|
|
197
|
+
this.update({ phase: 'review', error: interrupted ? 'Recording stopped. Listen before sending.' : null });
|
|
198
|
+
if (this.stageRecording)
|
|
199
|
+
await this.send();
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
if (this.isCurrent(g))
|
|
203
|
+
this.update({ phase: 'failed', error: this.error(e) });
|
|
204
|
+
}
|
|
205
|
+
})();
|
|
206
|
+
this.stopping = work;
|
|
207
|
+
try {
|
|
208
|
+
await work;
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
if (this.stopping === work)
|
|
212
|
+
this.stopping = null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async send() {
|
|
216
|
+
if (!['review', 'failed'].includes(this.state.phase) || !this.state.file || !this.resolve)
|
|
217
|
+
return;
|
|
218
|
+
const file = this.state.file, g = this.generation, abort = this.abort, uploadId = this.uploadId;
|
|
219
|
+
let retryUpload = false;
|
|
220
|
+
try {
|
|
221
|
+
this.check(file);
|
|
222
|
+
if (this.stageRecording && this.options.stageLocal) {
|
|
223
|
+
const media = { assetId: uploadId, mimeType: file.mimeType, fileName: file.fileName, byteSize: file.byteSize, durationMs: file.durationMs, waveform: [...(this.state.waveform ?? [])] };
|
|
224
|
+
this.prepared.set(uploadId, file);
|
|
225
|
+
const resolve = this.resolve;
|
|
226
|
+
this.resolve = null;
|
|
227
|
+
this.uploadId = '';
|
|
228
|
+
this.abort = null;
|
|
229
|
+
this.update({ phase: 'idle', file: null, error: null });
|
|
230
|
+
resolve?.(media);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
this.update({ phase: 'uploading', progress: 0, error: null, retryUpload: false });
|
|
234
|
+
retryUpload = true;
|
|
235
|
+
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))
|
|
236
|
+
this.update({ progress: Math.max(0, Math.min(1, f)) }); } });
|
|
237
|
+
retryUpload = false;
|
|
238
|
+
if (!this.isCurrent(g)) {
|
|
239
|
+
await this.options.storage.discard(uploadId);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
validateMessageMedia(this.state.kind, media, this.options.getSettings());
|
|
243
|
+
if (this.options.getConversationId() !== this.conversationId)
|
|
244
|
+
throw Error('The conversation changed. Choose the attachment again.');
|
|
245
|
+
const resolve = this.resolve;
|
|
246
|
+
this.resolve = null;
|
|
247
|
+
this.uploadId = '';
|
|
248
|
+
this.abort = null;
|
|
249
|
+
if (this.stageRecording)
|
|
250
|
+
this.prepared.set(media.assetId, file);
|
|
251
|
+
this.update({ phase: 'idle', file: null, error: null });
|
|
252
|
+
resolve?.(media);
|
|
253
|
+
if (!this.stageRecording)
|
|
254
|
+
await this.options.device.remove(file).catch(() => { });
|
|
255
|
+
}
|
|
256
|
+
catch (e) {
|
|
257
|
+
if (this.isCurrent(g))
|
|
258
|
+
this.update({ phase: 'failed', error: this.error(e), retryUpload });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/** View an owned cache copy of a draft. Closing never removes the original draft. */
|
|
262
|
+
async previewLocal(file, kind) {
|
|
263
|
+
const intent = ++this.intent;
|
|
264
|
+
await this.closeCurrent();
|
|
265
|
+
if (intent !== this.intent || this.disposed) {
|
|
266
|
+
await this.options.device.remove(file);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.conversationId = this.options.getConversationId();
|
|
270
|
+
this.update({ phase: 'viewing', kind, file, error: null, progress: 0 });
|
|
271
|
+
}
|
|
272
|
+
open(message) { return this.openMedia(message); }
|
|
273
|
+
/** Save/share a local copy of a playing stream without replacing its playback source. */
|
|
274
|
+
async exportFile(save) {
|
|
275
|
+
const file = this.state.file, generation = this.generation, message = this.openedMessage;
|
|
276
|
+
if (this.disposed || !file || this.state.phase !== 'viewing')
|
|
277
|
+
throw Error('The media viewer is closed.');
|
|
278
|
+
if (!file.stream)
|
|
279
|
+
return save(file);
|
|
280
|
+
if (!message || !this.abort)
|
|
281
|
+
throw Error('The attachment is unavailable.');
|
|
282
|
+
const copy = await this.options.storage.download(message, { signal: this.abort.signal, onProgress: () => { } });
|
|
283
|
+
try {
|
|
284
|
+
if (!this.isCurrent(generation) || this.options.getConversationId() !== message.conversationId)
|
|
285
|
+
throw Error('The media viewer is closed.');
|
|
286
|
+
if (copy.stream)
|
|
287
|
+
throw Error('A local file is required to save this attachment.');
|
|
288
|
+
return await save(copy);
|
|
289
|
+
}
|
|
290
|
+
finally {
|
|
291
|
+
await this.options.device.remove(copy).catch(() => { });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Recover a failed/expired native stream once through fresh authorized download. */
|
|
295
|
+
fallbackStream(uri, positionMs, autoPlay) {
|
|
296
|
+
if (!this.state.file?.stream || this.state.file.uri !== uri || !this.openedMessage)
|
|
297
|
+
return Promise.resolve();
|
|
298
|
+
return this.openMedia(this.openedMessage, { download: true, positionMs, autoPlay });
|
|
299
|
+
}
|
|
300
|
+
async openMedia(message, recovery) {
|
|
301
|
+
const intent = ++this.intent;
|
|
302
|
+
await this.closeCurrent();
|
|
303
|
+
if (intent !== this.intent)
|
|
304
|
+
return;
|
|
305
|
+
if (this.disposed)
|
|
306
|
+
throw Error('Messaging media is closed.');
|
|
307
|
+
if (!message.media || message.kind === 'text' || message.kind === 'poll')
|
|
308
|
+
return;
|
|
309
|
+
const g = ++this.generation, abort = this.abort = new AbortController();
|
|
310
|
+
this.conversationId = message.conversationId;
|
|
311
|
+
this.openedMessage = message;
|
|
312
|
+
this.update({ phase: 'opening', kind: message.kind, file: null, error: null, progress: 0 });
|
|
313
|
+
try {
|
|
314
|
+
const context = { signal: abort.signal, onProgress: f => { if (this.isCurrent(g))
|
|
315
|
+
this.update({ progress: Math.max(0, Math.min(1, f)) }); } };
|
|
316
|
+
const remote = !recovery?.download && ['voice', 'video'].includes(message.kind) ? await this.options.storage.stream?.(message, context) : null;
|
|
317
|
+
if (!this.isCurrent(g))
|
|
318
|
+
return;
|
|
319
|
+
const file = remote ?? await this.options.storage.download(message, context);
|
|
320
|
+
if (!this.isCurrent(g) || this.options.getConversationId() !== message.conversationId) {
|
|
321
|
+
await this.options.device.remove(file);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
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 });
|
|
325
|
+
}
|
|
326
|
+
catch (e) {
|
|
327
|
+
if (this.isCurrent(g))
|
|
328
|
+
this.update({ phase: 'failed', error: this.error(e) });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
cancel() { ++this.intent; return this.closeCurrent(); }
|
|
332
|
+
closeCurrent() {
|
|
333
|
+
if (this.closing)
|
|
334
|
+
return this.closing;
|
|
335
|
+
const wasRecording = ['recording', 'preparing'].includes(this.state.phase) && this.state.kind === 'voice';
|
|
336
|
+
const file = this.state.file, id = this.uploadId, resolve = this.resolve, stopping = this.stopping;
|
|
337
|
+
this.openedMessage = null;
|
|
338
|
+
++this.generation;
|
|
339
|
+
this.abort?.abort();
|
|
340
|
+
this.abort = null;
|
|
341
|
+
this.resolve = null;
|
|
342
|
+
this.uploadId = '';
|
|
343
|
+
clearInterval(this.timer);
|
|
344
|
+
this.timer = undefined;
|
|
345
|
+
this.update({ phase: 'idle', file: null, error: null });
|
|
346
|
+
resolve?.(null);
|
|
347
|
+
const work = (async () => {
|
|
348
|
+
if (stopping)
|
|
349
|
+
await stopping;
|
|
350
|
+
else if (wasRecording) {
|
|
351
|
+
const recorded = await this.options.device.stopRecording().catch(() => null);
|
|
352
|
+
if (recorded)
|
|
353
|
+
await this.options.device.remove(recorded).catch(() => { });
|
|
354
|
+
}
|
|
355
|
+
if (file)
|
|
356
|
+
await this.options.device.remove(file).catch(() => { });
|
|
357
|
+
if (id)
|
|
358
|
+
await this.options.storage.discard(id).catch(() => { });
|
|
359
|
+
})();
|
|
360
|
+
this.closing = work;
|
|
361
|
+
void work.finally(() => { if (this.closing === work)
|
|
362
|
+
this.closing = null; });
|
|
363
|
+
return work;
|
|
364
|
+
}
|
|
365
|
+
async interrupt() { if (this.state.phase === 'recording')
|
|
366
|
+
await this.finishRecording(true);
|
|
367
|
+
else if (this.state.phase === 'preparing')
|
|
368
|
+
await this.cancel(); }
|
|
369
|
+
async dispose() { this.disposed = true; for (const abort of this.preparedAborts.values())
|
|
370
|
+
abort.abort(); this.forwardUploads.clear(); await this.cancel(); await Promise.all([...this.prepared.keys()].map(id => this.releasePrepared(id))); this.listeners.clear(); }
|
|
371
|
+
error(e) { return e instanceof Error ? e.message : 'The attachment could not be opened. Try again.'; }
|
|
372
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type MediaReadyStage = 'image-loaded' | 'video-frame' | 'audio-playing' | 'video-playing' | 'file-opened';
|
|
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,62 @@
|
|
|
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
|
+
showWaveform?: boolean;
|
|
9
|
+
footer?: ReactNode;
|
|
10
|
+
onOpen: () => void;
|
|
11
|
+
onLongPress?: () => void;
|
|
12
|
+
}
|
|
13
|
+
export interface PlaybackProps {
|
|
14
|
+
playing?: boolean;
|
|
15
|
+
loading?: boolean;
|
|
16
|
+
positionMs?: number;
|
|
17
|
+
durationMs?: number;
|
|
18
|
+
rate?: number;
|
|
19
|
+
error?: string;
|
|
20
|
+
onToggle: () => void;
|
|
21
|
+
onSeek?: (ms: number) => void;
|
|
22
|
+
onRate?: () => void;
|
|
23
|
+
}
|
|
24
|
+
/** Same chat controls on web, iOS and Android. Audio engines supply state, not a different UI. */
|
|
25
|
+
export declare function VoicePlayback({ ink, accent, waveform, showWaveform, footer, onLongPress, playing, loading, positionMs, durationMs, error, onToggle, onSeek }: PlaybackProps & {
|
|
26
|
+
ink: string;
|
|
27
|
+
accent: string;
|
|
28
|
+
waveform?: number[];
|
|
29
|
+
showWaveform?: boolean;
|
|
30
|
+
footer?: ReactNode;
|
|
31
|
+
onLongPress?: () => void;
|
|
32
|
+
}): React.JSX.Element;
|
|
33
|
+
export declare function MediaPlaybackControls({ playing, loading, positionMs, durationMs, error, onToggle, onSeek, onFullscreen, fullscreen }: PlaybackProps & {
|
|
34
|
+
onFullscreen?: () => void;
|
|
35
|
+
fullscreen?: boolean;
|
|
36
|
+
}): React.JSX.Element;
|
|
37
|
+
/** A full-size photo surface. The same tap-to-hide-controls, pinch and pan handling runs in the preview and native app. */
|
|
38
|
+
export declare function ZoomablePhoto({ uri, label, onTap }: {
|
|
39
|
+
uri: string;
|
|
40
|
+
label?: string;
|
|
41
|
+
onTap?: () => void;
|
|
42
|
+
}): React.JSX.Element;
|
|
43
|
+
export declare function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview, onDownload, onShare, onForward, onDelete, downloading }: {
|
|
44
|
+
onForward?: () => void;
|
|
45
|
+
onDelete?: () => void;
|
|
46
|
+
downloading?: boolean;
|
|
47
|
+
onDownload?: () => void;
|
|
48
|
+
onShare?: () => void;
|
|
49
|
+
title: string;
|
|
50
|
+
subtitle?: string;
|
|
51
|
+
kind: 'image' | 'video' | 'document' | 'voice';
|
|
52
|
+
uri?: string;
|
|
53
|
+
fileName?: string;
|
|
54
|
+
byteSize?: number;
|
|
55
|
+
children?: ReactNode;
|
|
56
|
+
loading?: boolean;
|
|
57
|
+
error?: string;
|
|
58
|
+
onClose: () => void;
|
|
59
|
+
onFileAction?: () => void;
|
|
60
|
+
reviewAction?: string;
|
|
61
|
+
onReview?: () => void;
|
|
62
|
+
}): React.JSX.Element;
|
package/dist/media-ui.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useContext, useState, useEffect } 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, showWaveform = true, 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: showWaveform ? "Voice waveform" : "Voice position", 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: showWaveform ? 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); }) : _jsx(View, { pointerEvents: "none", style: { flex: 1, height: 3, borderRadius: 2, backgroundColor: ink + '44' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: accent, width: `${progress * 100}%` } }) }) }), _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, onFullscreen, fullscreen = false }) {
|
|
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 => { const bounds = Platform.OS === "web" ? e.currentTarget.getBoundingClientRect() : null; const x = bounds ? e.nativeEvent.clientX - bounds.left : e.nativeEvent.locationX, total = bounds?.width ?? width.current; if (total > 0 && Number.isFinite(x))
|
|
15
|
+
onSeek?.(Math.max(0, Math.min(1, x / total)) * 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) }), onFullscreen && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: fullscreen ? "Exit full screen" : "Full screen video", disabled: loading, onPress: onFullscreen, style: { width: 44, height: 44, alignItems: "center", justifyContent: "center" }, children: _jsx(MessageIcon, { name: fullscreen ? "close" : "open-in-new", color: "#fff", size: 22 }) })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', fontSize: 13 }, children: error })] });
|
|
16
|
+
}
|
|
17
|
+
/** A full-size photo surface. The same tap-to-hide-controls, pinch and pan handling runs in the preview and native app. */
|
|
18
|
+
export function ZoomablePhoto({ uri, label = 'Photo', onTap }) {
|
|
19
|
+
const mediaReady = useContext(MediaReadiness);
|
|
20
|
+
const [zoom, setZoom] = useState(1), [offset, setOffset] = useState({ x: 0, y: 0 }), [error, setError] = useState(false);
|
|
21
|
+
const latest = useRef({ zoom, offset, width: 1, height: 1 });
|
|
22
|
+
latest.current = { ...latest.current, zoom, offset };
|
|
23
|
+
const start = useRef({ distance: 0, zoom: 1, x: 0, y: 0 });
|
|
24
|
+
const pan = useRef(PanResponder.create({
|
|
25
|
+
onStartShouldSetPanResponder: e => e.nativeEvent.touches.length > 1,
|
|
26
|
+
onMoveShouldSetPanResponder: (e, g) => e.nativeEvent.touches.length > 1 || (latest.current.zoom > 1 && (Math.abs(g.dx) > 5 || Math.abs(g.dy) > 5)),
|
|
27
|
+
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 }; },
|
|
28
|
+
onPanResponderMove: (e, g) => { const t = e.nativeEvent.touches, s = latest.current; if (t.length > 1 && start.current.distance) {
|
|
29
|
+
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)));
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
const x = s.width * (s.zoom - 1) / 2, y = s.height * (s.zoom - 1) / 2;
|
|
33
|
+
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)) });
|
|
34
|
+
} },
|
|
35
|
+
onPanResponderRelease: () => { if (latest.current.zoom <= 1)
|
|
36
|
+
setOffset({ x: 0, y: 0 }); },
|
|
37
|
+
})).current;
|
|
38
|
+
useEffect(() => { setZoom(1); setOffset({ x: 0, y: 0 }); setError(false); }, [uri]);
|
|
39
|
+
const toggle = () => { setZoom(zoom === 1 ? 2 : 1); setOffset({ x: 0, y: 0 }); };
|
|
40
|
+
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: "Toggle photo controls", onPress: onTap, 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." })] });
|
|
41
|
+
}
|
|
42
|
+
export function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview, onDownload, onShare, onForward, onDelete, downloading = false }) {
|
|
43
|
+
const [controls, setControls] = useState(true);
|
|
44
|
+
useEffect(() => setControls(true), [uri]);
|
|
45
|
+
return _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1, minHeight: 0, backgroundColor: '#0c0e10' }, children: [controls && _jsxs(View, { testID: "media-action-toolbar", style: { position: 'absolute', top: 8, left: 12, right: 12, zIndex: 3, minHeight: 52, borderRadius: 26, borderWidth: 1, borderColor: '#ffffff1f', backgroundColor: '#121212eb', flexDirection: 'row', alignItems: 'center', paddingLeft: 8, paddingRight: 4, gap: 2 }, children: [[[onDownload, 'Download', 'download', downloading, false], [onDelete, 'Delete', 'trash', false, true], [onForward, 'Forward', 'forward', false, false], [onShare, 'Share', 'share', false, false]].filter(([action]) => !!action).map(([action, label, icon, busy, destructive]) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `${label} media`, accessibilityState: { busy }, disabled: busy, onPress: action, style: { flex: 1, minWidth: 44, minHeight: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', gap: 1, paddingHorizontal: 2 }, children: [busy ? _jsx(ActivityIndicator, { size: "small", color: "#e8843a" }) : _jsx(MessageIcon, { name: icon, size: 20, color: destructive ? '#f28b82' : '#fff' }), _jsx(Text, { numberOfLines: 1, style: { color: destructive ? '#f28b82' : '#fff', fontSize: 10, fontWeight: '800' }, children: label })] }, label)), _jsx(View, { style: { flex: !onDownload && !onDelete && !onForward && !onShare ? 1 : 0, minWidth: 0 }, children: !onDownload && !onDelete && !onForward && !onShare && _jsx(Text, { numberOfLines: 1, style: { color: '#fff', fontSize: 14, paddingLeft: 8 }, children: title }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close media", onPress: onClose, style: { width: 44, height: 44, borderRadius: 22, backgroundColor: '#ffffff14', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 24, color: "#fff" }) })] }), 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, onTap: () => setControls(value => !value) }) : 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 }) }) })] });
|
|
46
|
+
}
|