@robono/messaging-react-native 0.11.1 → 0.13.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/dist/account.d.ts CHANGED
@@ -10,6 +10,7 @@ export interface MessagingAccount {
10
10
  email?: string;
11
11
  onEnableNotifications?: () => void | Promise<void>;
12
12
  onEditProfile?: () => void | Promise<void>;
13
+ onDeleteAccount?: () => void | Promise<void>;
13
14
  onSignOut?: () => void | Promise<void>;
14
15
  }
15
16
  export declare function MessagingAccountScreen({ account, companyName, onClose }: {
package/dist/account.js CHANGED
@@ -36,5 +36,5 @@ export function MessagingAccountScreen({ account, companyName, onClose }) {
36
36
  }
37
37
  else
38
38
  onClose();
39
- } }, children: _jsx(ScrollView, { contentContainerStyle: { gap: 16, paddingBottom: 12 }, children: checking && account?.deviceCheck ? _jsx(CallDeviceCheckPanel, { check: account.deviceCheck, renderVideo: account.renderCallPreview, labels: Object.fromEntries(Object.entries(callDeviceCheckText).map(([key, value]) => [key, uiText(value)])) }) : _jsxs(_Fragment, { children: [_jsxs(View, { style: { backgroundColor: '#111111', borderRadius: 16, padding: 16, gap: 14 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 14 }, children: [account?.avatarUrl ? _jsx(Image, { source: { uri: account.avatarUrl }, style: { width: 56, height: 56, borderRadius: 28 } }) : _jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#252525', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "person-circle-outline", color: c.foreground, size: 36 }) }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: { color: c.foreground, fontSize: 20, fontWeight: '600' }, children: account?.name ?? uiText("Your account") }), _jsx(Text, { style: { color: c.secondary, fontSize: 14 }, children: companyName })] })] }), account?.phone && _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: account.phone }), account?.email && _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: account.email }), account?.onEditProfile && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Edit profile"), disabled: busy, onPress: () => begin(account.onEditProfile), style: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [_jsx(MessageIcon, { name: "edit", color: c.foreground, size: 20 }), _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: uiText("Edit profile") })] })] }), account?.deviceCheck && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Check call devices"), disabled: busy, onPress: () => setChecking(true), style: { backgroundColor: '#111111', borderRadius: 12, minHeight: 52, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: uiText("Check call devices") }) }), account?.onEnableNotifications && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Enable notifications"), disabled: busy, onPress: () => void run(account.onEnableNotifications), style: { backgroundColor: '#111111', borderRadius: 12, minHeight: 52, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: uiText("Enable notifications") }) }), account?.onSignOut && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Sign out"), disabled: busy, onPress: () => begin(account.onSignOut), style: { backgroundColor: '#111111', borderRadius: 12, minHeight: 52, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: uiText("Sign out") }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, fontSize: 15 }, children: error })] }) }) });
39
+ } }, children: _jsx(ScrollView, { contentContainerStyle: { gap: 16, paddingBottom: 12 }, children: checking && account?.deviceCheck ? _jsx(CallDeviceCheckPanel, { check: account.deviceCheck, renderVideo: account.renderCallPreview, labels: Object.fromEntries(Object.entries(callDeviceCheckText).map(([key, value]) => [key, uiText(value)])) }) : _jsxs(_Fragment, { children: [_jsxs(View, { style: { backgroundColor: '#111111', borderRadius: 16, padding: 16, gap: 14 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 14 }, children: [account?.avatarUrl ? _jsx(Image, { source: { uri: account.avatarUrl }, style: { width: 56, height: 56, borderRadius: 28 } }) : _jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#252525', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "person-circle-outline", color: c.foreground, size: 36 }) }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: { color: c.foreground, fontSize: 20, fontWeight: '600' }, children: account?.name ?? uiText("Your account") }), _jsx(Text, { style: { color: c.secondary, fontSize: 14 }, children: companyName })] })] }), account?.phone && _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: account.phone }), account?.email && _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: account.email }), account?.onEditProfile && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Edit profile"), disabled: busy, onPress: () => begin(account.onEditProfile), style: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [_jsx(MessageIcon, { name: "edit", color: c.foreground, size: 20 }), _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: uiText("Edit profile") })] })] }), account?.deviceCheck && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Check call devices"), disabled: busy, onPress: () => setChecking(true), style: { backgroundColor: '#111111', borderRadius: 12, minHeight: 52, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: uiText("Check call devices") }) }), account?.onEnableNotifications && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Enable notifications"), disabled: busy, onPress: () => void run(account.onEnableNotifications), style: { backgroundColor: '#111111', borderRadius: 12, minHeight: 52, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: uiText("Enable notifications") }) }), account?.onDeleteAccount && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Delete account"), disabled: busy, onPress: () => begin(account.onDeleteAccount), style: { backgroundColor: '#111111', borderRadius: 12, minHeight: 52, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: '#ffb5a3', fontSize: 16 }, children: uiText("Delete account") }) }), account?.onSignOut && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Sign out"), disabled: busy, onPress: () => begin(account.onSignOut), style: { backgroundColor: '#111111', borderRadius: 12, minHeight: 52, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: uiText("Sign out") }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, fontSize: 15 }, children: error })] }) }) });
40
40
  }
@@ -1,5 +1,7 @@
1
1
  import { MessagingError } from '@robono/messaging';
2
2
  import { awaitMediaScreening } from './media-screening';
3
+ import { AppState } from 'react-native';
4
+ import { prepareRecordingPermission } from './recording-permission.js';
3
5
  import { AudioModule, RecordingPresets, createAudioPlayer, setAudioModeAsync } from 'expo-audio';
4
6
  import * as Picker from 'expo-image-picker';
5
7
  import * as Documents from 'expo-document-picker';
@@ -107,6 +109,7 @@ async function recordingDuration(uri) {
107
109
  const extensionMimes = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', heic: 'image/heic', heif: 'image/heif', avif: 'image/avif', mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm', '3gp': 'video/3gpp', pdf: 'application/pdf', txt: 'text/plain', csv: 'text/csv', zip: 'application/zip', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', ppt: 'application/vnd.ms-powerpoint', pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' };
108
110
  function mime(value, name) { return value?.toLowerCase() || extensionMimes[name.split('.').at(-1)?.toLowerCase() ?? ''] || 'application/octet-stream'; }
109
111
  export function createExpoMessagingDevice(audio) {
112
+ let permissionPending = false;
110
113
  let recorder = null, release = null;
111
114
  let recordingDeadline = 0;
112
115
  let starting = null;
@@ -163,20 +166,31 @@ export function createExpoMessagingDevice(audio) {
163
166
  return {
164
167
  async pick(kind, settings, signal, source) { return (await chooseFiles(kind, settings, signal, source, 1))[0] ?? null; },
165
168
  pickMany: (kind, settings, signal, maximum, mixedLibrary) => chooseFiles(kind, settings, signal, undefined, maximum, mixedLibrary),
169
+ isRequestingRecordingPermission: () => permissionPending,
166
170
  async startRecording(maximum, signal) {
167
171
  if (starting || recorder)
168
172
  throw Error('A recording is already in progress.');
169
173
  const work = (async () => {
170
- const permission = await AudioModule.requestRecordingPermissionsAsync();
171
- check(signal);
172
- if (!permission.granted)
173
- throw Error('Microphone permission is needed to record a voice message.');
174
+ permissionPending = true;
175
+ try {
176
+ await prepareRecordingPermission({
177
+ read: () => AudioModule.getRecordingPermissionsAsync(), request: () => AudioModule.requestRecordingPermissionsAsync(),
178
+ state: () => AppState.currentState, subscribe: changed => { const subscription = AppState.addEventListener('change', changed); return () => subscription.remove(); },
179
+ }, signal);
180
+ }
181
+ finally {
182
+ permissionPending = false;
183
+ }
174
184
  release = await audio.acquire('recording');
175
185
  try {
176
186
  check(signal);
187
+ if (AppState.currentState !== 'active')
188
+ throw Error('Return to the conversation to record a voice message.');
177
189
  recorder = new AudioModule.AudioRecorder({ ...RecordingPresets.HIGH_QUALITY, isMeteringEnabled: true });
178
190
  await recorder.prepareToRecordAsync({ ...RecordingPresets.HIGH_QUALITY, isMeteringEnabled: true });
179
191
  check(signal); // Leave room for AAC encoder/container padding; the backend checks final file duration.
192
+ if (AppState.currentState !== 'active')
193
+ throw Error('Return to the conversation to record a voice message.');
180
194
  const captureMs = Math.max(100, maximum - 100);
181
195
  recorder.record({ forDuration: captureMs / 1000 });
182
196
  recordingDeadline = Date.now() + captureMs;
@@ -118,11 +118,16 @@ export interface ExpoMessengerProps extends Omit<ExpoMessagingScreenProps, 'cont
118
118
  start: (open: (conversationId: string, messageId: string) => Promise<void>) => Promise<void>;
119
119
  enable: () => Promise<void>;
120
120
  };
121
+ /** Let the host choose/create an authorized contact; no customer directory is guessed. */
122
+ onNewConversation?: () => void;
123
+ /** Custom inbox dialogs share the same bounded popup host. */
124
+ inboxOverlay?: React.ReactNode;
121
125
  inbox: MessagingInboxClient;
122
126
  contacts?: Record<string, {
123
127
  name: string;
124
128
  avatarUrl?: string;
125
129
  phoneNumber?: string;
130
+ groupEligible?: boolean;
126
131
  }>;
127
132
  initialConversation?: ConversationSummary;
128
133
  selectedConversation?: ConversationSummary | null;
@@ -160,7 +160,7 @@ function ExpoMessengerContent({ inbox, contacts = {}, initialConversation, selec
160
160
  const subscription = AppState.addEventListener('change', sync);
161
161
  return () => { subscription.remove(); props.client.stop(); };
162
162
  }, [selected?.id, props.client, props.active]);
163
- const autoCalls = calling && selected && groupAllowed ? { ...(callState.features.voice ? { voice: () => calling.call(selected.id, 'voice', selected.kind === 'group') } : {}), ...(callState.features.video ? { video: () => calling.call(selected.id, 'video', selected.kind === 'group') } : {}), voiceLabel: 'Internet call' } : undefined;
163
+ const autoCalls = calling && selected && selected.contactId !== props.userId && groupAllowed ? { ...(callState.features.voice ? { voice: () => calling.call(selected.id, 'voice', selected.kind === 'group') } : {}), ...(callState.features.video ? { video: () => calling.call(selected.id, 'video', selected.kind === 'group') } : {}), voiceLabel: 'Internet call' } : undefined;
164
164
  const setSelected = (row) => { setInternal(row); onSelectConversation?.(row); };
165
165
  const [showMaps, setShowMaps] = useState(false), mapConnection = useMessengerMaps(maps), mapsAvailable = mapConnection.available;
166
166
  const inboxState = useSyncExternalStore(inbox.subscribe, inbox.getSnapshot, inbox.getSnapshot);
@@ -174,6 +174,6 @@ function ExpoMessengerContent({ inbox, contacts = {}, initialConversation, selec
174
174
  open(row, message); }).catch(() => { }); }, [notifications, inbox]);
175
175
  const notificationAccount = notifications ? { name: props.account?.name ?? 'Your account', ...props.account, onEnableNotifications: () => notifications.enable() } : props.account;
176
176
  const account = calling?.deviceCheck ? { name: 'Your account', ...notificationAccount, deviceCheck: calling.deviceCheck, renderCallPreview: (stream) => renderNativeCallVideo({ stream, local: true, style: { width: '100%', height: '100%' } }) } : notificationAccount;
177
- const contact = selected?.kind === 'group' ? { name: selected.name ?? 'Group' } : contacts[selected?.contactId ?? ''] ?? { name: selected?.contactId ?? 'Chat' };
178
- return _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(MessengerMapsNotice, { error: mapConnection.error, retry: mapConnection.retry }), showMaps && maps ? _jsx(RobonoMaps, { interfaceLocale: mapsLocale, workspace: maps, contacts: contacts, onClose: () => setShowMaps(false) }) : _jsx(ExpoMessagingScreen, { ...props, account: account, contact: contact, participants: props.participants ?? contacts, extras: selected ? props.extras : undefined, callActions: selected ? { ...autoCalls, ...props.callActions, phoneNumber: contacts[selected.contactId ?? '']?.phoneNumber ?? props.callActions?.phoneNumber } : undefined, onBack: () => { props.client.stop(); setSelected(null); void inbox.refresh().catch(() => { }); }, renderConversation: screen => selected ? _jsx(MessagingCallingScreen, { ...screen, onCall: screen.callActions?.voice, onVideoCall: screen.callActions?.video, callLabel: "Internet call", unansweredCallId: callState.unanswered?.conversationId === selected.id ? callState.unanswered.id : undefined, onDismissCallMessage: calling?.dismissUnanswered }) : _jsxs(MessagingProvider, { ...screen, children: [_jsx(MessagingInbox, { viewMemory: inboxMemory, client: inbox, userId: props.userId, contacts: contacts, companyName: props.companyName, appearance: props.appearance, active: props.active !== false, headerActions: _jsx(MessagingAccountMenu, { onPublicRooms: inboxState.publicRoomsAvailable ? () => setPublicRooms(true) : undefined, onMaps: mapsAvailable ? () => setShowMaps(true) : undefined, onNewGroup: inboxState.groupCreationAvailable ? () => setNewGroup(true) : undefined }), onSelect: open, onDeleted: id => props.client.forgetConversation(id) }), publicRooms && _jsx(MessagingPublicRooms, { inbox: inbox, accent: (props.appearance ?? inboxState.appearance ?? defaultMessagingSettings().appearance).colors.accent, createRequestId: () => props.client.createForwardId(), onClose: () => setPublicRooms(false), onJoined: (id, name) => { setPublicRooms(false); open(inbox.getSnapshot().conversations.find(c => c.id === id) ?? { id, kind: 'group', name, contactId: null, lastMessage: null, unreadCount: 0, canSend: true }); } }), newGroup && _jsx(MessagingGroupEditor, { client: props.client, inbox: inbox, userId: props.userId, contacts: contacts, groups: inboxState.groups, accent: (props.appearance ?? inboxState.appearance ?? defaultMessagingSettings().appearance).colors.accent, onClose: () => setNewGroup(false), onSaved: (id, name) => { setNewGroup(false); const row = inbox.getSnapshot().conversations.find(c => c.id === id) ?? { id, kind: 'group', name, contactId: null, lastMessage: null, unreadCount: 0, canSend: true }; open(row); } })] }) }), _jsx(ExpoMessengerCalls, { calling: calling, state: callState })] });
177
+ const contact = selected?.contactId === props.userId ? { name: 'Notes to self' } : selected?.kind === 'group' ? { name: selected.name ?? 'Group' } : contacts[selected?.contactId ?? ''] ?? { name: selected?.contactId ?? 'Chat' };
178
+ return _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(MessengerMapsNotice, { error: mapConnection.error, retry: mapConnection.retry }), showMaps && maps ? _jsx(RobonoMaps, { interfaceLocale: mapsLocale, workspace: maps, contacts: contacts, onClose: () => setShowMaps(false) }) : _jsx(ExpoMessagingScreen, { ...props, account: account, contact: contact, participants: props.participants ?? contacts, extras: selected ? props.extras : undefined, callActions: selected && selected.contactId !== props.userId ? { ...autoCalls, ...props.callActions, phoneNumber: contacts[selected.contactId ?? '']?.phoneNumber ?? props.callActions?.phoneNumber } : undefined, onBack: () => { props.client.stop(); setSelected(null); void inbox.refresh().catch(() => { }); }, renderConversation: screen => selected ? _jsx(MessagingCallingScreen, { ...screen, onCall: screen.callActions?.voice, onVideoCall: screen.callActions?.video, callLabel: "Internet call", unansweredCallId: callState.unanswered?.conversationId === selected.id ? callState.unanswered.id : undefined, onDismissCallMessage: calling?.dismissUnanswered }) : _jsxs(MessagingProvider, { ...screen, children: [_jsx(MessagingInbox, { viewMemory: inboxMemory, client: inbox, userId: props.userId, contacts: contacts, companyName: props.companyName, appearance: props.appearance, active: props.active !== false, headerActions: _jsx(MessagingAccountMenu, { onNewConversation: props.onNewConversation, onPublicRooms: inboxState.publicRoomsAvailable ? () => setPublicRooms(true) : undefined, onMaps: mapsAvailable ? () => setShowMaps(true) : undefined, onNewGroup: inboxState.groupCreationAvailable ? () => setNewGroup(true) : undefined }), onSelect: open, onDeleted: id => props.client.forgetConversation(id) }), publicRooms && _jsx(MessagingPublicRooms, { inbox: inbox, accent: (props.appearance ?? inboxState.appearance ?? defaultMessagingSettings().appearance).colors.accent, createRequestId: () => props.client.createForwardId(), onClose: () => setPublicRooms(false), onJoined: (id, name) => { setPublicRooms(false); open(inbox.getSnapshot().conversations.find(c => c.id === id) ?? { id, kind: 'group', name, contactId: null, lastMessage: null, unreadCount: 0, canSend: true }); } }), newGroup && _jsx(MessagingGroupEditor, { client: props.client, inbox: inbox, userId: props.userId, contacts: contacts, groups: inboxState.groups, accent: (props.appearance ?? inboxState.appearance ?? defaultMessagingSettings().appearance).colors.accent, onClose: () => setNewGroup(false), onSaved: (id, name) => { setNewGroup(false); const row = inbox.getSnapshot().conversations.find(c => c.id === id) ?? { id, kind: 'group', name, contactId: null, lastMessage: null, unreadCount: 0, canSend: true }; open(row); } }), props.inboxOverlay] }) }), _jsx(ExpoMessengerCalls, { calling: calling, state: callState })] });
179
179
  }
@@ -1,6 +1,6 @@
1
1
  export { createExpoMessagingClient } from './expo-outbox.js';
2
2
  import React from 'react';
3
- import { type MessagingAppearance } from '@robono/messaging';
3
+ import { type MessagingAppearance, type MessagingMessage } from '@robono/messaging';
4
4
  import { type MessagingScreenProps } from './index.js';
5
5
  import { MessagingMediaController, type LocalMessagingMedia, type MessagingMediaStorage } from './media-controller.js';
6
6
  import { type MediaReadyStage } from './media-readiness.js';
@@ -9,7 +9,13 @@ export { createExpoMessagingAudioSession, createExpoMessagingDevice, createExpoM
9
9
  export type { MessagingAudioSession } from './expo-media.js';
10
10
  export type { LocalMessagingMedia, MessagingMediaStorage, MediaTransferOptions } from './media-controller.js';
11
11
  export { MessagingMediaController } from './media-controller.js';
12
- export declare function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice, inlineRecorder, viewerActions, onDismiss }: {
12
+ export type MessagingAttachmentDownload = (input: {
13
+ message: MessagingMessage;
14
+ file: LocalMessagingMedia;
15
+ signal: AbortSignal;
16
+ }) => Promise<void>;
17
+ export declare function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice, inlineRecorder, viewerActions, onDismiss, onDownloadAttachment }: {
18
+ onDownloadAttachment?: MessagingAttachmentDownload;
13
19
  onDismiss?: () => void;
14
20
  viewerActions?: () => {
15
21
  onForward?: () => void;
@@ -24,6 +30,8 @@ export declare function MessagingMediaOverlay({ controller, audio, appearance, i
24
30
  export interface ExpoMessagingScreenProps extends Omit<MessagingScreenProps, 'pickAttachment' | 'openAttachment'> {
25
31
  storage: MessagingMediaStorage;
26
32
  audioSession: MessagingAudioSession;
33
+ /** Save an authorized private copy to your own destination. Copy it before resolving; Robono removes its temporary copy afterward. Respect signal when the viewer/account closes. */
34
+ onDownloadAttachment?: MessagingAttachmentDownload;
27
35
  /** Optional timing callback from native image/frame/playback readiness, measured from opening. */
28
36
  onMediaReady?: (event: {
29
37
  messageId: string;
@@ -140,7 +140,7 @@ function VoicePlayer({ file, audio, design, inline, draft, autoPlay = false }) {
140
140
  setLoaded(player.isLoaded);
141
141
  restorePaused();
142
142
  const subscription = player.addListener('playbackStatusUpdate', status => { if (!scope.alive)
143
- return; setLoaded(status.isLoaded); if (status.error) {
143
+ return; setLoaded(status.isLoaded); if ('error' in status && status.error) {
144
144
  if (!recoverStream())
145
145
  fail(Error('The recording could not be played. Try again.'));
146
146
  return;
@@ -332,7 +332,7 @@ function FileViewer({ file, kind, audio, design }) {
332
332
  } })();
333
333
  } }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: design.colors.foreground }, children: error })] });
334
334
  }
335
- export function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice = false, inlineRecorder = false, viewerActions, onDismiss }) {
335
+ export function MessagingMediaOverlay({ controller, audio, appearance, inlineVoice = false, inlineRecorder = false, viewerActions, onDismiss, onDownloadAttachment }) {
336
336
  const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
337
337
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
338
338
  const c = appearance.colors;
@@ -347,7 +347,14 @@ export function MessagingMediaOverlay({ controller, audio, appearance, inlineVoi
347
347
  if ((typeof inlineVoice === 'function' ? inlineVoice() : inlineVoice) && state.kind === 'voice' && ['opening', 'viewing', 'failed'].includes(state.phase) && !review)
348
348
  return null;
349
349
  const viewer = state.file && (review || state.phase === 'viewing') ? _jsx(MediaViewer, { expanded: expanded, onExpandedChange: setExpanded, kind: state.kind, uri: state.file.uri, title: review ? uiText("Preview attachment") : state.kind === 'image' ? uiText("Photo") : state.kind === 'video' ? uiText("Video") : state.kind === 'voice' ? uiText("Voice message") : uiText("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)
350
- 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 : uiText("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(uiText("Could not share this attachment."))); } : undefined, onFileAction: () => { void (async () => { try {
350
+ return; saving.current = true; setDownloading(true); setFileError(''); void controller.exportFile(async (file, context) => { if (onDownloadAttachment) {
351
+ if (!context.message)
352
+ throw Error('The attachment is unavailable.');
353
+ await onDownloadAttachment({ file, message: context.message, signal: context.signal });
354
+ }
355
+ else
356
+ await saveMessagingAttachment(file, state.kind); if (!context.signal.aborted)
357
+ mediaReady('file-opened', file.uri); }, { reauthorize: !!onDownloadAttachment }).catch(e => setFileError(e instanceof Error ? e.message : uiText("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(uiText("Could not share this attachment."))); } : undefined, onFileAction: () => { void (async () => { try {
351
358
  if (!await Sharing.isAvailableAsync())
352
359
  throw Error('File opening is unavailable.');
353
360
  await Sharing.shareAsync(state.file.uri, { mimeType: state.file.mimeType, dialogTitle: state.file.fileName });
@@ -415,11 +422,12 @@ function ExpoMessagingScreenContent({ storage, audioSession, renderConversation,
415
422
  return copy;
416
423
  }, [controller, recorder, props.client, outbox, audioSession]);
417
424
  const prepare = async (kind, media) => { await restore(media, kind); const owner = recorder.getPrepared(media.assetId) ? recorder : controller; if (!owner.getPrepared(media.assetId))
418
- return media; const uploaded = await owner.uploadPrepared(media.assetId, kind); thumbnails.alias(media.assetId, uploaded.assetId); return uploaded; };
425
+ return media; const uploaded = await owner.uploadPrepared(media.assetId, kind); return uploaded; };
419
426
  useEffect(() => props.client.setAttachmentPreparer(async (content) => { if (!content.media || content.kind === 'text' || content.kind === 'poll' || !await restore(content.media, content.kind))
420
427
  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]);
421
428
  const thumbnails = useMemo(() => createExpoThumbnails(recent.storage, id => controller.getPrepared(id) ?? recorder.getPrepared(id), createExpoMessagingDevice(audioSession).remove), [recent, controller, recorder, audioSession]);
422
- useEffect(() => () => thumbnails.clear(), [thumbnails, state.conversationId, state.settings?.enabled, state.conversation?.canSend, props.active]);
429
+ const thumbnailAccess = JSON.stringify([state.settings?.enabled, state.settings?.features, state.conversation?.canSend, !!state.error]);
430
+ useEffect(() => () => thumbnails.clear(), [thumbnails, state.conversationId, thumbnailAccess, props.active]);
423
431
  const cacheDisposal = useRef(new Map());
424
432
  useEffect(() => {
425
433
  clearTimeout(cacheDisposal.current.get(recent));
@@ -547,7 +555,7 @@ function ExpoMessagingScreenContent({ storage, audioSession, renderConversation,
547
555
  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) {
548
556
  await createExpoMessagingDevice(audioSession).remove(file);
549
557
  return;
550
- } opened.current = null; message.current = null; await controller.previewLocal(file, record.message.kind); },
558
+ } opened.current = null; message.current = null; await controller.previewLocal(file, record.message.kind, record.message); },
551
559
  };
552
560
  const mediaReady = (stage, uri) => { const event = opened.current; if (!event || controller.getSnapshot().phase !== 'viewing' || controller.getSnapshot().file?.uri !== uri)
553
561
  return; if (stage !== 'video-frame' && message.current?.conversationId === current.current.client.getSnapshot().conversationId)
@@ -556,7 +564,7 @@ function ExpoMessagingScreenContent({ storage, audioSession, renderConversation,
556
564
  current.current.onMediaReady?.({ messageId: event.messageId, kind: event.kind, stage, elapsedMs: Date.now() - event.started });
557
565
  }
558
566
  catch { } };
559
- return _jsx(MessagingTypography, { appearance: props.appearance ?? state.settings?.appearance ?? defaultMessagingSettings().appearance, children: _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: () => {
567
+ return _jsx(MessagingTypography, { appearance: props.appearance ?? state.settings?.appearance ?? defaultMessagingSettings().appearance, children: _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, { onDownloadAttachment: props.onDownloadAttachment, onDismiss: viewerDismissed, viewerActions: () => {
560
568
  const m = message.current;
561
569
  if (!m || !state.messages.some(row => row.id === m.id && !row.deleted))
562
570
  return {};
@@ -5,5 +5,4 @@ import type { LocalMessagingMedia, MessagingMediaStorage } from './media-control
5
5
  export declare function createExpoThumbnails(storage: MessagingMediaStorage, local: (id: string) => LocalMessagingMedia | undefined, remove: (file: LocalMessagingMedia) => Promise<void>): {
6
6
  preview: (message: MessagingMessage) => Promise<{}>;
7
7
  clear: () => void;
8
- alias: (from: string, to: string) => void;
9
8
  };
@@ -1,14 +1,12 @@
1
+ import { MessagingPreviewCache } from './preview-cache.js';
1
2
  import * as ImageManipulator from 'expo-image-manipulator';
2
3
  import * as VideoThumbnails from 'expo-video-thumbnails';
3
4
  import * as FS from 'expo-file-system/legacy';
4
5
  /** Small local previews, with bounded downloads and memory. Large originals stay
5
6
  * tap-to-open until the storage service supplies a dedicated thumbnail. */
6
7
  export function createExpoThumbnails(storage, local, remove) {
7
- let generation = 0;
8
- const cache = new Map(), abort = new Set();
9
- let queue = Promise.resolve();
10
- function clear() { generation++; cache.clear(); for (const c of abort)
11
- c.abort(); abort.clear(); }
8
+ const cache = new MessagingPreviewCache();
9
+ const clear = () => cache.clear();
12
10
  function preview(message) {
13
11
  const media = message.media;
14
12
  if (!media || message.deleted)
@@ -17,52 +15,45 @@ export function createExpoThumbnails(storage, local, remove) {
17
15
  return Promise.resolve({ waveform: media.waveform });
18
16
  if (!['image', 'video'].includes(message.kind))
19
17
  return Promise.resolve({});
20
- const id = media.assetId, existing = cache.get(id);
21
- if (existing)
22
- return existing;
23
- const g = generation;
24
- const work = queue.then(async () => {
25
- if (g !== generation)
18
+ const id = media.assetId, key = JSON.stringify([message.conversationId, id, message.revision, media]);
19
+ return cache.get(key, async (signal) => {
20
+ if (signal.aborted)
26
21
  return {};
27
22
  let file = local(id), downloaded = false, poster, thumbnail;
28
- const controller = new AbortController();
29
- abort.add(controller);
23
+ // An optimistic bubble has no server message ID yet. Only its local file can
24
+ // supply a preview until acknowledgement replaces the pending bubble.
25
+ if (!file && message.id.startsWith('pending:'))
26
+ return {};
30
27
  try {
31
28
  let dedicated = false;
32
29
  if (!file && storage.thumbnail) {
33
- file = await storage.thumbnail(message, { signal: controller.signal, onProgress: () => { } }).catch(() => null) ?? undefined;
30
+ file = await storage.thumbnail(message, { signal, onProgress: () => { } }) ?? undefined;
34
31
  dedicated = !!file;
35
32
  downloaded = !!file;
36
33
  }
37
34
  if (!file) {
38
35
  if (media.byteSize > 2 * 1024 * 1024)
39
36
  return {};
40
- file = await storage.download(message, { signal: controller.signal, onProgress: () => { } });
37
+ file = await storage.download(message, { signal: signal, onProgress: () => { } });
41
38
  downloaded = true;
42
39
  }
43
- if (g !== generation)
40
+ if (signal.aborted)
44
41
  return {};
45
42
  if (message.kind === 'video' && !dedicated)
46
43
  poster = (await VideoThumbnails.getThumbnailAsync(file.uri, { time: 0, quality: .6 })).uri;
47
44
  const result = await ImageManipulator.manipulateAsync(poster ?? file.uri, [{ resize: { width: 480 } }], { compress: .7, format: ImageManipulator.SaveFormat.JPEG, base64: true });
48
45
  thumbnail = result.uri;
49
- return g === generation && result.base64 ? { uri: 'data:image/jpeg;base64,' + result.base64 } : {};
46
+ return !signal.aborted && result.base64 ? { uri: 'data:image/jpeg;base64,' + result.base64 } : {};
50
47
  }
51
48
  finally {
52
- abort.delete(controller);
53
49
  if (downloaded && file)
54
50
  await remove(file).catch(() => { });
55
51
  for (const uri of [poster, thumbnail])
56
52
  if (uri && FS.cacheDirectory && uri.startsWith(FS.cacheDirectory))
57
53
  await FS.deleteAsync(uri, { idempotent: true }).catch(() => { });
58
54
  }
59
- }).catch(() => ({}));
60
- queue = work.then(() => { });
61
- cache.set(id, work);
62
- if (cache.size > 100)
63
- cache.delete(cache.keys().next().value);
64
- return work;
55
+ });
65
56
  }
66
- return { preview, clear, alias: (from, to) => { const value = cache.get(from); if (value)
67
- cache.set(to, value); } };
57
+ // Uploaded IDs get their own authorized preview; never alias access between assets.
58
+ return { preview, clear };
68
59
  }
package/dist/expo.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  export * from './expo-screen.js';
2
2
  export { ExpoMessenger, createExpoMessenger, createHostedExpoMessenger, type ExpoMessengerProps } from './expo-messenger.js';
3
+ export { MessagingPopup } from './popup.js';
4
+ export { MessagingConnectionState, type MessagingConnectionStateProps, MessagingSignIn, MessagingPhoneInput, MessagingChoicePicker, type MessagingSignInProps, type MessagingPhoneInputProps, type MessagingChoice, type MessagingChoicePickerProps } from './sign-in.js';
package/dist/expo.js CHANGED
@@ -1,2 +1,4 @@
1
1
  export * from './expo-screen.js';
2
2
  export { ExpoMessenger, createExpoMessenger, createHostedExpoMessenger } from './expo-messenger.js';
3
+ export { MessagingPopup } from './popup.js';
4
+ export { MessagingConnectionState, MessagingSignIn, MessagingPhoneInput, MessagingChoicePicker } from './sign-in.js';
@@ -7,6 +7,7 @@ export interface MessagingGroupEditorProps {
7
7
  contacts: Record<string, {
8
8
  name: string;
9
9
  avatarUrl?: string;
10
+ groupEligible?: boolean;
10
11
  }>;
11
12
  conversation?: MessagingState['conversation'];
12
13
  groups: MessagingSettings['groups'];
@@ -47,7 +47,7 @@ export function MessagingGroupEditor({ client, inbox, userId, contacts, conversa
47
47
  }
48
48
  useEffect(() => { if (editing)
49
49
  void load(); return () => { ++generation.current; }; }, [client, conversationId]);
50
- const people = useMemo(() => [...new Set([...Object.keys(contacts), ...original])].filter(id => id !== userId &&
50
+ const people = useMemo(() => [...new Set([...Object.keys(contacts), ...original])].filter(id => id !== userId && contacts[id]?.groupEligible !== false &&
51
51
  (contacts[id]?.name ?? 'Member').toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())).sort((a, b) => (contacts[a]?.name ?? a).localeCompare(contacts[b]?.name ?? b)), [contacts, original, query, userId]);
52
52
  const chosen = (id) => !removed.has(id) && (original.has(id) || added.has(id));
53
53
  const toggle = (id) => {
package/dist/inbox.d.ts CHANGED
@@ -7,6 +7,7 @@ export interface MessagingInboxProps {
7
7
  contacts?: Record<string, {
8
8
  name: string;
9
9
  avatarUrl?: string;
10
+ groupEligible?: boolean;
10
11
  }>;
11
12
  viewMemory?: InboxViewMemory;
12
13
  query?: string;
package/dist/inbox.js CHANGED
@@ -24,7 +24,7 @@ export function MessagingInbox({ client, userId, contacts = {}, appearance, acti
24
24
  const setQuery = (value) => { memory.query = value; (onSearchChange ?? setLocalQuery)(value); };
25
25
  const scroll = useInboxScroll(memory, 'chats:' + query.trim(), state.conversations.length > 0 && !state.loading, !state.hasMore);
26
26
  const [selected, setSelected] = useState(null), [confirm, setConfirm] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState('');
27
- const title = (row) => row.kind === 'group' ? row.name ?? 'Group' : contacts[row.contactId ?? '']?.name ?? row.contactId ?? 'Chat';
27
+ const title = (row) => row.contactId === userId ? uiText('Notes to self') : row.kind === 'group' ? row.name ?? 'Group' : contacts[row.contactId ?? '']?.name ?? row.contactId ?? 'Chat';
28
28
  const rows = useRef(new Map()), pending = useRef(null);
29
29
  const [menuVisible, setMenuVisible] = useState(false), [confirmVisible, setConfirmVisible] = useState(false), [anchor, setAnchor] = useState({ x: 12, y: 88 });
30
30
  const openOptions = (row) => { if (!state.inboxActionsAvailable)
@@ -62,7 +62,7 @@ export function MessagingInbox({ client, userId, contacts = {}, appearance, acti
62
62
  const preview = (row) => { const m = row.lastMessage; if (!m)
63
63
  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; };
64
64
  return _jsx(MessagingTypography, { appearance: design, children: _jsxs(View, { style: { flex: 1, minHeight: 0, backgroundColor: c.background }, children: [_jsx(MessagingPopupHeaderBoundary, { children: _jsxs(View, { testID: "messaging-inbox-header", style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', minHeight: 64, paddingHorizontal: 16, paddingVertical: 8, gap: 8 }, children: [_jsx(Text, { accessibilityRole: "header", numberOfLines: 1, style: { flex: 1, color: c.foreground, fontSize: 20, fontWeight: '700' }, children: companyName ?? design.name }), headerActions] }) }), _jsxs(View, { style: { margin: 12, marginTop: 8, flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 10, paddingHorizontal: 10, backgroundColor: c.foreground + '0b' }, children: [_jsx(MessageIcon, { name: "search", size: 20, color: c.secondary }), _jsx(TextInput, { accessibilityLabel: uiText("Search chats"), placeholder: state.globalSearchAvailable ? uiText("Search chats and messages") : uiText("Search chats"), placeholderTextColor: c.secondary, value: query, onChangeText: setQuery, maxLength: 200, autoCapitalize: "none", autoCorrect: false, style: { flex: 1, minHeight: 44, color: c.foreground, fontSize: 16 } }), !!query && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Clear chat search"), onPress: () => setQuery(''), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 20, color: c.secondary }) })] }), 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: uiText("Try again") }) })] }), query.trim() && state.globalSearchAvailable ? _jsx(InboxMessageSearch, { viewMemory: memory, client: client, query: query.trim(), appearance: design, name: title, chats: state.conversations.filter(row => title(row).toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), onSelect: onSelect }, userId) : _jsx(FlatList, { ...scroll, testID: "messaging-inbox-list", data: state.conversations.filter(row => !query.trim() || [title(row), row.lastMessage?.text ?? '', searchText?.(row) ?? ''].join(' ').toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), 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 ? uiText("Loading chats…") : state.error ? '' : query ? uiText("No matching chats.") : uiText("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 ? uiText("Loading…") : uiText("More chats") }) }) : null, renderItem: ({ item }) => {
65
- const person = item.contactId ? contacts[item.contactId] : undefined, title = item.kind === 'group' ? item.name ?? 'Group' : person?.name ?? item.contactId ?? 'Chat';
65
+ const person = item.contactId ? contacts[item.contactId] : undefined, title = item.contactId === userId ? uiText('Notes to self') : item.kind === 'group' ? item.name ?? 'Group' : person?.name ?? item.contactId ?? 'Chat';
66
66
  const date = item.lastMessage ? new Date(item.lastMessage.createdAt) : null;
67
67
  return _jsxs(Pressable, { ref: node => { if (node)
68
68
  rows.current.set(item.id, node);
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export { MessagingConnectionState, type MessagingConnectionStateProps, MessagingSignIn, MessagingPhoneInput, MessagingChoicePicker, type MessagingSignInProps, type MessagingPhoneInputProps, type MessagingChoice, type MessagingChoicePickerProps } from './sign-in.js';
1
2
  import type { MessagingCustomization } from './customization.js';
2
3
  export { RobonoLocaleProvider, useRobonoLocale } from './localization.js';
3
4
  import { type MessagingAccount } from './account.js';
@@ -60,6 +61,14 @@ export interface AttachmentChoice {
60
61
  durationMs?: number;
61
62
  }
62
63
  export interface MessagingScreenProps {
64
+ /** Disabled links render as plain text. Callbacks replace system navigation;
65
+ * providing openUrl also disables embedded social browsing. */
66
+ linkHandling?: {
67
+ webLinks?: boolean;
68
+ addresses?: boolean;
69
+ openUrl?: (url: string, message: MessagingMessage) => void | Promise<void>;
70
+ openAddress?: (address: string, message: MessagingMessage) => void | Promise<void>;
71
+ };
63
72
  /** Compose surrounding navigation while keeping a single message context. */
64
73
  renderLayout?: (conversation: ReactNode) => ReactNode;
65
74
  /** Language of controls and accessibility labels; separate from translated message content. */
@@ -175,7 +184,8 @@ export declare function MessagingBubble({ message, pendingStatus, previousMessag
175
184
  export declare function MessagingList(): React.JSX.Element;
176
185
  export declare function MessagingComposer(): React.JSX.Element;
177
186
  /** Account navigation. Mount in the inbox/app toolbar, never in a contact menu. */
178
- export declare function MessagingAccountMenu({ onNewGroup, onMaps, onPublicRooms }?: {
187
+ export declare function MessagingAccountMenu({ onNewConversation, onNewGroup, onMaps, onPublicRooms }?: {
188
+ onNewConversation?: () => void;
179
189
  onNewGroup?: () => void;
180
190
  onMaps?: () => void;
181
191
  onPublicRooms?: () => void;