@robono/messaging-react-native 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +79 -4
- package/SBOM.spdx.json +832 -112
- package/dist/calling.js +2 -2
- package/dist/expo-download.d.ts +3 -0
- package/dist/expo-download.js +43 -0
- package/dist/expo-media.js +23 -0
- package/dist/expo-outbox.d.ts +13 -0
- package/dist/expo-outbox.js +81 -0
- package/dist/expo-saved.d.ts +24 -0
- package/dist/expo-saved.js +76 -0
- package/dist/expo-social.d.ts +3 -0
- package/dist/expo-social.js +21 -0
- package/dist/expo-thumbnails.d.ts +9 -0
- package/dist/expo-thumbnails.js +68 -0
- package/dist/expo.d.ts +9 -3
- package/dist/expo.js +143 -23
- package/dist/forward.d.ts +13 -0
- package/dist/forward.js +61 -0
- package/dist/icons.d.ts +1 -1
- package/dist/inbox.d.ts +16 -0
- package/dist/inbox.js +48 -0
- package/dist/index.d.ts +45 -2
- package/dist/index.js +376 -113
- package/dist/media-controller.d.ts +18 -0
- package/dist/media-controller.js +118 -3
- package/dist/media-readiness.d.ts +1 -1
- package/dist/media-ui.d.ts +16 -5
- package/dist/media-ui.js +15 -11
- package/dist/message-links.d.ts +5 -0
- package/dist/message-links.js +30 -0
- package/dist/message-order.d.ts +4 -0
- package/dist/message-order.js +17 -0
- package/dist/native-outbox.d.ts +49 -0
- package/dist/native-outbox.js +51 -0
- package/dist/pollVoteQueue.d.ts +3 -0
- package/dist/pollVoteQueue.js +29 -0
- package/dist/polls.d.ts +6 -2
- package/dist/polls.js +85 -18
- package/dist/popup.d.ts +9 -0
- package/dist/popup.js +7 -0
- package/dist/recent-media.js +1 -0
- package/dist/safety.d.ts +2 -1
- package/dist/safety.js +11 -5
- package/dist/saved.d.ts +10 -0
- package/dist/saved.js +46 -0
- package/dist/search.d.ts +9 -0
- package/dist/search.js +41 -0
- package/dist/social.d.ts +19 -0
- package/dist/social.js +35 -0
- package/dist/visual/RobonoIcon.js +2 -1
- package/dist/voice-composer.js +2 -2
- package/package.json +20 -4
|
@@ -20,6 +20,7 @@ export interface MessagingMediaStorage {
|
|
|
20
20
|
conversationId: string;
|
|
21
21
|
kind: MediaKind;
|
|
22
22
|
}): Promise<MessageMedia>;
|
|
23
|
+
thumbnail?(message: MessagingMessage, context: MediaTransferOptions): Promise<LocalMessagingMedia | null>;
|
|
23
24
|
download(message: MessagingMessage, context: MediaTransferOptions): Promise<LocalMessagingMedia>;
|
|
24
25
|
/** Optional native streaming for voice/video. Return null to use a verified download. */
|
|
25
26
|
stream?(message: MessagingMessage, context: MediaTransferOptions): Promise<LocalMessagingMedia | null>;
|
|
@@ -69,14 +70,27 @@ export declare class MessagingMediaController {
|
|
|
69
70
|
private stageRecording;
|
|
70
71
|
private openedMessage;
|
|
71
72
|
private prepared;
|
|
73
|
+
private preparedUploads;
|
|
74
|
+
private preparedAborts;
|
|
72
75
|
getPrepared: (assetId: string) => LocalMessagingMedia | undefined;
|
|
76
|
+
restorePrepared(assetId: string, file: LocalMessagingMedia, kind: MediaKind): void;
|
|
73
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>;
|
|
74
87
|
constructor(options: {
|
|
75
88
|
device: MessagingMediaDevice;
|
|
76
89
|
storage: MessagingMediaStorage;
|
|
77
90
|
createId: () => string;
|
|
78
91
|
getSettings: () => MessagingSettings;
|
|
79
92
|
getConversationId: () => string;
|
|
93
|
+
stageLocal?: boolean;
|
|
80
94
|
});
|
|
81
95
|
getSnapshot: () => MessagingMediaState;
|
|
82
96
|
subscribe: (f: () => void) => () => void;
|
|
@@ -86,7 +100,11 @@ export declare class MessagingMediaController {
|
|
|
86
100
|
private check;
|
|
87
101
|
finishRecording(interrupted?: boolean): Promise<void>;
|
|
88
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>;
|
|
89
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>;
|
|
90
108
|
/** Recover a failed/expired native stream once through fresh authorized download. */
|
|
91
109
|
fallbackStream(uri: string, positionMs: number, autoPlay: boolean): Promise<void>;
|
|
92
110
|
private openMedia;
|
package/dist/media-controller.js
CHANGED
|
@@ -18,9 +18,70 @@ export class MessagingMediaController {
|
|
|
18
18
|
stageRecording = false;
|
|
19
19
|
openedMessage = null;
|
|
20
20
|
prepared = new Map();
|
|
21
|
+
preparedUploads = new Map();
|
|
22
|
+
preparedAborts = new Map();
|
|
21
23
|
getPrepared = (assetId) => this.prepared.get(assetId);
|
|
22
|
-
|
|
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)
|
|
23
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
|
+
}
|
|
24
85
|
constructor(options) {
|
|
25
86
|
this.options = options;
|
|
26
87
|
}
|
|
@@ -86,7 +147,17 @@ export class MessagingMediaController {
|
|
|
86
147
|
this.update({ file, ...(source === 'camera' ? { kind: file.mimeType.startsWith('video/') ? 'video' : 'image' } : {}) });
|
|
87
148
|
this.check(file);
|
|
88
149
|
this.update({ phase: 'review' });
|
|
89
|
-
if (selectionIntent === '
|
|
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)
|
|
90
161
|
await this.send();
|
|
91
162
|
}
|
|
92
163
|
}
|
|
@@ -148,6 +219,17 @@ export class MessagingMediaController {
|
|
|
148
219
|
let retryUpload = false;
|
|
149
220
|
try {
|
|
150
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
|
+
}
|
|
151
233
|
this.update({ phase: 'uploading', progress: 0, error: null, retryUpload: false });
|
|
152
234
|
retryUpload = true;
|
|
153
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))
|
|
@@ -176,7 +258,39 @@ export class MessagingMediaController {
|
|
|
176
258
|
this.update({ phase: 'failed', error: this.error(e), retryUpload });
|
|
177
259
|
}
|
|
178
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
|
+
}
|
|
179
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
|
+
}
|
|
180
294
|
/** Recover a failed/expired native stream once through fresh authorized download. */
|
|
181
295
|
fallbackStream(uri, positionMs, autoPlay) {
|
|
182
296
|
if (!this.state.file?.stream || this.state.file.uri !== uri || !this.openedMessage)
|
|
@@ -252,6 +366,7 @@ export class MessagingMediaController {
|
|
|
252
366
|
await this.finishRecording(true);
|
|
253
367
|
else if (this.state.phase === 'preparing')
|
|
254
368
|
await this.cancel(); }
|
|
255
|
-
async dispose() { this.disposed = true;
|
|
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(); }
|
|
256
371
|
error(e) { return e instanceof Error ? e.message : 'The attachment could not be opened. Try again.'; }
|
|
257
372
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type MediaReadyStage = 'image-loaded' | 'video-frame' | 'audio-playing';
|
|
1
|
+
export type MediaReadyStage = 'image-loaded' | 'video-frame' | 'audio-playing' | 'video-playing' | 'file-opened';
|
|
2
2
|
export declare const MediaReadiness: import("react").Context<(stage: MediaReadyStage, uri: string) => void>;
|
|
3
3
|
/** Internal recovery channel; URLs are matched in memory, never logged. */
|
|
4
4
|
export declare const StreamRecovery: import("react").Context<(uri: string, positionMs: number, autoPlay: boolean) => void>;
|
package/dist/media-ui.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export interface VoiceBubbleProps {
|
|
|
5
5
|
ink: string;
|
|
6
6
|
accent: string;
|
|
7
7
|
waveform?: number[];
|
|
8
|
+
showWaveform?: boolean;
|
|
8
9
|
footer?: ReactNode;
|
|
9
10
|
onOpen: () => void;
|
|
10
11
|
onLongPress?: () => void;
|
|
@@ -21,20 +22,30 @@ export interface PlaybackProps {
|
|
|
21
22
|
onRate?: () => void;
|
|
22
23
|
}
|
|
23
24
|
/** 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
|
+
export declare function VoicePlayback({ ink, accent, waveform, showWaveform, footer, onLongPress, playing, loading, positionMs, durationMs, error, onToggle, onSeek }: PlaybackProps & {
|
|
25
26
|
ink: string;
|
|
26
27
|
accent: string;
|
|
27
28
|
waveform?: number[];
|
|
29
|
+
showWaveform?: boolean;
|
|
28
30
|
footer?: ReactNode;
|
|
29
31
|
onLongPress?: () => void;
|
|
30
32
|
}): React.JSX.Element;
|
|
31
|
-
export declare function MediaPlaybackControls({ playing, loading, positionMs, durationMs, error, onToggle, onSeek }: PlaybackProps
|
|
32
|
-
|
|
33
|
-
|
|
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 }: {
|
|
34
39
|
uri: string;
|
|
35
40
|
label?: string;
|
|
41
|
+
onTap?: () => void;
|
|
36
42
|
}): React.JSX.Element;
|
|
37
|
-
export declare function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview }: {
|
|
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;
|
|
38
49
|
title: string;
|
|
39
50
|
subtitle?: string;
|
|
40
51
|
kind: 'image' | 'video' | 'document' | 'voice';
|
package/dist/media-ui.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useRef, useContext, useState } from 'react';
|
|
2
|
+
import { useRef, useContext, useState, useEffect } from 'react';
|
|
3
3
|
import { ActivityIndicator, Image, PanResponder, Platform, Pressable, Text, View } from 'react-native';
|
|
4
4
|
import { MediaReadiness } from './media-readiness.js';
|
|
5
5
|
import { MessageIcon, duration, fileSize } from './icons.js';
|
|
6
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 }) {
|
|
7
|
+
export function VoicePlayback({ ink, accent, waveform, showWaveform = true, footer, onLongPress, playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek }) {
|
|
8
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 })] });
|
|
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
11
|
}
|
|
12
|
-
export function MediaPlaybackControls({ playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek }) {
|
|
12
|
+
export function MediaPlaybackControls({ playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek, onFullscreen, fullscreen = false }) {
|
|
13
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 =>
|
|
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 })] });
|
|
15
16
|
}
|
|
16
|
-
/** A full-size photo surface. The same tap-to-
|
|
17
|
-
export function ZoomablePhoto({ uri, label = 'Photo' }) {
|
|
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 }) {
|
|
18
19
|
const mediaReady = useContext(MediaReadiness);
|
|
19
20
|
const [zoom, setZoom] = useState(1), [offset, setOffset] = useState({ x: 0, y: 0 }), [error, setError] = useState(false);
|
|
20
21
|
const latest = useRef({ zoom, offset, width: 1, height: 1 });
|
|
@@ -34,9 +35,12 @@ export function ZoomablePhoto({ uri, label = 'Photo' }) {
|
|
|
34
35
|
onPanResponderRelease: () => { if (latest.current.zoom <= 1)
|
|
35
36
|
setOffset({ x: 0, y: 0 }); },
|
|
36
37
|
})).current;
|
|
38
|
+
useEffect(() => { setZoom(1); setOffset({ x: 0, y: 0 }); setError(false); }, [uri]);
|
|
37
39
|
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:
|
|
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." })] });
|
|
39
41
|
}
|
|
40
|
-
export function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview }) {
|
|
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 }) }) })] });
|
|
42
46
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Only explicit web links become actions. Never interpret arbitrary URI schemes. */
|
|
2
|
+
export function messageLinks(text) {
|
|
3
|
+
const parts = [];
|
|
4
|
+
let offset = 0;
|
|
5
|
+
for (const match of text.matchAll(/(?:https?:\/\/|www\.)[^\s<>]+/gi)) {
|
|
6
|
+
const start = match.index, raw = match[0];
|
|
7
|
+
let label = raw.replace(/[.,!?;:]+$/, '');
|
|
8
|
+
while (/[)\]}]$/.test(label)) {
|
|
9
|
+
const close = label.at(-1), open = { ')': '(', ']': '[', '}': '{' }[close];
|
|
10
|
+
if (label.split(close).length <= label.split(open).length)
|
|
11
|
+
break;
|
|
12
|
+
label = label.slice(0, -1);
|
|
13
|
+
}
|
|
14
|
+
if (!label)
|
|
15
|
+
continue;
|
|
16
|
+
try {
|
|
17
|
+
const url = new URL(/^www\./i.test(label) ? 'https://' + label : label);
|
|
18
|
+
if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
|
|
19
|
+
continue;
|
|
20
|
+
if (start > offset)
|
|
21
|
+
parts.push({ text: text.slice(offset, start) });
|
|
22
|
+
parts.push({ text: label, url: url.href });
|
|
23
|
+
offset = start + label.length;
|
|
24
|
+
}
|
|
25
|
+
catch { }
|
|
26
|
+
}
|
|
27
|
+
if (offset < text.length)
|
|
28
|
+
parts.push({ text: text.slice(offset) });
|
|
29
|
+
return parts.length ? parts : [{ text }];
|
|
30
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { MessagingMessage } from '@robono/messaging';
|
|
2
|
+
/** Upload completion order must not reorder the attachments selected together.
|
|
3
|
+
* Transport state remains in server sequence order for reconnect cursors. */
|
|
4
|
+
export declare function orderChatMessages<T extends MessagingMessage>(messages: readonly T[]): T[];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Upload completion order must not reorder the attachments selected together.
|
|
2
|
+
* Transport state remains in server sequence order for reconnect cursors. */
|
|
3
|
+
export function orderChatMessages(messages) {
|
|
4
|
+
const first = new Map();
|
|
5
|
+
const key = (m) => m.attachmentBatch ? JSON.stringify([m.senderId, m.attachmentBatch.id]) : null;
|
|
6
|
+
for (let i = 0; i < messages.length; i++) {
|
|
7
|
+
const k = key(messages[i]);
|
|
8
|
+
if (k && !first.has(k))
|
|
9
|
+
first.set(k, i);
|
|
10
|
+
}
|
|
11
|
+
return messages.map((message, index) => ({ message, index })).sort((a, b) => {
|
|
12
|
+
const ak = key(a.message), bk = key(b.message);
|
|
13
|
+
if (ak && ak === bk)
|
|
14
|
+
return a.message.attachmentBatch.index - b.message.attachmentBatch.index || a.index - b.index;
|
|
15
|
+
return (ak ? first.get(ak) : a.index) - (bk ? first.get(bk) : b.index);
|
|
16
|
+
}).map(item => item.message);
|
|
17
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { MessageContent, MessageMedia, MessagingOutbox, SavedOutgoingMessage } from '@robono/messaging';
|
|
2
|
+
import type { LocalMessagingMedia } from './media-controller.js';
|
|
3
|
+
export interface NativeDraftFiles {
|
|
4
|
+
read(scope: string, conversation: string): Promise<NativeDraftState>;
|
|
5
|
+
write(scope: string, conversation: string, state: NativeDraftState): Promise<void>;
|
|
6
|
+
copy(scope: string, conversation: string, id: string, source: LocalMessagingMedia): Promise<string>;
|
|
7
|
+
file(scope: string, conversation: string, id: string): Promise<string | null>;
|
|
8
|
+
remove(scope: string, conversation: string, id: string): Promise<void>;
|
|
9
|
+
clear(scope: string, conversation?: string): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
export interface NativeDraftState {
|
|
12
|
+
draft: string;
|
|
13
|
+
messages: Array<SavedOutgoingMessage & {
|
|
14
|
+
fileId?: string;
|
|
15
|
+
}>;
|
|
16
|
+
files: Record<string, {
|
|
17
|
+
kind: MessageContent['kind'];
|
|
18
|
+
media: MessageMedia;
|
|
19
|
+
}>;
|
|
20
|
+
}
|
|
21
|
+
export declare const emptyNativeDraft: () => NativeDraftState;
|
|
22
|
+
/** Durable outgoing content only, isolated by endpoint, namespace and signed-in user. */
|
|
23
|
+
export declare class NativeMessagingOutbox implements MessagingOutbox {
|
|
24
|
+
private files;
|
|
25
|
+
private generation;
|
|
26
|
+
private scope;
|
|
27
|
+
constructor(endpoint: string, userId: string, files: NativeDraftFiles, namespace?: string);
|
|
28
|
+
private serial;
|
|
29
|
+
load(cid: string): Promise<{
|
|
30
|
+
draft: string;
|
|
31
|
+
messages: {
|
|
32
|
+
clientMessageId: string;
|
|
33
|
+
createdAt: string;
|
|
34
|
+
content: MessageContent;
|
|
35
|
+
needsPreparation?: boolean;
|
|
36
|
+
}[];
|
|
37
|
+
attachments: {
|
|
38
|
+
kind: "text" | "voice" | "image" | "video" | "document" | "poll";
|
|
39
|
+
media: MessageMedia;
|
|
40
|
+
}[];
|
|
41
|
+
}>;
|
|
42
|
+
put(cid: string, message: SavedOutgoingMessage): Promise<void>;
|
|
43
|
+
remove(cid: string, id: string): Promise<void>;
|
|
44
|
+
draft(cid: string, text: string): Promise<void>;
|
|
45
|
+
clear(cid?: string): Promise<void>;
|
|
46
|
+
saveFile(cid: string, media: MessageMedia, file: LocalMessagingMedia, kind: MessageContent['kind']): Promise<void>;
|
|
47
|
+
file(cid: string, id: string): Promise<LocalMessagingMedia | undefined>;
|
|
48
|
+
releaseFile(cid: string, id: string): Promise<void>;
|
|
49
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const emptyNativeDraft = () => ({ draft: '', messages: [], files: {} });
|
|
2
|
+
const queues = new Map();
|
|
3
|
+
const epochs = new Map();
|
|
4
|
+
/** Durable outgoing content only, isolated by endpoint, namespace and signed-in user. */
|
|
5
|
+
export class NativeMessagingOutbox {
|
|
6
|
+
files;
|
|
7
|
+
generation = 0;
|
|
8
|
+
scope;
|
|
9
|
+
constructor(endpoint, userId, files, namespace = 'default') {
|
|
10
|
+
this.files = files;
|
|
11
|
+
this.scope = JSON.stringify([endpoint, namespace, userId]);
|
|
12
|
+
}
|
|
13
|
+
serial(work) { const task = (queues.get(this.scope) ?? Promise.resolve()).catch(() => { }).then(work); queues.set(this.scope, task); void task.finally(() => { if (queues.get(this.scope) === task)
|
|
14
|
+
queues.delete(this.scope); }).catch(() => { }); return task; }
|
|
15
|
+
load(cid) { return this.serial(async () => { const saved = await this.files.read(this.scope, cid), used = new Set(saved.messages.map(m => m.fileId)); return { draft: saved.draft, messages: saved.messages.map(({ fileId, ...message }) => message), attachments: Object.entries(saved.files).filter(([id]) => !used.has(id)).map(([, f]) => ({ kind: f.kind, media: f.media })) }; }); }
|
|
16
|
+
put(cid, message) { return this.serial(async () => { const state = await this.files.read(this.scope, cid), previous = state.messages.find(m => m.clientMessageId === message.clientMessageId); state.messages = state.messages.filter(m => m.clientMessageId !== message.clientMessageId); state.messages.push({ ...message, fileId: message.needsPreparation ? message.content.media?.assetId : previous?.fileId }); await this.files.write(this.scope, cid, state); }); }
|
|
17
|
+
remove(cid, id) { return this.serial(async () => { const state = await this.files.read(this.scope, cid), fileId = state.messages.find(m => m.clientMessageId === id)?.fileId; state.messages = state.messages.filter(m => m.clientMessageId !== id); if (fileId && !state.messages.some(m => m.fileId === fileId)) {
|
|
18
|
+
delete state.files[fileId];
|
|
19
|
+
await this.files.write(this.scope, cid, state);
|
|
20
|
+
await this.files.remove(this.scope, cid, fileId);
|
|
21
|
+
}
|
|
22
|
+
else
|
|
23
|
+
await this.files.write(this.scope, cid, state); }); }
|
|
24
|
+
draft(cid, text) { return this.serial(async () => { const state = await this.files.read(this.scope, cid); state.draft = text.slice(0, 10000); await this.files.write(this.scope, cid, state); }); }
|
|
25
|
+
clear(cid) { this.generation++; epochs.set(this.scope, (epochs.get(this.scope) ?? 0) + 1); return this.serial(() => this.files.clear(this.scope, cid)); }
|
|
26
|
+
saveFile(cid, media, file, kind) {
|
|
27
|
+
const generation = this.generation, epoch = epochs.get(this.scope) ?? 0;
|
|
28
|
+
const current = () => generation === this.generation && epoch === (epochs.get(this.scope) ?? 0);
|
|
29
|
+
return this.serial(async () => {
|
|
30
|
+
if (!current())
|
|
31
|
+
throw Error('The chat closed before the attachment was saved.');
|
|
32
|
+
await this.files.copy(this.scope, cid, media.assetId, file);
|
|
33
|
+
try {
|
|
34
|
+
if (!current())
|
|
35
|
+
throw Error('The chat closed before the attachment was saved.');
|
|
36
|
+
const state = await this.files.read(this.scope, cid);
|
|
37
|
+
state.files[media.assetId] = { kind, media };
|
|
38
|
+
await this.files.write(this.scope, cid, state);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
await this.files.remove(this.scope, cid, media.assetId).catch(() => { });
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
file(cid, id) { return this.serial(async () => { const saved = (await this.files.read(this.scope, cid)).files[id]; if (!saved)
|
|
47
|
+
return undefined; const uri = await this.files.file(this.scope, cid, id); if (!uri)
|
|
48
|
+
throw Error('This saved attachment is missing. Remove it and choose it again.'); return { ...saved.media, uri }; }); }
|
|
49
|
+
releaseFile(cid, id) { return this.serial(async () => { const state = await this.files.read(this.scope, cid); if (state.messages.some(m => m.fileId === id))
|
|
50
|
+
return; delete state.files[id]; await this.files.write(this.scope, cid, state); await this.files.remove(this.scope, cid, id); }); }
|
|
51
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
/** Show the latest selection immediately, but serialize complete selections so
|
|
2
|
+
* an older, slower request can never overwrite a newer vote on the server. */
|
|
3
|
+
export declare function createPollVoteQueue(save: (choices: number[]) => Promise<void>, pending: (choices: number[] | null) => void, failed: () => void): (choices: number[]) => Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Show the latest selection immediately, but serialize complete selections so
|
|
2
|
+
* an older, slower request can never overwrite a newer vote on the server. */
|
|
3
|
+
export function createPollVoteQueue(save, pending, failed) {
|
|
4
|
+
let running = false, next;
|
|
5
|
+
return async (choices) => {
|
|
6
|
+
next = [...choices];
|
|
7
|
+
pending(next);
|
|
8
|
+
if (running)
|
|
9
|
+
return;
|
|
10
|
+
running = true;
|
|
11
|
+
try {
|
|
12
|
+
while (next) {
|
|
13
|
+
const current = next;
|
|
14
|
+
next = undefined;
|
|
15
|
+
try {
|
|
16
|
+
await save(current);
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
if (!next)
|
|
20
|
+
failed();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
running = false;
|
|
26
|
+
pending(null);
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
package/dist/polls.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { type MessagingAppearance, type MessagingMessage, type MessagePoll } from '@robono/messaging';
|
|
3
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 }: {
|
|
4
|
+
export declare function PollMessage({ message, userId, ink, accent, enabled, onVote, onClose, resolveVoterName, background, foreground, renderDetails, detailsOpen, onDetailsOpenChange }: {
|
|
5
|
+
detailsOpen?: boolean;
|
|
6
|
+
onDetailsOpenChange?: (open: boolean) => void;
|
|
7
|
+
renderDetails?: (content: React.ReactNode, onClose: () => void) => React.ReactNode;
|
|
5
8
|
message: MessagingMessage;
|
|
6
9
|
userId: string;
|
|
7
10
|
ink: string;
|
|
@@ -13,7 +16,8 @@ export declare function PollMessage({ message, userId, ink, accent, enabled, onV
|
|
|
13
16
|
background?: string;
|
|
14
17
|
foreground?: string;
|
|
15
18
|
}): React.JSX.Element | null;
|
|
16
|
-
export declare function PollComposer({ appearance, onSend, onCancel, retrySafe }: {
|
|
19
|
+
export declare function PollComposer({ appearance, onSend, onCancel, retrySafe, yesNo }: {
|
|
20
|
+
yesNo?: boolean;
|
|
17
21
|
retrySafe?: boolean;
|
|
18
22
|
appearance: MessagingAppearance;
|
|
19
23
|
onSend: (poll: MessagePoll) => Promise<void>;
|