@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.
Files changed (53) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +79 -4
  3. package/SBOM.spdx.json +832 -112
  4. package/dist/calling.js +2 -2
  5. package/dist/expo-download.d.ts +3 -0
  6. package/dist/expo-download.js +43 -0
  7. package/dist/expo-media.js +23 -0
  8. package/dist/expo-outbox.d.ts +13 -0
  9. package/dist/expo-outbox.js +81 -0
  10. package/dist/expo-saved.d.ts +24 -0
  11. package/dist/expo-saved.js +76 -0
  12. package/dist/expo-social.d.ts +3 -0
  13. package/dist/expo-social.js +21 -0
  14. package/dist/expo-thumbnails.d.ts +9 -0
  15. package/dist/expo-thumbnails.js +68 -0
  16. package/dist/expo.d.ts +9 -3
  17. package/dist/expo.js +143 -23
  18. package/dist/forward.d.ts +13 -0
  19. package/dist/forward.js +61 -0
  20. package/dist/icons.d.ts +1 -1
  21. package/dist/inbox.d.ts +16 -0
  22. package/dist/inbox.js +48 -0
  23. package/dist/index.d.ts +45 -2
  24. package/dist/index.js +376 -113
  25. package/dist/media-controller.d.ts +18 -0
  26. package/dist/media-controller.js +118 -3
  27. package/dist/media-readiness.d.ts +1 -1
  28. package/dist/media-ui.d.ts +16 -5
  29. package/dist/media-ui.js +15 -11
  30. package/dist/message-links.d.ts +5 -0
  31. package/dist/message-links.js +30 -0
  32. package/dist/message-order.d.ts +4 -0
  33. package/dist/message-order.js +17 -0
  34. package/dist/native-outbox.d.ts +49 -0
  35. package/dist/native-outbox.js +51 -0
  36. package/dist/pollVoteQueue.d.ts +3 -0
  37. package/dist/pollVoteQueue.js +29 -0
  38. package/dist/polls.d.ts +6 -2
  39. package/dist/polls.js +85 -18
  40. package/dist/popup.d.ts +9 -0
  41. package/dist/popup.js +7 -0
  42. package/dist/recent-media.js +1 -0
  43. package/dist/safety.d.ts +2 -1
  44. package/dist/safety.js +11 -5
  45. package/dist/saved.d.ts +10 -0
  46. package/dist/saved.js +46 -0
  47. package/dist/search.d.ts +9 -0
  48. package/dist/search.js +41 -0
  49. package/dist/social.d.ts +19 -0
  50. package/dist/social.js +35 -0
  51. package/dist/visual/RobonoIcon.js +2 -1
  52. package/dist/voice-composer.js +2 -2
  53. 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;
@@ -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
- async releasePrepared(assetId) { const file = this.prepared.get(assetId); this.prepared.delete(assetId); if (file)
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 === 'send' && !source)
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; await this.cancel(); await Promise.all([...this.prepared.keys()].map(id => this.releasePrepared(id))); this.listeners.clear(); }
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>;
@@ -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): React.JSX.Element;
32
- /** A full-size photo surface. The same tap-to-zoom, pinch and pan handling runs in the preview and native app. */
33
- export declare function ZoomablePhoto({ uri, label }: {
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 => onSeek?.(Math.max(0, Math.min(1, e.nativeEvent.locationX / width.current)) * durationMs), style: { flex: 1, height: 44, justifyContent: 'center' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#ffffff44' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#fff', width: `${durationMs ? Math.min(100, positionMs / durationMs * 100) : 0}%` } }) }) }), _jsx(Text, { style: { color: '#bbb', fontSize: 12, fontVariant: ['tabular-nums'] }, children: duration(durationMs) })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', fontSize: 13 }, children: error })] });
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-zoom, pinch and pan handling runs in the preview and native app. */
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: zoom === 1 ? 'Zoom photo' : 'Fit photo', onPress: toggle, style: { flex: 1, overflow: 'hidden' }, children: _jsx(Image, { accessibilityLabel: label, source: { uri }, resizeMode: "contain", onLoad: () => mediaReady('image-loaded', uri), onError: () => setError(true), style: { width: '100%', height: '100%', transform: [{ translateX: offset.x }, { translateY: offset.y }, { scale: zoom }] } }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: zoom === 1 ? 'Zoom in' : 'Zoom out', onPress: toggle, style: { position: 'absolute', right: 16, bottom: 16, width: 44, height: 44, borderRadius: 22, backgroundColor: '#222a', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "zoom", color: "#fff" }) }), error && _jsx(Text, { accessibilityRole: "alert", style: { position: 'absolute', top: '50%', alignSelf: 'center', color: '#fff' }, children: "This photo could not be opened." })] });
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
- return _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1, minHeight: 0, backgroundColor: '#0c0e10' }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 12, flexShrink: 0, paddingHorizontal: 8, paddingVertical: 8, borderBottomWidth: 1, borderBottomColor: '#ffffff12' }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close media", onPress: onClose, hitSlop: 8, style: { width: 44, height: 44, flexShrink: 0, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "back", color: "#fff" }) }), _jsxs(View, { style: { flex: 1, paddingRight: 12, gap: 3 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: '#fff', fontSize: 16, fontWeight: '600' }, children: title }), !!subtitle && _jsx(Text, { numberOfLines: 1, style: { color: '#a4abb3', fontSize: 12 }, children: subtitle })] })] }), loading ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }, children: [_jsx(ActivityIndicator, { color: "#fff" }), _jsx(Text, { style: { color: '#c1c7ce' }, children: "Opening\u2026" })] }) : kind === 'image' && uri ? _jsx(ZoomablePhoto, { uri: uri, label: fileName }) : kind === 'document' ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 20 }, children: [_jsx(View, { style: { width: 88, height: 106, borderRadius: 16, backgroundColor: '#ffffff12', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "document", color: "#fff", size: 40 }) }), _jsx(Text, { style: { color: '#fff', fontSize: 19, fontWeight: '600', textAlign: 'center' }, children: fileName }), _jsxs(Text, { style: { color: '#9ba3ad', fontSize: 13 }, children: [fileName?.split('.').pop()?.toUpperCase(), " \u00B7 ", fileSize(byteSize)] }), onFileAction && _jsx(Pressable, { accessibilityRole: "button", onPress: onFileAction, style: { borderRadius: 24, paddingHorizontal: 28, paddingVertical: 14, backgroundColor: '#fff' }, children: _jsx(Text, { style: { color: '#15191e', fontWeight: '600' }, children: "Open file" }) })] }) : children, !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', padding: 16 }, children: error }), reviewAction && onReview && _jsx(View, { style: { padding: 16, borderTopWidth: 1, borderTopColor: '#ffffff15' }, children: _jsx(Pressable, { accessibilityRole: "button", onPress: onReview, style: { minHeight: 48, borderRadius: 24, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontWeight: '600', color: '#121619' }, children: reviewAction }) }) })] });
42
+ 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,5 @@
1
+ /** Only explicit web links become actions. Never interpret arbitrary URI schemes. */
2
+ export declare function messageLinks(text: string): Array<{
3
+ text: string;
4
+ url?: string;
5
+ }>;
@@ -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>;