@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
package/dist/expo.js CHANGED
@@ -1,4 +1,9 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { ExpoSocialPlayer } from './expo-social.js';
3
+ import { expoOutbox } from './expo-outbox.js';
4
+ import { expoSaved } from './expo-saved.js';
5
+ export { createExpoMessagingClient } from './expo-outbox.js';
6
+ import { createExpoThumbnails } from './expo-thumbnails.js';
2
7
  import { useEffect, useContext, useMemo, useRef, useState, useSyncExternalStore } from 'react';
3
8
  import { AppState, Image, Modal, Platform, Pressable, ScrollView, Text, View } from 'react-native';
4
9
  import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';
@@ -6,7 +11,9 @@ import MaterialIcons from "./visual/RobonoIcon";
6
11
  import { useAudioPlayer } from 'expo-audio';
7
12
  import { useVideoPlayer, VideoView } from 'expo-video';
8
13
  import * as Sharing from 'expo-sharing';
14
+ import { saveMessagingAttachment } from './expo-download.js';
9
15
  import * as Crypto from 'expo-crypto';
16
+ import * as Clipboard from 'expo-clipboard';
10
17
  import { defaultMessagingSettings } from '@robono/messaging';
11
18
  import { MessagingScreen, useMessagingUI } from './index.js';
12
19
  import { VoiceMessageComposer, VoiceDraftPlayback } from './voice-composer.js';
@@ -217,7 +224,7 @@ function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
217
224
  const duration = file.durationMs ?? player.duration * 1000;
218
225
  if (draft)
219
226
  return _jsx(VoiceDraftPlayback, { compact: draft.compact, appearance: design, playing: playing, positionMs: position, durationMs: duration, waveform: draft.waveform, onToggle: () => void toggle(), onRemove: draft.onRemove, disabled: draft.disabled, error: error });
