@robono/messaging-react-native 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +79 -4
- package/SBOM.spdx.json +832 -112
- package/dist/calling.js +2 -2
- package/dist/expo-download.d.ts +3 -0
- package/dist/expo-download.js +43 -0
- package/dist/expo-media.js +23 -0
- package/dist/expo-outbox.d.ts +13 -0
- package/dist/expo-outbox.js +81 -0
- package/dist/expo-saved.d.ts +24 -0
- package/dist/expo-saved.js +76 -0
- package/dist/expo-social.d.ts +3 -0
- package/dist/expo-social.js +21 -0
- package/dist/expo-thumbnails.d.ts +9 -0
- package/dist/expo-thumbnails.js +68 -0
- package/dist/expo.d.ts +9 -3
- package/dist/expo.js +143 -23
- package/dist/forward.d.ts +13 -0
- package/dist/forward.js +61 -0
- package/dist/icons.d.ts +1 -1
- package/dist/inbox.d.ts +16 -0
- package/dist/inbox.js +48 -0
- package/dist/index.d.ts +45 -2
- package/dist/index.js +376 -113
- package/dist/media-controller.d.ts +18 -0
- package/dist/media-controller.js +118 -3
- package/dist/media-readiness.d.ts +1 -1
- package/dist/media-ui.d.ts +16 -5
- package/dist/media-ui.js +15 -11
- package/dist/message-links.d.ts +5 -0
- package/dist/message-links.js +30 -0
- package/dist/message-order.d.ts +4 -0
- package/dist/message-order.js +17 -0
- package/dist/native-outbox.d.ts +49 -0
- package/dist/native-outbox.js +51 -0
- package/dist/pollVoteQueue.d.ts +3 -0
- package/dist/pollVoteQueue.js +29 -0
- package/dist/polls.d.ts +6 -2
- package/dist/polls.js +85 -18
- package/dist/popup.d.ts +9 -0
- package/dist/popup.js +7 -0
- package/dist/recent-media.js +1 -0
- package/dist/safety.d.ts +2 -1
- package/dist/safety.js +11 -5
- package/dist/saved.d.ts +10 -0
- package/dist/saved.js +46 -0
- package/dist/search.d.ts +9 -0
- package/dist/search.js +41 -0
- package/dist/social.d.ts +19 -0
- package/dist/social.js +35 -0
- package/dist/visual/RobonoIcon.js +2 -1
- package/dist/voice-composer.js +2 -2
- package/package.json +20 -4
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 ??
|
|
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:
|
|
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
|
|
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,
|
|
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: "
|
|
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)
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
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 ||
|
|
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, {
|
|
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;
|
package/dist/forward.js
ADDED
|
@@ -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): "#
|
|
7
|
+
export declare function chatSurface(background: string): "#1e262b" | "#ffffff";
|
package/dist/inbox.d.ts
ADDED
|
@@ -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';
|