220
- return _jsx(VoicePlayback, { ink: inline?.ink ?? c.foreground, accent: inline?.accent ?? c.accent, waveform: inline?.waveform, footer: inline?.footer, onLongPress: inline?.onLongPress, durationMs: duration, positionMs: position, playing: playing, loading: !!file.stream && !loaded && !error, error: error, onToggle: () => void toggle(), onSeek: ms => { lastPosition.current = Math.max(0, Math.min(duration, ms)); void player.seekTo(lastPosition.current / 1000).catch(() => { if (!recoverStream())
227
+ return _jsx(VoicePlayback, { ink: inline?.ink ?? c.foreground, accent: inline?.accent ?? c.accent, waveform: inline?.waveform, showWaveform: inline?.showWaveform, footer: inline?.footer, onLongPress: inline?.onLongPress, durationMs: duration, positionMs: position, playing: playing, loading: !!file.stream && !loaded && !error, error: error, onToggle: () => void toggle(), onSeek: ms => { lastPosition.current = Math.max(0, Math.min(duration, ms)); void player.seekTo(lastPosition.current / 1000).catch(() => { if (!recoverStream())
221
228
  setError('Could not seek. Try again.'); }); } });
222
229
  }
223
230
  function VideoPlayer({ file, audio }) {
@@ -225,8 +232,8 @@ function VideoPlayer({ file, audio }) {
225
232
  const source = useMemo(() => file.stream ? { uri: file.uri, headers: file.stream.headers, contentType: 'progressive', useCaching: false } : file.uri, [file]);
226
233
  const player = useVideoPlayer(source, p => { p.loop = false; p.audioMixingMode = 'mixWithOthers'; if (file.stream)
227
234
  p.bufferOptions = { preferredForwardBufferDuration: 5, minBufferForPlayback: 0.5 }; });
228
- const [ready, setReady] = useState(false), [error, setError] = useState(''), [position, setPosition] = useState(0), [playing, setPlaying] = useState(false);
229
- const desired = useRef(file.autoPlay ?? false), lastPosition = useRef(file.resumeAtMs ?? 0);
235
+ const [ready, setReady] = useState(false), [error, setError] = useState(''), [position, setPosition] = useState(0), [playing, setPlaying] = useState(false), [fullscreen, setFullscreen] = useState(false);
236
+ const view = useRef(null), desired = useRef(file.autoPlay ?? true), lastPosition = useRef(file.resumeAtMs ?? 0);
230
237
  useEffect(() => {
231
238
  let alive = true, recovered = false, audioReady = false, resumed = false, release;
232
239
  const recoverStream = () => {
@@ -249,8 +256,12 @@ function VideoPlayer({ file, audio }) {
249
256
  return;
250
257
  } release = r; audioReady = true; setReady(true); begin(); }).catch(e => { if (alive)
251
258
  setError(e.message); });
259
+ let consumed = false;
252
260
  const timer = setInterval(() => { if (!alive)
253
- return; if (player.currentTime > 0)
261
+ return; if (!consumed && player.playing && player.currentTime > 0) {
262
+ consumed = true;
263
+ mediaReady('video-playing', file.uri);
264
+ } if (player.currentTime > 0)
254
265
  lastPosition.current = player.currentTime * 1000; setPosition(player.currentTime * 1000); setPlaying(player.playing); if (audio.isAvailable?.() === false) {
255
266
  desired.current = false;
256
267
  player.pause();
@@ -278,7 +289,8 @@ function VideoPlayer({ file, audio }) {
278
289
  }
279
290
  catch { } void release?.().catch(() => { }); };
280
291
  }, [player, audio]);
281
- return _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(View, { style: { flex: 1, minHeight: 0 }, children: _jsx(VideoView, { onFirstFrameRender: () => mediaReady('video-frame', file.uri), player: player, nativeControls: false, contentFit: "contain", style: { height: '100%', width: '100%' } }) }), _jsx(MediaPlaybackControls, { playing: playing, loading: !ready && !error, positionMs: position, durationMs: file.durationMs ?? player.duration * 1000, error: error, onToggle: () => { if (!ready)
292
+ return _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(View, { style: { flex: 1, minHeight: 0 }, children: _jsx(VideoView, { ref: view, onFullscreenEnter: () => setFullscreen(true), onFullscreenExit: () => setFullscreen(false), fullscreenOptions: { enable: true, orientation: player.videoTrack?.size ? player.videoTrack.size.width > player.videoTrack.size.height ? "landscape" : "portrait" : "default" }, onFirstFrameRender: () => mediaReady('video-frame', file.uri), player: player, nativeControls: fullscreen, contentFit: "contain", style: { height: '100%', width: '100%' } }) }), _jsx(MediaPlaybackControls, { fullscreen: fullscreen, onFullscreen: () => { if (ready)
293
+ void (fullscreen ? view.current?.exitFullscreen() : view.current?.enterFullscreen())?.catch(() => setError("Full screen could not open. Try again.")); }, playing: playing, loading: !ready && !error, positionMs: position, durationMs: file.durationMs ?? player.duration * 1000, error: error, onToggle: () => { if (!ready)
282
294
  return; desired.current = !player.playing; if (player.playing)
283
295
  player.pause();
284
296
  else {
@@ -309,29 +321,40 @@ function FileViewer({ file, kind, audio, design }) {
309
321
  } })();
310
322
  } }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: design.colors.foreground }, children: error })] });
311
323
  }
312
- export function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice = false, inlineRecorder = false }) {
324
+ export function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice = false, inlineRecorder = false, viewerActions, onDismiss }) {
313
325
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
314
326
  const c = appearance.colors;
315
- const [fileError, setFileError] = useState('');
327
+ const mediaReady = useContext(MediaReadiness);
328
+ const [fileError, setFileError] = useState(''), [downloading, setDownloading] = useState(false);
329
+ const saving = useRef(false);
316
330
  const review = ['review', 'failed'].includes(state.phase) && !!state.file;
317
- if (inlineRecorder && state.kind === 'voice')
331
+ if (state.phase !== 'idle' && (typeof inlineRecorder === 'function' ? inlineRecorder() : inlineRecorder) && state.kind === 'voice')
318
332
  return null;
319
- if (inlineVoice && state.kind === 'voice' && ['opening', 'viewing', 'failed'].includes(state.phase) && !review)
333
+ if ((typeof inlineVoice === 'function' ? inlineVoice() : inlineVoice) && state.kind === 'voice' && ['opening', 'viewing', 'failed'].includes(state.phase) && !review)
320
334
  return null;
321
- const viewer = state.file && (review || state.phase === 'viewing') ? _jsx(SafeAreaView, { style: { flex: 1, backgroundColor: '#0c0e10' }, children: _jsx(MediaViewer, { kind: state.kind, uri: state.file.uri, title: review ? 'Preview attachment' : state.kind === 'image' ? 'Photo' : state.kind === 'video' ? 'Video' : state.kind === 'voice' ? 'Voice message' : 'File', subtitle: state.file.fileName, fileName: state.file.fileName, byteSize: state.file.byteSize, error: state.error ?? fileError, onClose: () => { setFileError(''); void controller.cancel(); }, reviewAction: review ? (state.phase === 'failed' ? 'Retry upload' : 'Add attachment') : undefined, onReview: review ? () => void controller.send() : undefined, onFileAction: () => { void (async () => { try {
335
+ const viewer = state.file && (review || state.phase === 'viewing') ? _jsx(SafeAreaView, { style: { flex: 1, backgroundColor: '#0c0e10' }, children: _jsx(MediaViewer, { kind: state.kind, uri: state.file.uri, title: review ? 'Preview attachment' : state.kind === 'image' ? 'Photo' : state.kind === 'video' ? 'Video' : state.kind === 'voice' ? 'Voice message' : 'File', subtitle: state.file.fileName, fileName: state.file.fileName, byteSize: state.file.byteSize, error: state.error ?? fileError, onClose: () => { setFileError(''); void controller.cancel(); }, reviewAction: review ? (state.phase === 'failed' ? 'Retry upload' : 'Add attachment') : undefined, onReview: review ? () => void controller.send() : undefined, ...(!review ? viewerActions?.() : {}), downloading: downloading, onDownload: !review && appearance.showDownloadButton === true && ['image', 'video', 'document'].includes(state.kind) ? () => { if (saving.current || !state.file)
336
+ return; saving.current = true; setDownloading(true); setFileError(''); void controller.exportFile(async (file) => { await saveMessagingAttachment(file, state.kind); mediaReady('file-opened', state.file.uri); }).catch(e => setFileError(e instanceof Error ? e.message : 'Could not save the attachment.')).finally(() => { saving.current = false; setDownloading(false); }); } : undefined, onShare: !review ? () => { void controller.exportFile(async (file) => { await Sharing.shareAsync(file.uri, { mimeType: file.mimeType, dialogTitle: file.fileName }); mediaReady('file-opened', state.file.uri); }).catch(() => setFileError('Could not share this attachment.')); } : undefined, onFileAction: () => { void (async () => { try {
322
337
  if (!await Sharing.isAvailableAsync())
323
338
  throw Error('File opening is unavailable.');
324
339
  await Sharing.shareAsync(state.file.uri, { mimeType: state.file.mimeType, dialogTitle: state.file.fileName });
340
+ mediaReady('file-opened', state.file.uri);
325
341
  }
326
342
  catch (e) {
327
343
  setFileError(e instanceof Error ? e.message : 'Could not open the file.');
328
344
  } })(); }, children: state.kind === 'video' ? _jsx(VideoPlayer, { file: state.file, audio: audio, design: appearance }, state.file.uri) : state.kind === 'voice' ? _jsx(View, { style: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 20 }, children: _jsx(VoicePlayer, { file: state.file, audio: audio, design: { ...appearance, colors: { ...appearance.colors, foreground: '#ffffff', accent: '#8ab4f8' } } }) }) : null }) }) : null;
329
345
  // A native modal has its own window; measure its safe area inside that window.
330
- return _jsx(Modal, { visible: state.phase !== 'idle', animationType: "none", onRequestClose: () => void controller.cancel(), children: _jsx(SafeAreaProvider, { style: { flex: 1 }, children: viewer ?? (_jsx(SafeAreaView, { style: { flex: 1, backgroundColor: c.background }, children: _jsxs(ScrollView, { contentContainerStyle: { padding: 24, gap: 20, flexGrow: 1 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsx(Text, { accessibilityRole: "header", style: { color: c.foreground, fontSize: 24, fontWeight: '600' }, children: state.kind === 'voice' ? 'Voice message' : state.kind === 'image' ? 'Photo' : state.kind === 'video' ? 'Video' : 'File' }), _jsx(Control, { label: state.phase === 'viewing' ? 'Close' : 'Cancel', icon: "close", color: c.foreground, onPress: () => void controller.cancel() })] }), state.phase === 'recording' ? _jsxs(View, { style: { alignItems: 'center', gap: 20, paddingVertical: 32 }, children: [_jsx(MaterialIcons, { name: "mic", size: 48, color: c.accent }), _jsx(Text, { style: { color: c.foreground, fontSize: 32, fontVariant: ['tabular-nums'] }, children: time(state.elapsedMs) }), _jsxs(Text, { style: { color: c.secondary }, children: [time(Math.max(0, state.maxDurationMs - state.elapsedMs)), " remaining"] }), _jsx(Control, { label: "Stop recording", icon: "stop", color: c.accent, onPress: () => void controller.finishRecording() })] }) : null, ['choosing', 'preparing', 'uploading', 'opening'].includes(state.phase) && _jsx(Text, { accessibilityRole: "progressbar", accessibilityValue: { min: 0, max: 100, now: Math.round(state.progress * 100) }, style: { color: c.foreground, fontSize: 18 }, children: state.phase === 'uploading' ? `Uploading… ${Math.round(state.progress * 100)}%` : state.phase === 'opening' ? (state.progress > 0 ? `Opening… ${Math.round(state.progress * 100)}%` : 'Opening…') : state.phase === 'choosing' ? 'Choose an attachment.' : 'Preparing recording…' }), state.file && _jsxs(_Fragment, { children: [_jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: state.file.fileName }), _jsx(Text, { style: { color: c.secondary }, children: size(state.file.byteSize) }), (review || state.phase === 'viewing') && _jsx(FileViewer, { file: state.file, kind: state.kind, audio: audio, design: appearance }, state.file.uri)] }), state.error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: state.error }), review && (state.phase === 'review' || state.retryUpload) && _jsx(Control, { label: state.phase === 'failed' ? 'Retry upload' : 'Add attachment', icon: state.phase === 'failed' ? 'refresh' : 'send', color: c.accent, onPress: () => void controller.send() })] }) })) }) });
346
+ return _jsx(Modal, { transparent: true, visible: state.phase !== 'idle' && state.phase !== 'choosing', onDismiss: onDismiss, animationType: "fade", onRequestClose: () => void controller.cancel(), children: _jsx(SafeAreaProvider, { style: { flex: 1 }, children: viewer ?? (_jsx(SafeAreaView, { style: { flex: 1, backgroundColor: c.background }, children: _jsxs(ScrollView, { contentContainerStyle: { padding: 24, gap: 20, flexGrow: 1 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsx(Text, { accessibilityRole: "header", style: { color: c.foreground, fontSize: 24, fontWeight: '600' }, children: state.kind === 'voice' ? 'Voice message' : state.kind === 'image' ? 'Photo' : state.kind === 'video' ? 'Video' : 'File' }), _jsx(Control, { label: state.phase === 'viewing' ? 'Close' : 'Cancel', icon: "close", color: c.foreground, onPress: () => void controller.cancel() })] }), state.phase === 'recording' ? _jsxs(View, { style: { alignItems: 'center', gap: 20, paddingVertical: 32 }, children: [_jsx(MaterialIcons, { name: "mic", size: 48, color: c.accent }), _jsx(Text, { style: { color: c.foreground, fontSize: 32, fontVariant: ['tabular-nums'] }, children: time(state.elapsedMs) }), _jsxs(Text, { style: { color: c.secondary }, children: [time(Math.max(0, state.maxDurationMs - state.elapsedMs)), " remaining"] }), _jsx(Control, { label: "Stop recording", icon: "stop", color: c.accent, onPress: () => void controller.finishRecording() })] }) : null, ['choosing', 'preparing', 'uploading', 'opening'].includes(state.phase) && _jsx(Text, { accessibilityRole: "progressbar", accessibilityValue: { min: 0, max: 100, now: Math.round(state.progress * 100) }, style: { color: c.foreground, fontSize: 18 }, children: state.phase === 'uploading' ? `Uploading… ${Math.round(state.progress * 100)}%` : state.phase === 'opening' ? (state.progress > 0 ? `Opening… ${Math.round(state.progress * 100)}%` : 'Opening…') : state.phase === 'choosing' ? 'Choose an attachment.' : 'Preparing recording…' }), state.file && _jsxs(_Fragment, { children: [_jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: state.file.fileName }), _jsx(Text, { style: { color: c.secondary }, children: size(state.file.byteSize) }), (review || state.phase === 'viewing') && _jsx(FileViewer, { file: state.file, kind: state.kind, audio: audio, design: appearance }, state.file.uri)] }), state.error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: state.error }), review && (state.phase === 'review' || state.retryUpload) && _jsx(Control, { label: state.phase === 'failed' ? 'Retry upload' : 'Add attachment', icon: state.phase === 'failed' ? 'refresh' : 'send', color: c.accent, onPress: () => void controller.send() })] }) })) }) });
331
347
  }
332
348
  /** Optional ready-made recording, preview, upload and download tools for both mobile platforms. */
333
349
  export function ExpoMessagingScreen({ storage, audioSession, renderConversation, preloadRecentMedia = true, ...props }) {
334
350
  useEffect(() => { void props.client.reportUsage({ platform: Platform.OS === 'ios' ? 'ios' : Platform.OS === 'android' ? 'android' : 'other', components: ['media-tools'] }).catch(() => { }); }, [props.client]);
351
+ const [requestedMessageAction, setRequestedMessageAction] = useState();
352
+ const afterViewer = useRef(undefined);
353
+ const dismissFallback = useRef(undefined);
354
+ const viewerDismissed = () => { clearTimeout(dismissFallback.current); const action = afterViewer.current; if (!action)
355
+ return; requestAnimationFrame(() => { if (afterViewer.current !== action)
356
+ return; afterViewer.current = undefined; setRequestedMessageAction(action); }); };
357
+ useEffect(() => { afterViewer.current = undefined; return () => { afterViewer.current = undefined; clearTimeout(dismissFallback.current); }; }, [props.client, props.active]);
335
358
  const current = useRef(props);
336
359
  current.current = props;
337
360
  const message = useRef(null);
@@ -350,11 +373,33 @@ export function ExpoMessagingScreen({ storage, audioSession, renderConversation,
350
373
  return current.current.resolveAttachmentChoice(id, signal);
351
374
  }
352
375
  return device.pick(kind, settings, signal, source);
353
- } }, storage: recent.storage, createId: Crypto.randomUUID,
376
+ } }, storage: recent.storage, createId: Crypto.randomUUID, stageLocal: true,
354
377
  getSettings: () => current.current.client.getSnapshot().settings ?? defaultMessagingSettings(), getConversationId: () => current.current.client.getSnapshot().conversationId });
355
378
  }, [recent, audioSession, props.client]);
356
- const recorder = useMemo(() => { const device = createExpoMessagingDevice(audioSession); return new MessagingMediaController({ device: { ...device, startRecording: async (maximum, signal) => { await stopVoicePlayers(); return device.startRecording(maximum, signal); } }, storage, createId: Crypto.randomUUID, getSettings: () => current.current.client.getSnapshot().settings ?? defaultMessagingSettings(), getConversationId: () => current.current.client.getSnapshot().conversationId }); }, [storage, audioSession, props.client]);
379
+ const recorder = useMemo(() => { const device = createExpoMessagingDevice(audioSession); return new MessagingMediaController({ device: { ...device, startRecording: async (maximum, signal) => { await stopVoicePlayers(); return device.startRecording(maximum, signal); } }, storage, stageLocal: true, createId: Crypto.randomUUID, getSettings: () => current.current.client.getSnapshot().settings ?? defaultMessagingSettings(), getConversationId: () => current.current.client.getSnapshot().conversationId }); }, [storage, audioSession, props.client]);
357
380
  const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
381
+ const outbox = expoOutbox(props.client);
382
+ const restore = async (media, kind) => {
383
+ const present = controller.getPrepared(media.assetId) ?? recorder.getPrepared(media.assetId);
384
+ if (present)
385
+ return present;
386
+ const conversationId = props.client.getSnapshot().conversationId, saved = await outbox?.file(conversationId, media.assetId);
387
+ if (!saved)
388
+ return undefined;
389
+ const copy = await copyMessagingCache(saved);
390
+ if (props.client.getSnapshot().conversationId !== conversationId) {
391
+ await createExpoMessagingDevice(audioSession).remove(copy);
392
+ throw Error('The conversation changed.');
393
+ }
394
+ controller.restorePrepared(media.assetId, copy, kind);
395
+ return copy;
396
+ };
397
+ const prepare = async (kind, media) => { await restore(media, kind); const owner = recorder.getPrepared(media.assetId) ? recorder : controller; if (!owner.getPrepared(media.assetId))
398
+ return media; const uploaded = await owner.uploadPrepared(media.assetId, kind); thumbnails.alias(media.assetId, uploaded.assetId); return uploaded; };
399
+ useEffect(() => props.client.setAttachmentPreparer(async (content) => { if (!content.media || content.kind === 'text' || content.kind === 'poll' || !await restore(content.media, content.kind))
400
+ throw Error('The saved attachment is unavailable. Remove it and choose it again.'); return { ...content, media: await prepare(content.kind, content.media) }; }), [props.client, controller, recorder, outbox]);
401
+ const thumbnails = useMemo(() => createExpoThumbnails(recent.storage, id => controller.getPrepared(id) ?? recorder.getPrepared(id), createExpoMessagingDevice(audioSession).remove), [recent, controller, recorder, audioSession]);
402
+ useEffect(() => () => thumbnails.clear(), [thumbnails, state.conversationId, state.settings?.enabled, state.conversation?.canSend, props.active]);
358
403
  const cacheDisposal = useRef(new Map());
359
404
  useEffect(() => {
360
405
  clearTimeout(cacheDisposal.current.get(recent));
@@ -371,6 +416,10 @@ export function ExpoMessagingScreen({ storage, audioSession, renderConversation,
371
416
  recent.clear(); });
372
417
  return () => { unsubscribe(); app.remove(); cacheDisposal.current.set(recent, setTimeout(() => { recent.clear(); cacheDisposal.current.delete(recent); }, 0)); };
373
418
  }, [recent, props.client, props.active, preloadRecentMedia]);
419
+ const TranslationVoice = useMemo(() => function TranslationVoice({ transform, ink, accent, showWaveform }) {
420
+ const { design } = useMessagingUI(), audio = transform.audio;
421
+ return _jsx(VoicePlayer, { file: { uri: audio.url, mimeType: audio.mimeType, fileName: 'Translated voice', byteSize: audio.byteSize, durationMs: audio.durationMs, stream: {} }, audio: audioSession, design: design, inline: { message: { id: transform.messageId, clientMessageId: 'translation-' + transform.messageId, conversationId: 'translation', senderId: '', kind: 'voice', createdAt: '', readAt: null }, ink, accent, showWaveform, waveform: audio.waveform, onOpen: () => { } } }, audio.url);
422
+ }, [audioSession]);
374
423
  const InlineVoice = useMemo(() => function InlineVoice(bubble) {
375
424
  const media = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
376
425
  const { design } = useMessagingUI();
@@ -386,12 +435,14 @@ export function ExpoMessagingScreen({ storage, audioSession, renderConversation,
386
435
  const remove = () => { void recorder.cancel(); };
387
436
  return _jsx(VoiceMessageComposer, { ...ui, phase: phase, elapsedMs: media.elapsedMs, waveform: media.waveform, error: capturing ? media.error : null, onStop: () => void recorder.finishRecording(), onSend: () => void recorder.send(), onRemove: remove, review: capturing && media.file ? _jsx(VoicePlayer, { file: media.file, audio: audioSession, design: ui.appearance, draft: { onRemove: remove, waveform: media.waveform, disabled: media.phase === 'uploading', compact: !!(ui.renderTextComposer || ui.renderAttachments) } }) : undefined });
388
437
  }, [recorder, audioSession]);
389
- const VoiceAttachment = useMemo(() => function VoiceAttachment(ui) { const file = recorder.getPrepared(ui.media.assetId); return file ? _jsx(VoicePlayer, { file: file, audio: audioSession, design: ui.appearance, draft: { onRemove: ui.onRemove, disabled: ui.disabled, compact: true } }) : null; }, [recorder, audioSession]);
438
+ const VoiceAttachment = useMemo(() => function VoiceAttachment(ui) { const [file, setFile] = useState(recorder.getPrepared(ui.media.assetId) ?? controller.getPrepared(ui.media.assetId)); useEffect(() => { let live = true; void restore(ui.media, 'voice').then(file => { if (live)
439
+ setFile(file); }).catch(() => { }); return () => { live = false; }; }, [ui.media.assetId]); return file ? _jsx(VoicePlayer, { file: file, audio: audioSession, design: ui.appearance, draft: { onRemove: ui.onRemove, disabled: ui.disabled, compact: true } }) : null; }, [recorder, audioSession]);
390
440
  const conversation = useRef(state.conversationId);
391
441
  const disposal = useRef(new Map());
392
442
  useEffect(() => {
393
- if (conversation.current !== state.conversationId || props.active === false || !state.settings?.enabled || message.current && !state.messages.some(m => m.id === message.current.id && !m.deleted)) {
443
+ if (conversation.current !== state.conversationId || props.active === false || !state.settings?.enabled || message.current && !state.messages.some(m => m.id === message.current.id && !m.deleted) && !controller.getPrepared(message.current.media?.assetId ?? '') && !recorder.getPrepared(message.current.media?.assetId ?? '')) {
394
444
  ++openingIntent.current;
445
+ afterViewer.current = undefined;
395
446
  message.current = null;
396
447
  void controller.cancel();
397
448
  void recorder.cancel();
@@ -412,18 +463,87 @@ export function ExpoMessagingScreen({ storage, audioSession, renderConversation,
412
463
  return () => { ++openingIntent.current; sub.remove(); for (const item of controllers)
413
464
  disposal.current.set(item, setTimeout(() => { disposal.current.delete(item); void item.dispose(); }, 0)); };
414
465
  }, [controller, recorder]);
415
- const screenProps = { ...props,
416
- components: { VoiceMessage: InlineVoice, VoiceComposer: InlineComposer, VoiceAttachment, ...props.components },
417
- releaseAttachment: media => { void recorder.releasePrepared(media.assetId); void props.releaseAttachment?.(media); },
418
- pickAttachment: (kind, options) => { if (kind === 'voice')
419
- return recorder.pick('voice', undefined, 'attach'); message.current = null; chosen.current = options.attachmentId; return controller.pick(kind, options.source, options.intent); },
466
+ useEffect(() => props.client.setForwardPreparer((message, target) => controller.prepareForward(message, target)), [props.client, controller]);
467
+ const saved = expoSaved(props.client);
468
+ const SocialPlayer = useMemo(() => function SocialPlayer(ui) {
469
+ const [ready, setReady] = useState(false);
470
+ useEffect(() => {
471
+ let alive = true, release;
472
+ void stopVoicePlayers().then(() => audioSession.acquire('playback')).then(async (done) => { if (!alive) {
473
+ await done();
474
+ return;
475
+ } release = done; setReady(true); }).catch(() => { if (alive)
476
+ ui.onError(); });
477
+ return () => { alive = false; void release?.().catch(() => { }); };
478
+ }, []);
479
+ return ready ? _jsx(ExpoSocialPlayer, { ...ui }) : null;
480
+ }, [audioSession]);
481
+ useEffect(() => saved?.bindDownload(async (m) => { const device = createExpoMessagingDevice(audioSession), file = await storage.download(m, { signal: AbortSignal.timeout(120000), onProgress: () => { } }); return { file, release: () => device.remove(file) }; }), [saved, storage, audioSession]);
482
+ const screenProps = { ...props, requestedMessageAction,
483
+ copyText: props.copyText ?? (async (text) => { await Clipboard.setStringAsync(text); }),
484
+ components: { SocialPlayer, TranslationVoice, VoiceMessage: InlineVoice, VoiceComposer: InlineComposer, VoiceAttachment, ...props.components },
485
+ attachmentPreview: props.attachmentPreview ?? (async (m) => { if (m.media && m.kind !== 'text' && m.kind !== 'poll')
486
+ await restore(m.media, m.kind); return thumbnails.preview(m); }),
487
+ prepareAttachment: prepare,
488
+ retainDraftAttachments: !!outbox,
489
+ releaseAttachment: async (media) => { await outbox?.releaseFile(state.conversationId, media.assetId); await controller.releasePrepared(media.assetId); await recorder.releasePrepared(media.assetId); await props.releaseAttachment?.(media); },
490
+ pickAttachment: async (kind, options) => {
491
+ const conversationId = props.client.getSnapshot().conversationId;
492
+ let media;
493
+ if (kind === 'voice')
494
+ media = await recorder.pick('voice', undefined, 'attach');
495
+ else {
496
+ message.current = null;
497
+ chosen.current = options.attachmentId;
498
+ media = await controller.pick(kind, options.source, options.source === 'call-video' ? 'preview' : options.intent === 'preview' ? 'preview' : 'attach');
499
+ }
500
+ if (media && outbox) {
501
+ const file = recorder.getPrepared(media.assetId) ?? controller.getPrepared(media.assetId);
502
+ if (!file)
503
+ throw Error('The selected file is unavailable.');
504
+ try {
505
+ await outbox.saveFile(conversationId, media, file, kind);
506
+ }
507
+ catch (error) {
508
+ await recorder.releasePrepared(media.assetId);
509
+ await controller.releasePrepared(media.assetId);
510
+ throw error;
511
+ }
512
+ }
513
+ return media;
514
+ },
420
515
  openAttachment: async (m) => { const intent = ++openingIntent.current; await stopVoicePlayers(); if (intent !== openingIntent.current)
421
- return; opened.current = { messageId: m.id, kind: m.kind, started: Date.now(), reported: false }; message.current = m; await controller.open(m); },
516
+ return; opened.current = { messageId: m.id, kind: m.kind, started: Date.now(), reported: false }; message.current = m; const draft = m.media && m.kind !== 'text' && m.kind !== 'poll' ? await restore(m.media, m.kind) : undefined; if (draft && m.kind !== 'text' && m.kind !== 'poll')
517
+ await controller.previewLocal(await copyMessagingCache(draft), m.kind);
518
+ else
519
+ await controller.open(m); },
520
+ openSavedAttachment: async (record) => { if (!saved || record.message.kind === 'text' || record.message.kind === 'poll')
521
+ throw Error('This saved attachment is unavailable.'); const intent = ++openingIntent.current; await stopVoicePlayers(); const file = await copyMessagingCache(await saved.file(record)); if (intent !== openingIntent.current) {
522
+ await createExpoMessagingDevice(audioSession).remove(file);
523
+ return;
524
+ } opened.current = null; message.current = null; await controller.previewLocal(file, record.message.kind); },
422
525
  };
423
- const mediaReady = (stage, uri) => { const event = opened.current; if (!event || event.reported || controller.getSnapshot().file?.uri !== uri)
526
+ const mediaReady = (stage, uri) => { const event = opened.current; if (!event || controller.getSnapshot().phase !== 'viewing' || controller.getSnapshot().file?.uri !== uri)
527
+ return; if (stage !== 'video-frame' && message.current?.conversationId === current.current.client.getSnapshot().conversationId)
528
+ void current.current.client.consumeMessage(event.messageId).catch(() => { }); if (event.reported)
424
529
  return; event.reported = true; try {
425
530
  current.current.onMediaReady?.({ messageId: event.messageId, kind: event.kind, stage, elapsedMs: Date.now() - event.started });
426
531
  }
427
532
  catch { } };
428
- return _jsx(StreamRecovery.Provider, { value: (uri, position, playing) => { void controller.fallbackStream(uri, position, playing); }, children: _jsxs(MediaReadiness.Provider, { value: mediaReady, children: [renderConversation ? renderConversation(screenProps) : _jsx(MessagingScreen, { ...screenProps }), _jsx(MessagingMediaOverlay, { inlineVoice: true, inlineRecorder: !!state.settings?.features.voice, controller: controller, audio: audioSession, appearance: props.appearance ?? state.settings?.appearance ?? defaultMessagingSettings().appearance })] }) });
533
+ return _jsx(StreamRecovery.Provider, { value: (uri, position, playing) => { void controller.fallbackStream(uri, position, playing); }, children: _jsxs(MediaReadiness.Provider, { value: mediaReady, children: [renderConversation ? renderConversation(screenProps) : _jsx(MessagingScreen, { ...screenProps }), _jsx(MessagingMediaOverlay, { onDismiss: viewerDismissed, viewerActions: () => {
534
+ const m = message.current;
535
+ if (!m || !state.messages.some(row => row.id === m.id && !row.deleted))
536
+ return {};
537
+ const action = (kind) => {
538
+ afterViewer.current = { kind, messageId: m.id, sequence: Date.now() };
539
+ void controller.cancel().then(() => {
540
+ // Some installed native runtimes omit onDismiss after a media modal closes.
541
+ // Keep the normal event fast path; recover only after the native fade ends.
542
+ // Navigation/unmount clears the queued action, so it cannot open in another chat.
543
+ if (afterViewer.current)
544
+ dismissFallback.current = setTimeout(viewerDismissed, Platform.OS === 'ios' ? 500 : 250);
545
+ });
546
+ };
547
+ return { onForward: props.client.canForward(m) ? () => action('forward') : undefined, onDelete: m.senderId === props.userId && !m.readAt && state.messageActionsAvailable ? () => action('delete') : undefined };
548
+ }, inlineVoice: () => !!message.current, inlineRecorder: () => !!message.current && !!current.current.client.getSnapshot().settings?.features.voice, controller: controller, audio: audioSession, appearance: props.appearance ?? state.settings?.appearance ?? defaultMessagingSettings().appearance })] }) });
429
549
  }
@@ -0,0 +1,13 @@
1
+ import React from 'react';
2
+ import type { MessagingAppearance, MessagingClient, MessagingMessage } from '@robono/messaging';
3
+ /** Messenger's compact destination picker, retaining per-message retry identities. */
4
+ export declare function MessagingForward({ messages, client, appearance, participants, userId, onClose }: {
5
+ messages: MessagingMessage[];
6
+ client: MessagingClient;
7
+ appearance: MessagingAppearance;
8
+ participants?: Record<string, {
9
+ name: string;
10
+ }>;
11
+ userId: string;
12
+ onClose: () => void;
13
+ }): React.JSX.Element;
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
4
+ import { MessagingPopup } from './popup.js';
5
+ /** Messenger's compact destination picker, retaining per-message retry identities. */
6
+ export function MessagingForward({ messages, client, appearance, participants = {}, userId, onClose }) {
7
+ const [page, setPage] = useState(null), [loading, setLoading] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState('');
8
+ const live = useRef(true), inFlight = useRef(false), attempts = useRef(new Map()), done = useRef(new Set());
9
+ const c = appearance.colors;
10
+ useEffect(() => { live.current = true; void load(false); return () => { live.current = false; }; }, []);
11
+ async function load(more) { if (inFlight.current)
12
+ return; inFlight.current = true; setLoading(true); setError(''); try {
13
+ const next = await client.forwardTargets(more ? page?.nextCursor ?? undefined : undefined);
14
+ if (live.current)
15
+ setPage(previous => ({ ...next, conversations: more ? [...new Map([...(previous?.conversations ?? []), ...next.conversations].map(row => [row.id, row])).values()] : next.conversations }));
16
+ }
17
+ catch (e) {
18
+ if (live.current)
19
+ setError(e instanceof Error ? e.message : 'Chats could not be loaded.');
20
+ }
21
+ finally {
22
+ inFlight.current = false;
23
+ if (live.current)
24
+ setLoading(false);
25
+ } }
26
+ async function send(row) {
27
+ if (inFlight.current)
28
+ return;
29
+ inFlight.current = true;
30
+ setBusy(true);
31
+ setError('');
32
+ try {
33
+ for (const message of messages) {
34
+ const key = row.id + ':' + message.id;
35
+ if (done.current.has(key))
36
+ continue;
37
+ let id = attempts.current.get(key);
38
+ if (!id) {
39
+ id = client.createForwardId();
40
+ attempts.current.set(key, id);
41
+ }
42
+ await client.forwardMessage(message.id, row.id, id);
43
+ done.current.add(key);
44
+ if (!live.current)
45
+ return;
46
+ }
47
+ onClose();
48
+ }
49
+ catch (e) {
50
+ if (live.current)
51
+ setError(e instanceof Error ? e.message : 'The message could not be forwarded. Try again.');
52
+ }
53
+ finally {
54
+ inFlight.current = false;
55
+ if (live.current)
56
+ setBusy(false);
57
+ }
58
+ }
59
+ return _jsxs(MessagingPopup, { background: c.background, onClose: () => { if (!busy)
60
+ onClose(); }, children: [_jsx(Text, { accessibilityRole: "header", style: { color: c.foreground, fontSize: 17, fontWeight: '600' }, children: "Forward to" }), _jsx(ScrollView, { style: { maxHeight: 360, marginTop: 8 }, children: page?.conversations.filter(row => row.canSend).map(row => { const name = row.kind === 'group' ? row.name ?? 'Group' : row.contactId === userId ? 'You' : participants[row.contactId ?? '']?.name ?? row.contactId ?? 'Chat'; return _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Forward to ' + name, disabled: busy || loading, onPress: () => void send(row), style: { minHeight: 48, justifyContent: 'center', paddingVertical: 10 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: name }) }, row.id); }) }), loading || busy ? _jsx(ActivityIndicator, { color: c.accent, style: { margin: 12 } }) : page && !page.conversations.some(row => row.canSend) && _jsx(Text, { style: { color: c.secondary, marginVertical: 12 }, children: "No chats available." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', justifyContent: 'flex-end', gap: 20, marginTop: 8 }, children: [!page && error && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void load(false), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Try again" }) }), page?.hasMore && _jsx(Pressable, { accessibilityRole: "button", disabled: loading || busy, onPress: () => void load(true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "More chats" }) }), _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: onClose, style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground }, children: "Cancel" }) })] })] });
61
+ }
package/dist/icons.d.ts CHANGED
@@ -4,4 +4,4 @@ export declare function MessageIcon(props: RobonoIconProps): React.JSX.Element;
4
4
  export declare function duration(ms?: number): string;
5
5
  export declare function fileSize(bytes?: number): string;
6
6
  /** Neutral app chrome stays legible for light and dark customer palettes. */
7
- export declare function chatSurface(background: string): "#ffffff" | "#1e262b";
7
+ export declare function chatSurface(background: string): "#1e262b" | "#ffffff";
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+ import { type MessagingAppearance, type MessagingInboxClient, type ConversationSummary } from '@robono/messaging';
3
+ export interface MessagingInboxProps {
4
+ client: MessagingInboxClient;
5
+ userId: string;
6
+ contacts?: Record<string, {
7
+ name: string;
8
+ avatarUrl?: string;
9
+ }>;
10
+ appearance?: MessagingAppearance;
11
+ active?: boolean;
12
+ onSelect: (conversation: ConversationSummary) => void;
13
+ onDeleted?: (conversationId: string) => Promise<void> | void;
14
+ }
15
+ /** Compact Messenger chat rows: avatar, name, last message, time and unread badge. */
16
+ export declare function MessagingInbox({ client, userId, contacts, appearance, active, onSelect, onDeleted }: MessagingInboxProps): React.JSX.Element;
package/dist/inbox.js ADDED
@@ -0,0 +1,48 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState, useSyncExternalStore } from 'react';
3
+ import { AppState, FlatList, Image, Modal, Platform, Pressable, Text, View, useWindowDimensions } from 'react-native';
4
+ import { defaultMessagingSettings } from '@robono/messaging';
5
+ import { MessagingPopup } from './popup.js';
6
+ /** Compact Messenger chat rows: avatar, name, last message, time and unread badge. */
7
+ export function MessagingInbox({ client, userId, contacts = {}, appearance, active = true, onSelect, onDeleted }) {
8
+ const state = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot), design = appearance ?? state.appearance ?? defaultMessagingSettings().appearance, c = design.colors;
9
+ useEffect(() => { const update = (value) => { if (active && (value === null || value === 'active'))
10
+ client.start();
11
+ else
12
+ client.stop(); }; update(AppState.currentState); const sub = AppState.addEventListener('change', update); return () => { sub.remove(); client.stop(); }; }, [client, active]);
13
+ const [selected, setSelected] = useState(null), [confirm, setConfirm] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(''), [anchor, setAnchor] = useState({ x: 8, y: 8 });
14
+ const window = useWindowDimensions();
15
+ const title = (row) => row.kind === 'group' ? row.name ?? 'Group' : contacts[row.contactId ?? '']?.name ?? row.contactId ?? 'Chat';
16
+ const openOptions = (row, x, y) => { if (!state.inboxActionsAvailable)
17
+ return; setSelected(row); setAnchor({ x: Math.max(8, Math.min(x, window.width - 288)), y: Math.max(8, Math.min(y, window.height - 200)) }); setConfirm(false); setError(''); };
18
+ useEffect(() => { if (selected && !state.conversations.some(c => c.id === selected.id) && !busy && !error)
19
+ setSelected(null); }, [state.conversations, busy, selected, error]);
20
+ async function act(remove) { if (!selected || busy)
21
+ return; setBusy(true); setError(''); try {
22
+ if (remove) {
23
+ await client.deleteConversation(selected);
24
+ await onDeleted?.(selected.id);
25
+ }
26
+ else
27
+ await client.setMuted(selected.id, !selected.muted);
28
+ setSelected(null);
29
+ setConfirm(false);
30
+ }
31
+ catch (e) {
32
+ setError(e instanceof Error ? e.message : 'Chat options could not be saved.');
33
+ }
34
+ finally {
35
+ setBusy(false);
36
+ } }
37
+ const preview = (row) => { const m = row.lastMessage; if (!m)
38
+ return 'No messages yet'; const text = m.kind === 'text' ? m.text ?? '' : ({ voice: 'Voice message', image: 'Photo', video: 'Video', document: 'Document', poll: 'Poll' }[m.kind] ?? 'Message'); return (m.senderId === userId ? 'You: ' : '') + text; };
39
+ return _jsxs(View, { style: { flex: 1, minHeight: 0, backgroundColor: c.background }, children: [_jsx(Text, { accessibilityRole: "header", style: { color: c.foreground, fontSize: 20, fontWeight: '700', padding: 12, borderBottomWidth: 1, borderBottomColor: c.foreground + '12' }, children: "Chats" }), state.error && _jsxs(View, { style: { padding: 12 }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: state.error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void client.refresh().catch(() => { }), children: _jsx(Text, { style: { color: c.accent, paddingVertical: 8 }, children: "Try again" }) })] }), _jsx(FlatList, { data: state.conversations, keyExtractor: row => row.id, contentContainerStyle: { flexGrow: 1 }, ItemSeparatorComponent: () => _jsx(View, { style: { height: 1, backgroundColor: c.foreground + '12', marginLeft: 64 } }), ListEmptyComponent: _jsx(Text, { style: { padding: 20, textAlign: 'center', color: c.secondary }, children: state.loading ? 'Loading chats…' : state.error ? '' : 'No chats yet' }), ListFooterComponent: state.hasMore ? _jsx(Pressable, { accessibilityRole: "button", disabled: state.loading, onPress: () => void client.loadMore().catch(() => { }), children: _jsx(Text, { style: { padding: 12, textAlign: 'center', color: c.accent }, children: state.loading ? 'Loading…' : 'More chats' }) }) : null, renderItem: ({ item }) => {
40
+ const person = item.contactId ? contacts[item.contactId] : undefined, title = item.kind === 'group' ? item.name ?? 'Group' : person?.name ?? item.contactId ?? 'Chat';
41
+ const date = item.lastMessage ? new Date(item.lastMessage.createdAt) : null;
42
+ return _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `${title}${item.unreadCount ? `, ${item.unreadCount} unread` : ''}`, onPress: () => onSelect(item), onLongPress: e => openOptions(item, e.nativeEvent.pageX, e.nativeEvent.pageY), accessibilityActions: state.inboxActionsAvailable ? [{ name: 'showOptions', label: 'Chat options' }] : [], onAccessibilityAction: e => { if (e.nativeEvent.actionName === 'showOptions')
43
+ openOptions(item, window.width / 2 - 140, window.height / 2 - 100); }, ...(Platform.OS === 'web' ? { onContextMenu: (e) => { e.preventDefault(); openOptions(item, e.clientX, e.clientY); } } : {}), style: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 10, minHeight: 68 }, children: [person?.avatarUrl ? _jsx(Image, { source: { uri: person.avatarUrl }, style: { width: 40, height: 40, borderRadius: 20 } }) : _jsx(View, { style: { width: 40, height: 40, borderRadius: 20, backgroundColor: c.accent + '20', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 18, fontWeight: '700' }, children: title.slice(0, 1).toUpperCase() }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsxs(Text, { numberOfLines: 1, style: { color: c.foreground, fontSize: 16, fontWeight: '700' }, children: [title, item.muted ? ' · Muted' : ''] }), _jsx(Text, { numberOfLines: 1, style: { color: c.secondary, marginTop: 3, fontSize: 14 }, children: preview(item) })] }), _jsxs(View, { style: { alignItems: 'flex-end', gap: 4 }, children: [_jsx(Text, { style: { color: item.unreadCount ? c.accent : c.secondary, fontSize: 12 }, children: date ? date.toDateString() === new Date().toDateString() ? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '' }), item.unreadCount > 0 && _jsx(View, { style: { minWidth: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: c.accent, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 5 }, children: _jsx(Text, { style: { color: c.accent, fontSize: 10, fontWeight: '900' }, children: item.unreadCount > 99 ? '99+' : item.unreadCount }) })] })] });
44
+ } }), selected && !confirm && _jsx(Modal, { visible: true, transparent: true, animationType: "fade", onRequestClose: () => { if (!busy)
45
+ setSelected(null); }, children: _jsx(Pressable, { accessibilityLabel: "Close chat options", onPress: () => { if (!busy)
46
+ setSelected(null); }, style: { flex: 1, backgroundColor: '#0004' }, children: _jsxs(View, { onStartShouldSetResponder: () => true, style: { position: 'absolute', left: anchor.x, top: anchor.y, width: 280, borderRadius: 18, padding: 8, backgroundColor: c.background }, children: [_jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => void act(false), style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground }, children: selected.muted ? 'Unmute' : 'Mute' }) }), _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => setConfirm(true), style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground }, children: "Delete chat" }) }), _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => setSelected(null), style: { padding: 16 }, children: _jsx(Text, { style: { color: c.secondary }, children: "Cancel" }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, padding: 12 }, children: error })] }) }) }), selected && confirm && _jsxs(MessagingPopup, { background: c.background, onClose: () => { if (!busy)
47
+ setConfirm(false); }, children: [_jsxs(Text, { accessibilityRole: "header", style: { color: c.foreground, fontSize: 17, fontWeight: '600' }, children: ["Delete chat with ", title(selected), "?"] }), _jsx(Text, { style: { color: c.secondary, marginTop: 8 }, children: "This removes the chat and its history from your view. Other people keep their copy. New messages can bring the chat back." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', justifyContent: 'flex-end', gap: 20, marginTop: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => setConfirm(false), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground }, children: "Cancel" }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Confirm delete chat", disabled: busy, onPress: () => void act(true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Delete" }) })] })] })] });
48
+ }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,6 @@
1
+ import { type SocialPlayerProps } from './social.js';
2
+ export type { SocialPlayerProps } from './social.js';
3
+ export { MessagingSearch } from './search.js';
1
4
  export { MessagingSafety } from './safety.js';
2
5
  export { PollMessage, PollComposer } from "./polls.js";
3
6
  import React, { type ComponentType, type ReactNode } from "react";
@@ -8,14 +11,22 @@ export type { VoiceComposerProps } from "./voice-composer.js";
8
11
  import { type VoiceBubbleProps } from "./media-ui.js";
9
12
  export { VoicePlayback, MediaViewer, MediaPlaybackControls, ZoomablePhoto } from "./media-ui.js";
10
13
  export type { VoiceBubbleProps } from "./media-ui.js";
11
- import { type MessageKind, type MessageMedia, type MessagingAppearance, type MessagingClient, type MessagingMessage, type MessagingState } from "@robono/messaging";
14
+ import { type MessageKind, type MessageMedia, type MessagingAppearance, type MessagingClient, type MessagingMessage, type MessagingState, type MessageTransform, type SavedMessage } from "@robono/messaging";
12
15
  export interface VoiceAttachmentProps {
13
16
  media: MessageMedia;
14
17
  appearance: MessagingAppearance;
15
18
  disabled: boolean;
16
19
  onRemove: () => void;
17
20
  }
21
+ export interface TranslationVoiceProps {
22
+ transform: MessageTransform;
23
+ ink: string;
24
+ accent: string;
25
+ showWaveform?: boolean;
26
+ }
18
27
  export interface MessagingComponents {
28
+ SocialPlayer?: ComponentType<SocialPlayerProps>;
29
+ TranslationVoice?: ComponentType<TranslationVoiceProps>;
19
30
  VoiceAttachment?: ComponentType<VoiceAttachmentProps>;
20
31
  Header?: ComponentType;
21
32
  Message?: ComponentType<{
@@ -37,6 +48,10 @@ export interface AttachmentChoice {
37
48
  }
38
49
  export interface MessagingScreenProps {
39
50
  client: MessagingClient;
51
+ /** Browser adapter supplies the hosting origin; native embeds use Robono's domain. */
52
+ socialOrigin?: string;
53
+ /** Optional self-hosted copy of messaging-embed.html, for strict website policies. */
54
+ socialEmbedPageUrl?: string;
40
55
  /** Target language for this viewer; defaults to the device locale. */
41
56
  language?: string;
42
57
  userId: string;
@@ -58,6 +73,14 @@ export interface MessagingScreenProps {
58
73
  /** Optional override for the supplied contact safety menu; use your existing safety flow. */
59
74
  onContactOptions?: () => void;
60
75
  onMessageOptions?: (message: MessagingMessage) => void;
76
+ /** Used by the media preview to open the same message actions. */
77
+ requestedMessageAction?: {
78
+ kind: 'forward' | 'delete';
79
+ messageId: string;
80
+ sequence: number;
81
+ };
82
+ /** Native clipboard adapter. Supplied by ExpoMessagingScreen. Web uses its clipboard. */
83
+ copyText?: (text: string) => Promise<void>;
61
84
  /** Optional recent media shown beneath the attachment buttons. */
62
85
  attachmentChoices?: AttachmentChoice[];
63
86
  /** Pick, capture or record, then upload to your private store. Return an opaque asset reference. */
@@ -71,6 +94,9 @@ export interface MessagingScreenProps {
71
94
  }) => Promise<MessageMedia | null>;
72
95
  /** Release local draft resources after removal or submission; keep uploaded assets needed by retries. */
73
96
  releaseAttachment?: (media: MessageMedia) => void | Promise<void>;
97
+ /** Upload a locally selected attachment after its outgoing bubble is visible. */
98
+ prepareAttachment?: (kind: Exclude<MessageKind, 'text' | 'poll'>, media: MessageMedia) => Promise<MessageMedia>;
99
+ retainDraftAttachments?: boolean;
74
100
  /** Resolve an authorized thumbnail and/or normalized waveform. Never put signed URLs in message data. */
75
101
  attachmentPreview?: (message: MessagingMessage) => Promise<{
76
102
  uri?: string;
@@ -78,24 +104,41 @@ export interface MessagingScreenProps {
78
104
  }>;
79
105
  /** Authorize the asset and open it using your app's media player/viewer. */
80
106
  openAttachment?: (message: MessagingMessage) => Promise<void>;
107
+ openSavedAttachment?: (record: SavedMessage) => Promise<void>;
81
108
  }
82
109
  interface MessagingContext extends MessagingScreenProps {
83
110
  state: MessagingState;
84
111
  design: MessagingAppearance;
85
112
  reply: MessagingMessage | null;
113
+ editing: MessagingMessage | null;
114
+ setEditing: (message: MessagingMessage | null) => void;
115
+ setForwarding: (messages: MessagingMessage[]) => void;
116
+ setDeleting: (messages: MessagingMessage[]) => void;
117
+ selection: MessagingMessage[];
118
+ setSelection: (messages: MessagingMessage[]) => void;
119
+ saved: SavedMessage[];
120
+ saving: boolean;
121
+ toggleSaved: (message: MessagingMessage) => Promise<void>;
122
+ setSavedOpen: (open: boolean) => void;
86
123
  setReply: (message: MessagingMessage | null) => void;
87
124
  }
88
125
  export declare function useMessagingUI(): MessagingContext;
89
126
  export declare function MessagingProvider({ children, ...props }: MessagingScreenProps & {
90
127
  children: ReactNode;
91
128
  }): React.JSX.Element;
129
+ export declare function messagingHeaderColors(design: MessagingAppearance, group: boolean): {
130
+ background: string;
131
+ foreground: string;
132
+ };
92
133
  export declare function MessagingHeader({ children }?: {
93
134
  children?: ReactNode;
94
135
  }): React.JSX.Element;
95
- export declare function MessagingBubble({ message }: {
136
+ export declare function MessagingBubble({ message, pendingStatus }: {
96
137
  message: MessagingMessage;
138
+ pendingStatus?: 'sending' | 'failed';
97
139
  }): React.JSX.Element;
98
140
  export declare function MessagingList(): React.JSX.Element;
99
141
  export declare function MessagingComposer(): React.JSX.Element;
100
142
  /** Embed inside your app's safe area and navigation. No engine, push or global audio side effects. */
101
143
  export declare function MessagingScreen(props: MessagingScreenProps): React.JSX.Element;
144
+ export { MessagingInbox, type MessagingInboxProps } from './inbox.js';