@robono/messaging-react-native 0.11.1 → 0.12.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 +4 -0
- package/README.md +37 -3
- package/SBOM.spdx.json +194 -98
- package/dist/account.d.ts +1 -0
- package/dist/account.js +1 -1
- package/dist/expo-media.js +18 -4
- package/dist/expo-messenger.d.ts +5 -0
- package/dist/expo-messenger.js +3 -3
- package/dist/expo-screen.d.ts +10 -2
- package/dist/expo-screen.js +15 -7
- package/dist/expo-thumbnails.d.ts +0 -1
- package/dist/expo-thumbnails.js +17 -26
- package/dist/expo.d.ts +1 -0
- package/dist/expo.js +1 -0
- package/dist/group-editor.d.ts +1 -0
- package/dist/group-editor.js +1 -1
- package/dist/inbox.d.ts +1 -0
- package/dist/inbox.js +2 -2
- package/dist/index.d.ts +10 -1
- package/dist/index.js +14 -12
- package/dist/media-controller.d.ts +9 -2
- package/dist/media-controller.js +15 -10
- package/dist/media-screening.js +9 -3
- package/dist/preview-cache.d.ts +24 -0
- package/dist/preview-cache.js +59 -0
- package/dist/recording-permission.d.ts +12 -0
- package/dist/recording-permission.js +34 -0
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -349,11 +349,13 @@ function MessageMetadata({ mine, ink, pendingStatus }) {
|
|
|
349
349
|
}
|
|
350
350
|
export function MessagingBubble({ message, pendingStatus, previousMessage, nextMessage }) {
|
|
351
351
|
const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
|
|
352
|
-
const { userId, design, state, client, participants, openDiscussion, discussionPreview, discussionRoot, setReply, setEditing, setForwarding, setDeleting, selection, setSelection, saved, saving, toggleSaved, onMessageOptions, openAttachment, language, contact, copyText, components, extras, extrasState, onCallHistory } = useMessagingUI();
|
|
353
|
-
const
|
|
352
|
+
const { userId, design, state, client, participants, openDiscussion, discussionPreview, discussionRoot, setReply, setEditing, setForwarding, setDeleting, selection, setSelection, saved, saving, toggleSaved, onMessageOptions, openAttachment, language, contact, copyText, components, extras, extrasState, onCallHistory, linkHandling } = useMessagingUI();
|
|
353
|
+
const deliveryStatus = pendingStatus ?? (message.senderId === userId && message.delivery?.state === 'pending' ? 'sending' : message.senderId === userId && message.delivery?.state === 'failed' ? 'failed' : undefined);
|
|
354
|
+
const candidateExtra = !message.custom && !pendingStatus && !message.deleted ? extrasState?.messages[message.id] : undefined;
|
|
355
|
+
const extra = candidateExtra?.address && (linkHandling?.addresses === false || linkHandling?.openAddress) ? undefined : candidateExtra;
|
|
354
356
|
const extraRef = useRef(null), socialRef = useRef(null);
|
|
355
357
|
const socialOrigin = useMessagingUI().socialOrigin ?? 'https://robono.com';
|
|
356
|
-
const socialReady = !message.custom && !extra?.address && !pendingStatus && !message.deleted && message.kind === 'text' && state.settings?.enabled && state.socialPreviewAvailable && components?.SocialPlayer && messageLinks(message.text ?? '').some(link => !!link.url && !!socialShare(link.url, socialOrigin));
|
|
358
|
+
const socialReady = linkHandling?.webLinks !== false && !linkHandling?.openUrl && !message.custom && !extra?.address && !pendingStatus && !message.deleted && message.kind === 'text' && state.settings?.enabled && state.socialPreviewAvailable && components?.SocialPlayer && messageLinks(message.text ?? '').some(link => !!link.url && !!socialShare(link.url, socialOrigin));
|
|
357
359
|
const contextContactName = contact.name;
|
|
358
360
|
const mine = message.senderId === userId, c = design.colors, ink = message.kind === 'poll' ? c.foreground : mine ? c.outgoingText : c.incomingText;
|
|
359
361
|
const bubbleRadius = design.presentation?.bubbleShape === 'square' ? 2 : design.presentation?.bubbleShape === 'rounded' ? 16 : 8;
|
|
@@ -443,14 +445,14 @@ export function MessagingBubble({ message, pendingStatus, previousMessage, nextM
|
|
|
443
445
|
setBusy(false);
|
|
444
446
|
}
|
|
445
447
|
}
|
|
446
|
-
const renderLinks = (text) => messageLinks(text).map((part, index) => part.url ? _jsx(Text, { accessibilityRole: "link", onLongPress: menu, onPress: event => { event.stopPropagation(); void Linking.openURL(part.url).catch(() => setError(uiText("This link could not be opened."))); }, style: { textDecorationLine: 'underline' }, children: part.text }, index) : part.text);
|
|
447
|
-
const renderText = (text) => { const address = findChatAddress(text); return address ? _jsxs(_Fragment, { children: [renderLinks(text.slice(0, address.start)), _jsx(Text, { accessibilityRole: "link", onLongPress: menu, onPress: event => { event.stopPropagation(); void openChatAddress(address.text).catch(() => setError(uiText("Maps could not be opened."))); }, style: { color: c.accent, textDecorationLine: 'underline' }, children: address.text }), renderLinks(text.slice(address.end))] }) : renderLinks(text); };
|
|
448
|
+
const renderLinks = (text) => messageLinks(text).map((part, index) => part.url && linkHandling?.webLinks !== false ? _jsx(Text, { accessibilityRole: "link", onLongPress: menu, onPress: event => { event.stopPropagation(); void Promise.resolve().then(() => linkHandling?.openUrl ? linkHandling.openUrl(part.url, message) : Linking.openURL(part.url)).catch(() => setError(uiText("This link could not be opened."))); }, style: { textDecorationLine: 'underline' }, children: part.text }, index) : part.text);
|
|
449
|
+
const renderText = (text) => { const address = linkHandling?.addresses === false ? null : findChatAddress(text); return address ? _jsxs(_Fragment, { children: [renderLinks(text.slice(0, address.start)), _jsx(Text, { accessibilityRole: "link", onLongPress: menu, onPress: event => { event.stopPropagation(); void Promise.resolve().then(() => linkHandling?.openAddress ? linkHandling.openAddress(address.text, message) : openChatAddress(address.text)).catch(() => setError(uiText("Maps could not be opened."))); }, style: { color: c.accent, textDecorationLine: 'underline' }, children: address.text }), renderLinks(text.slice(address.end))] }) : renderLinks(text); };
|
|
448
450
|
const extraCard = extra && extras ? _jsx(MessageExtra, { ref: extraRef, addressBubbleHeight: addressBubbleHeight, extra: extra, messageId: message.id, userId: userId, extras: extras, ink: ink, accent: c.accent, background: c.background, foreground: c.foreground, Map: components?.TrackingMap, onLongPress: menu, onCall: onCallHistory, footer: _jsx(MessageMetadata, { message: message, mine: mine, ink: ink }), renderAddress: (action, people) => _jsxs(_Fragment, { children: [_jsxs(Pressable, { onLayout: event => { const h = event.nativeEvent.layout.height; setAddressBubbleHeight(old => Math.abs(old - h) < .5 ? old : h); }, testID: "address-message-bubble", accessibilityRole: "button", accessibilityLabel: uiText("Show live tracking map"), hitSlop: { top: 6, bottom: 6, left: 4, right: 4 }, onLongPress: menu, onPress: event => { event.stopPropagation(); if (!quoteGesture.suppressPress())
|
|
449
451
|
extraRef.current?.open(); }, style: { flexShrink: 1, minWidth: 0, minHeight: 44, justifyContent: 'center', position: 'relative', backgroundColor: mine ? c.outgoing : c.incoming, borderRadius: bubbleRadius, paddingHorizontal: 6, paddingVertical: 3 }, children: [bubbleBody, people] }), action] }) }) : null;
|
|
450
452
|
const socialCard = socialReady && components?.SocialPlayer ? _jsx(SocialMessagePreview, { ref: socialRef, onLongPress: menu, message: message, client: client, Player: components.SocialPlayer, origin: socialOrigin, accent: c.accent, foreground: c.foreground, background: c.background, renderMessage: action => _jsxs(_Fragment, { children: [_jsx(View, { testID: "social-message-bubble", style: { flexShrink: 1, minWidth: 0, minHeight: 44, justifyContent: 'center', position: 'relative', backgroundColor: mine ? c.outgoing : c.incoming, borderRadius: bubbleRadius, paddingHorizontal: 6, paddingVertical: 3 }, children: bubbleBody }), action] }) }) : null;
|
|
451
453
|
const canCopy = !!message.text && (!!copyText || Platform.OS === 'web');
|
|
452
454
|
const reactionPicker = !mine && canInteract && state.settings?.features.reactions && _jsx(View, { testID: "message-reaction-picker", accessibilityLabel: uiText("Choose a reaction"), style: { height: 50, borderRadius: 25, backgroundColor: '#171717', borderWidth: .5, borderColor: '#ffffff24', justifyContent: 'center', boxShadow: '0 6px 14px #0006' }, children: _jsx(ScrollView, { horizontal: true, showsHorizontalScrollIndicator: false, keyboardShouldPersistTaps: "handled", contentContainerStyle: { alignItems: 'center', paddingHorizontal: 8, gap: 4 }, children: messageReactions.map(emoji => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("React {value1}", { value1: emoji }), accessibilityState: { selected: !!message.reactions?.some(r => r.emoji === emoji && r.userIds.includes(userId)) }, disabled: busy, onPress: () => void react(emoji), style: { width: 40, height: 38, borderRadius: 19, alignItems: 'center', justifyContent: 'center', backgroundColor: message.reactions?.some(r => r.emoji === emoji && r.userIds.includes(userId)) ? '#f0843433' : 'transparent' }, children: _jsx(Text, { style: { fontSize: 23, lineHeight: 28 }, children: emoji }) }, emoji)) }) });
|
|
453
|
-
const footer = _jsx(MessageMetadata, { message: message, mine: mine, ink: visual ? '#ffffff' : ink, pendingStatus:
|
|
455
|
+
const footer = _jsx(MessageMetadata, { message: message, mine: mine, ink: visual ? '#ffffff' : ink, pendingStatus: deliveryStatus });
|
|
454
456
|
const bigEmoji = message.kind === 'text' && !message.replyTo && !extra && !canTranslate && !transformed && isBigEmojiText(message.text);
|
|
455
457
|
const showingTransform = !!transformed && !showOriginal && (transformed.kind === 'transcript' || canTranslate);
|
|
456
458
|
const languageActions = (canTranscript || canTranslate || transformed) && _jsxs(View, { testID: "message-language-actions", style: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: voice && !showingTransform ? 'flex-end' : 'flex-start', columnGap: 8, rowGap: 0 }, children: [transformed && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: showOriginal ? (transformed.kind === 'transcript' ? 'Show transcript' : 'Show translation') : uiText("Show original"), onPress: () => { setTranslationAutoPlay(false); setShowOriginal(v => !v); }, hitSlop: 8, style: { minHeight: 17, paddingVertical: 1, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 11, lineHeight: 15 }, children: showOriginal ? (transformed.kind === 'transcript' ? 'Show transcript' : 'Show translation') : uiText("Show original") }) }), canTranscript && !transformed && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Read voice message"), disabled: transforming, onPress: () => void transform('transcript'), hitSlop: 8, style: { minHeight: 17, paddingVertical: 1, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 11, lineHeight: 15 }, children: transforming ? uiText("Transcribing…") : uiText("Transcript") }) }), canTranslate && transformed?.kind !== 'translation' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: translation.mode === 'voice' ? uiText("Play translation") : uiText("Translate message"), disabled: transforming, onPress: () => void transform('translation', translation.mode === 'voice' ? 'voice' : 'text'), hitSlop: 8, style: { minHeight: 17, paddingVertical: 1, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 11, lineHeight: 15 }, children: translation.mode === 'voice' ? uiText("Play Translation") : uiText("Translate") }) })] });
|
|
@@ -459,11 +461,11 @@ export function MessagingBubble({ message, pendingStatus, previousMessage, nextM
|
|
|
459
461
|
const content = showingTransform ? _jsxs(View, { children: [_jsx(Text, { style: { color: ink, fontSize: 11, opacity: .65, marginBottom: 2 }, children: transformed.kind === 'transcript' ? uiText("Transcript") : 'Translated · ' + supportedLanguageLabel(translationLanguage) }), transformed.audio && TranslationVoice ? _jsx(View, { testID: "message-translated-voice", children: _jsx(TranslationVoice, { transform: transformed, ink: ink, accent: c.accent, showWaveform: design.showWaveform !== false, autoPlay: translationAutoPlay }) }) : _jsx(Text, { testID: "message-language-result", selectable: true, style: { color: ink, fontSize: 16, lineHeight: 22 }, children: transformed.text }), !voice && footer] }) : originalContent;
|
|
460
462
|
const regionProps = { message, mine, appearance: design, pendingStatus };
|
|
461
463
|
const bodyContent = components?.MessageBody && !message.deleted ? _jsx(components.MessageBody, { ...regionProps, children: content }) : content;
|
|
462
|
-
const bubbleBody = _jsxs(_Fragment, { children: [!bigEmoji && (!mine || !
|
|
464
|
+
const bubbleBody = _jsxs(_Fragment, { children: [!bigEmoji && (!mine || !deliveryStatus) && _jsx(View, { testID: mine ? (message.readAt && state.settings?.features.readReceipts ? 'message-status-read' : 'message-status-sent') : 'message-status-incoming', pointerEvents: "none", accessible: false, style: { position: 'absolute', top: joinsBefore ? -(messageGap + 2) / 2 : 0, bottom: joinsAfter ? -(messageGap + 2) / 2 : 0, start: 0, end: 0, zIndex: 40, ...(mine ? { borderEndWidth: 2, borderEndColor: message.readAt && state.settings?.features.readReceipts ? c.accent : c.foreground, borderTopEndRadius: joinsBefore ? 0 : voice && !message.text && !showingTransform ? 24 : bubbleRadius, borderBottomEndRadius: joinsAfter ? 0 : voice && !message.text && !showingTransform ? 24 : bubbleRadius } : { borderStartWidth: 2, borderStartColor: '#2B2B2B', borderTopStartRadius: joinsBefore ? 0 : voice && !message.text && !showingTransform ? 24 : bubbleRadius, borderBottomStartRadius: joinsAfter ? 0 : voice && !message.text && !showingTransform ? 24 : bubbleRadius }) } }), message.replyTo && message.replyTo !== discussionRoot && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Go to quoted message"), onLongPress: menu, onPress: () => { void client.focusMessage(message.replyTo).catch(e => setError(e instanceof Error ? e.message : uiText("This message is unavailable."))); }, style: { borderLeftWidth: 3, borderLeftColor: c.accent, paddingLeft: 9, paddingVertical: 5, marginBottom: 7 }, children: _jsx(QuotedPreview, { message: state.messages.find(m => m.id === message.replyTo) ?? state.quotedMessages?.find(m => m.id === message.replyTo), ink: ink }) }), !mine && !grouped && state.conversation?.kind === 'group' && _jsx(Text, { style: { color: ink, fontSize: 12, fontWeight: '600', marginBottom: 5 }, children: message.deleted ? uiText("Former member") : participants?.[message.senderId]?.name ?? uiText("Member") }), message.custom || extra?.address || extra?.weather || extra?.call || voice || message.kind === 'poll' ? bodyContent : _jsx(Pressable, { accessibilityRole: message.kind === 'text' ? undefined : 'button', accessibilityLabel: message.kind === 'text' ? undefined : uiText('{kind}: {name}', { kind: uiText(mediaNames[message.kind]), name: message.media?.fileName ?? uiText('Open attachment') }), disabled: busy, onLongPress: menu, onPress: message.kind === 'text' ? () => { if (quoteGesture.suppressPress())
|
|
463
465
|
return; if (extra?.address)
|
|
464
466
|
extraRef.current?.open();
|
|
465
467
|
else
|
|
466
|
-
socialRef.current?.open(); } : () => void open(), children: bodyContent }), !discussionPreview && state.discussionAvailable && !!message.replyCount && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: message.replyCount + ' ' + (message.replyCount === 1 ? 'reply' : 'replies'), onPress: () => openDiscussion(message.id), style: { minHeight: 32, justifyContent: 'center' }, children: _jsxs(Text, { style: { color: c.accent, fontSize: 12, fontWeight: '600' }, children: [message.replyCount, " ", message.replyCount === 1 ? uiText("reply") : uiText("replies")] }) }), message.kind !== 'text' && !!message.text && _jsx(Text, { selectable: true, onLongPress: menu, style: { color: ink, fontSize: 16, lineHeight: 22, marginTop: 4 }, children: renderText(message.text) }), message.kind !== 'text' && message.kind !== 'poll' && !voice && !visual && _jsx(View, { style: { marginTop: 4, alignSelf: 'flex-end' }, children: footer }), (!voice || showingTransform) && languageActions, components?.MessageFooter && !message.deleted && _jsx(components.MessageFooter, { ...regionProps }), transforming && !voice && _jsx(Text, { accessibilityLiveRegion: "polite", style: { color: ink, fontSize: 11 }, children: uiText("Converting…") }), busy && !voice && _jsx(Text, { style: { color: ink, fontSize: 12, marginTop: 4 }, children: uiText("Opening…") }), !!error && _jsxs(MessagingPopup, { compact: true, contentWidth: 640, title: uiText("Message"), foreground: c.foreground, background: chatSurface(c.background), onClose: () => setError(''), children: [_jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground, fontSize: 15, lineHeight: 21, marginTop: 8 }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Close message alert"), onPress: () => setError(''), style: { alignSelf: 'flex-end', minHeight: 44, justifyContent: 'center', paddingHorizontal: 8, marginTop: 8 }, children: _jsx(Text, { style: { color: c.accent }, children: uiText("OK") }) })] }),
|
|
468
|
+
socialRef.current?.open(); } : () => void open(), children: bodyContent }), !discussionPreview && state.discussionAvailable && !!message.replyCount && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: message.replyCount + ' ' + (message.replyCount === 1 ? 'reply' : 'replies'), onPress: () => openDiscussion(message.id), style: { minHeight: 32, justifyContent: 'center' }, children: _jsxs(Text, { style: { color: c.accent, fontSize: 12, fontWeight: '600' }, children: [message.replyCount, " ", message.replyCount === 1 ? uiText("reply") : uiText("replies")] }) }), message.kind !== 'text' && !!message.text && _jsx(Text, { selectable: true, onLongPress: menu, style: { color: ink, fontSize: 16, lineHeight: 22, marginTop: 4 }, children: renderText(message.text) }), message.kind !== 'text' && message.kind !== 'poll' && !voice && !visual && _jsx(View, { style: { marginTop: 4, alignSelf: 'flex-end' }, children: footer }), (!voice || showingTransform) && languageActions, components?.MessageFooter && !message.deleted && _jsx(components.MessageFooter, { ...regionProps }), transforming && !voice && _jsx(Text, { accessibilityLiveRegion: "polite", style: { color: ink, fontSize: 11 }, children: uiText("Converting…") }), busy && !voice && _jsx(Text, { style: { color: ink, fontSize: 12, marginTop: 4 }, children: uiText("Opening…") }), !!error && _jsxs(MessagingPopup, { compact: true, contentWidth: 640, title: uiText("Message"), foreground: c.foreground, background: chatSurface(c.background), onClose: () => setError(''), children: [_jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground, fontSize: 15, lineHeight: 21, marginTop: 8 }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Close message alert"), onPress: () => setError(''), style: { alignSelf: 'flex-end', minHeight: 44, justifyContent: 'center', paddingHorizontal: 8, marginTop: 8 }, children: _jsx(Text, { style: { color: c.accent }, children: uiText("OK") }) })] }), deliveryStatus === 'failed' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Retry sending message"), onPress: () => void client.retry(message.clientMessageId).catch(() => { }), style: { minHeight: 32, justifyContent: 'center', alignSelf: 'flex-end' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 12 }, children: design.labels.retry }) }), Platform.OS === 'web' && !mine && canInteract && state.settings?.features.reactions && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("React to message"), onFocus: () => setHovered(true), onBlur: () => setHovered(false), onPress: openMenu, style: { opacity: hovered || actions ? 1 : 0, position: 'absolute', top: 6, ...(mine ? { left: -34 } : { right: -34 }), width: 32, height: 32, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "reaction", color: c.secondary, size: 20 }) }), !!message.reactions?.length && _jsx(View, { testID: "message-reaction-badge", pointerEvents: "none", accessibilityLabel: message.reactions.slice(-3).map(r => r.emoji).join(' '), style: { position: 'absolute', zIndex: 41, top: -16, ...(mine ? { end: -9 } : { start: -9 }), minWidth: 30, height: 20, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontSize: 16, lineHeight: 20 }, children: message.reactions.slice(-3).map(r => r.emoji).join(' ') }) }), _jsxs(MessageMenu, { visible: actions, position: menuPosition, screenHeight: viewport.height, mine: mine, reactions: reactionPicker, onClose: () => setActions(false), onDismiss: menuDismissed, children: [quoting && _jsx(MessageMenuRow, { icon: "quote", label: uiText("Quote message"), onPress: () => { setActions(false); setReply(message); } }), !discussionPreview && state.discussionAvailable && !pendingStatus && !message.deleted && _jsx(MessageMenuRow, { icon: "quote", label: uiText("Replies"), onPress: () => leaveMenu(() => openDiscussion(message.threadRoot ?? message.id)) }), state.discussionAvailable && canInteract && state.conversation?.ownerId === userId && _jsx(MessageMenuRow, { icon: "bookmark", label: message.pinned ? uiText("Unpin for group") : uiText("Pin for group"), onPress: () => { setActions(false); void client.pinMessage(message.id, !message.pinned).catch(e => setError(e instanceof Error ? e.message : uiText("The pin could not change."))); } }), canCopy && _jsx(MessageMenuRow, { icon: "copy", label: uiText("Copy"), onPress: () => { setActions(false); void (copyText ? copyText(message.text) : navigator.clipboard.writeText(message.text)).catch(() => setError(uiText("The text could not be copied."))); } }), canInteract && client.canForward(message) && _jsx(MessageMenuRow, { icon: "redo", label: uiText("Forward"), onPress: () => leaveMenu(() => setForwarding([message])) }), !pendingStatus && !message.deleted && message.kind !== 'poll' && client.hasSavedMessages() && _jsx(MessageMenuRow, { icon: "bookmark", label: saved.some(r => r.key === savedMessageKey(message)) ? uiText("Remove from Saved") : uiText("Save"), disabled: saving, onPress: () => { setActions(false); void toggleSaved(message); } }), canChange && !message.custom && message.kind !== 'poll' && state.settings?.features[message.kind] && _jsx(MessageMenuRow, { icon: "edit", label: uiText("Edit"), onPress: () => { setActions(false); setEditing(message); } }), !mine && state.conversation?.safety?.canReport && _jsx(MessageMenuRow, { icon: "flag", label: uiText("Report"), danger: true, onPress: () => leaveMenu(() => setReportOpen(true)) }), !pendingStatus && !message.deleted && ['owner', 'moderator'].includes(state.conversation?.roomRole ?? '') && _jsx(MessageMenuRow, { icon: "trash", label: uiText('Remove from group'), danger: true, onPress: () => leaveMenu(() => setRoomRemoval(true)) }), canChange && _jsx(MessageMenuRow, { icon: "trash", label: uiText("Delete"), danger: true, onPress: () => leaveMenu(() => setDeleting([message])) }), (pendingStatus === 'failed' || pendingStatus === 'queued') && _jsx(MessageMenuRow, { icon: "trash", label: uiText("Remove unsent message"), danger: true, onPress: () => { setActions(false); void client.discardPending(message.clientMessageId).catch(() => setError(uiText("The unsent message could not be removed."))); } }), !pendingStatus && onMessageOptions && _jsx(MessageMenuRow, { icon: "more", label: uiText("More options"), onPress: () => leaveMenu(() => onMessageOptions(message)) }), !pendingStatus && !message.deleted && _jsx(MessageMenuRow, { icon: "check", label: uiText("Select Multiple"), divider: true, onPress: () => { setActions(false); setSelection([message]); Keyboard.dismiss(); } })] }), roomRemoval && _jsx(MessagingRoomRemoval, { client: client, message: message, accent: c.accent, onClose: () => setRoomRemoval(false) }), reportOpen && _jsx(MessagingSafety, { client: client, state: state, appearance: design, name: participants?.[message.senderId]?.name ?? 'Contact', messageId: message.id, onClose: () => setReportOpen(false) })] });
|
|
467
469
|
return _jsxs(View, { ...(Platform.OS === 'web' ? { onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onContextMenu: (event) => { event.preventDefault(); menu(); } } : {}), style: { width: '100%', position: 'relative', marginTop: messageGap + (message.reactions?.length ? 16 : 0), marginBottom: 0 }, children: [quoteGesture.infoLoaded && _jsxs(Animated.View, { pointerEvents: "none", testID: "message-info-reveal", accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", style: { ...quoteGesture.revealWebStyle, position: 'absolute', top: '50%', marginTop: -26, minHeight: 52, width: messageInfoRevealWidth, justifyContent: 'center', ...(mine ? { start: 0, alignItems: 'flex-start' } : { end: 0, alignItems: 'flex-end' }), opacity: quoteGesture.infoOpacity }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'baseline', gap: 4 }, children: [_jsx(Text, { style: { color: c.secondary, fontSize: 8, lineHeight: 11, fontWeight: '900' }, children: uiText("SENT:") }), _jsx(Text, { style: { color: c.foreground, fontSize: 10, lineHeight: 12, fontWeight: '800' }, children: messageTimeLabel(message, uiLocale, uiText) })] }), mine && state.settings?.features.readReceipts && _jsxs(View, { style: { flexDirection: 'row', alignItems: 'baseline', gap: 4 }, children: [_jsx(Text, { style: { color: c.secondary, fontSize: 8, lineHeight: 11, fontWeight: '900' }, children: uiText("READ:") }), _jsx(Text, { style: { color: message.readAt ? c.foreground : c.secondary, fontSize: 10, lineHeight: 12, fontWeight: '800' }, children: message.readAt ? new Date(message.readAt).toLocaleTimeString(uiLocale, { hour: 'numeric', minute: '2-digit' }) : uiText("Not yet") })] })] }), quoting && _jsxs(Animated.View, { pointerEvents: "none", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : { accessible: false, accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' }), testID: `quote-reveal-${mine ? 'outgoing' : 'incoming'}`, style: { ...quoteGesture.revealWebStyle, position: 'absolute', top: '50%', marginTop: -26, ...(mine ? { end: 0 } : { start: 0 }), width: messageReplyRevealWidth, minHeight: 52, gap: 3, alignItems: 'center', justifyContent: 'center', opacity: quoteGesture.revealOpacity }, children: [_jsx(View, { style: { width: 40, height: 40, borderRadius: 20, alignItems: 'center', justifyContent: 'center', backgroundColor: chatSurface(c.background), borderWidth: 1.5, borderColor: c.secondary + '40' }, children: _jsx(MessageIcon, { name: "quote", color: c.accent, size: 30 }) }), _jsx(Text, { style: { color: c.secondary, fontSize: 10, fontWeight: '900' }, children: uiText("QUOTE MESSAGE") })] }), _jsx(Animated.View, { ref: node => { anchor.current = node; if (quoteGesture.webRef)
|
|
468
470
|
quoteGesture.webRef.current = node; }, ...quoteGesture.handlers, accessibilityActions: quoting ? [{ name: "quote", label: uiText("Quote message") }] : undefined, onAccessibilityAction: event => { if (quoting && event.nativeEvent.actionName === "quote")
|
|
469
471
|
setReply(message); }, testID: `message-${message.kind}-${mine ? 'outgoing' : 'incoming'}`, style: { ...quoteGesture.webStyle, transform: [{ translateX: quoteGesture.offset }], ...(Platform.OS === "web" && !pendingStatus ? { touchAction: "pan-y", userSelect: "none" } : {}), alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '88%', backgroundColor: mine ? c.outgoing : c.incoming, borderRadius: bubbleRadius, padding: visual ? 3 : 6, paddingBottom: visual || canTranscript || canTranslate ? 3 : 6, boxShadow: bigEmoji ? undefined : '0 1px 1px #00000010', ...(voice && !message.text && !showingTransform ? { backgroundColor: 'transparent', padding: 0, paddingBottom: 0, boxShadow: undefined } : {}), ...(message.kind === 'poll' ? { backgroundColor: chatSurface(c.background), borderWidth: 1, borderColor: c.accent + '60', paddingHorizontal: 8, paddingTop: 4, paddingBottom: 2 } : {}), ...(bigEmoji ? { backgroundColor: 'transparent', paddingHorizontal: 4, paddingTop: 0, paddingBottom: 0 } : {}), ...(extra?.address || socialReady ? { flexDirection: mine ? 'row-reverse' : 'row', alignItems: 'center', gap: 8, padding: 0, paddingBottom: 0, backgroundColor: 'transparent', boxShadow: undefined } : {}) }, children: extra?.address ? extraCard : socialReady ? socialCard : bubbleBody })] });
|
|
@@ -969,15 +971,15 @@ export function MessagingComposer() {
|
|
|
969
971
|
return (_jsxs(View, { testID: "messaging-composer", style: { paddingStart: 8, paddingEnd: 0, paddingVertical: 4, gap: 8, backgroundColor: c.background }, children: [mentionSuggestions.length > 0 && _jsx(ScrollView, { keyboardShouldPersistTaps: "handled", style: { maxHeight: 144 }, children: mentionSuggestions.map(id => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText('Mention {name}', { name: participants[id].name }), onPress: () => chooseMention(id), style: { minHeight: 44, paddingHorizontal: 12, justifyContent: 'center', borderBottomWidth: .5, borderBottomColor: c.secondary + '30' }, children: _jsxs(Text, { style: { color: c.accent, fontSize: 16 }, children: ["@", participants[id].name] }) }, id)) }), mentionQuery !== undefined && !!mentionError && _jsx(Text, { style: { color: c.secondary, fontSize: 13 }, children: mentionError }), editing && _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsxs(Text, { numberOfLines: 1, style: { flex: 1, color: c.secondary }, children: [uiText("Editing:"), editing.text || editing.media?.fileName] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Cancel editing"), disabled: busy, onPress: () => setEditing(null), style: { minHeight: 36, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: uiText("Cancel") }) })] }), !editing && reply && (_jsxs(View, { testID: "composer-quote", style: { flexDirection: "row", gap: 8 }, children: [_jsx(View, { style: { flex: 1 }, children: _jsx(QuotedPreview, { message: reply, ink: c.secondary }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Cancel quote"), onPress: () => setReply(null), children: _jsx(Text, { style: { color: c.foreground }, children: uiText("Cancel") }) })] })), state.settings?.history?.mode === 'last_two' && _jsx(Text, { style: { color: c.secondary, fontSize: 11, textAlign: 'center' }, children: uiText("Only the latest two messages are kept.") }), editing ? _jsxs(_Fragment, { children: [renderAttachments(), renderTextComposer()] }) : features?.voice ? _jsx(VoiceComposer, { appearance: design, maxSeconds: state.settings?.voice?.maxDurationSeconds ?? 60, busy: busy, onRecord: () => { Keyboard.dismiss(); void pick('voice'); }, renderAttachments: renderAttachments, renderTextComposer: features?.text || selected.length ? renderTextComposer : undefined, actions: _jsx(_Fragment, { children: (camera || library.image || library.video || library.document || !editing && (features?.poll || weatherAllowed)) && _jsx(Pressable, { ref: attachmentButton, accessibilityRole: "button", accessibilityLabel: uiText("Attach"), accessibilityState: { expanded: attachmentsOpen }, disabled: busy, onPress: () => setAttachmentsOpen(!attachmentsOpen), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: attachmentsOpen ? 'close' : 'plus', color: c.foreground, size: 25 }) }) }) }) : _jsxs(_Fragment, { children: [renderAttachments(), renderTextComposer()] }), attachmentsOpen && _jsx(AttachmentMenu, { anchor: attachmentButton, accent: c.accent, onClose: () => setAttachmentsOpen(false), actions: attachmentActions }), scheduleOpen && _jsx(ScheduleTimePicker, { initial: scheduleAttempt.current?.time, retryAt: scheduleError ? scheduleAttempt.current?.time : undefined, accent: c.accent, busy: busy, error: scheduleError, onSave: time => void schedule(time), onClose: () => setScheduleOpen(false) }), pollOpen && features?.poll && _jsx(PollComposer, { yesNo: state.conversation?.kind !== 'group', appearance: design, onSend: poll => submit({ kind: 'poll', poll }), onCancel: () => setPollOpen(false) }), error && (_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }))] }));
|
|
970
972
|
}
|
|
971
973
|
/** Account navigation. Mount in the inbox/app toolbar, never in a contact menu. */
|
|
972
|
-
export function MessagingAccountMenu({ onNewGroup, onMaps, onPublicRooms } = {}) {
|
|
974
|
+
export function MessagingAccountMenu({ onNewConversation, onNewGroup, onMaps, onPublicRooms } = {}) {
|
|
973
975
|
const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
|
|
974
976
|
const { client, design, setSavedOpen, account, companyName } = useMessagingUI();
|
|
975
977
|
const [open, setOpen] = useState(false), [accountOpen, setAccountOpen] = useState(false), pending = useRef(null);
|
|
976
978
|
const button = useRef(null), viewport = useWindowDimensions(), [anchor, setAnchor] = useState({ x: 12, y: 12 });
|
|
977
979
|
const width = Math.min(218, viewport.width - 24);
|
|
978
|
-
const show = () => button.current?.measureInWindow((x, y, w, h) => { setAnchor({ x: Math.max(12, Math.min(x + w - width, viewport.width - width - 12)), y: Math.max(12, Math.min(y + h + 4, viewport.height - (client.hasSavedMessages() ? 116 : 72) - (onNewGroup ? 44 : 0) - (onMaps ? 44 : 0) - (onPublicRooms ? 44 : 0))) }); setOpen(true); });
|
|
980
|
+
const show = () => button.current?.measureInWindow((x, y, w, h) => { setAnchor({ x: Math.max(12, Math.min(x + w - width, viewport.width - width - 12)), y: Math.max(12, Math.min(y + h + 4, viewport.height - (client.hasSavedMessages() ? 116 : 72) - (onNewConversation ? 44 : 0) - (onNewGroup ? 44 : 0) - (onMaps ? 44 : 0) - (onPublicRooms ? 44 : 0))) }); setOpen(true); });
|
|
979
981
|
const dismissed = () => { const next = pending.current; if (!next)
|
|
980
|
-
return; pending.current = null; requestAnimationFrame(() => next === 'saved' ? setSavedOpen(true) : next === 'group' ? onNewGroup?.() : next === 'maps' ? onMaps?.() : next === 'rooms' ? onPublicRooms?.() : setAccountOpen(true)); };
|
|
982
|
+
return; pending.current = null; requestAnimationFrame(() => next === 'new-chat' ? onNewConversation?.() : next === 'saved' ? setSavedOpen(true) : next === 'group' ? onNewGroup?.() : next === 'maps' ? onMaps?.() : next === 'rooms' ? onPublicRooms?.() : setAccountOpen(true)); };
|
|
981
983
|
useEffect(() => { if (!open && Platform.OS !== 'ios')
|
|
982
984
|
dismissed(); }, [open]);
|
|
983
985
|
useEffect(() => { if (!open || Platform.OS !== 'web')
|
|
@@ -985,7 +987,7 @@ export function MessagingAccountMenu({ onNewGroup, onMaps, onPublicRooms } = {})
|
|
|
985
987
|
event.stopPropagation();
|
|
986
988
|
setOpen(false);
|
|
987
989
|
} }; document.addEventListener('keydown', key, true); return () => document.removeEventListener('keydown', key, true); }, [open]);
|
|
988
|
-
return _jsxs(_Fragment, { children: [_jsx(Pressable, { ref: button, accessibilityRole: "button", accessibilityLabel: uiText("Messaging menu"), accessibilityState: { expanded: open }, onPress: show, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "more", color: design.colors.foreground, size: 23 }) }), _jsx(MessagingMenuSurface, { visible: open, onClose: () => setOpen(false), onDismiss: dismissed, children: rect => _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: uiText("Close messaging menu"), onPress: () => setOpen(false), style: { position: 'absolute', inset: 0 } }), _jsx(View, { testID: "messaging-account-dropdown", style: { position: 'absolute', left: Math.max(8, Math.min(anchor.x - rect.left, rect.width - Math.min(width, rect.width - 16) - 8)), top: Math.max(8, Math.min(anchor.y - rect.top, rect.height - (client.hasSavedMessages() ? 116 : 72) - (onNewGroup ? 44 : 0) - (onMaps ? 44 : 0) - (onPublicRooms ? 44 : 0) - 8)), width: Math.min(width, rect.width - 16), maxHeight: Math.max(44, rect.height - 16), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006' }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Account"), onPress: () => { pending.current = 'account'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "person-circle-outline", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("Account") })] }), onMaps && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Maps"), onPress: () => { pending.current = 'maps'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "location", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("Maps") })] }), onPublicRooms && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Public groups"), onPress: () => { pending.current = 'rooms'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "people-outline", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("Public groups") })] }), onNewGroup && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("New group"), onPress: () => { pending.current = 'group'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "people-outline", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("New group") })] }), client.hasSavedMessages() && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Saved messages"), onPress: () => { pending.current = 'saved'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "bookmark-outline", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("Saved") })] })] }) })] }) }), accountOpen && _jsx(MessagingAccountScreen, { account: account, companyName: companyName ?? design.name, onClose: () => setAccountOpen(false) })] });
|
|
990
|
+
return _jsxs(_Fragment, { children: [_jsx(Pressable, { ref: button, accessibilityRole: "button", accessibilityLabel: uiText("Messaging menu"), accessibilityState: { expanded: open }, onPress: show, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "more", color: design.colors.foreground, size: 23 }) }), _jsx(MessagingMenuSurface, { visible: open, onClose: () => setOpen(false), onDismiss: dismissed, children: rect => _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: uiText("Close messaging menu"), onPress: () => setOpen(false), style: { position: 'absolute', inset: 0 } }), _jsx(View, { testID: "messaging-account-dropdown", style: { position: 'absolute', left: Math.max(8, Math.min(anchor.x - rect.left, rect.width - Math.min(width, rect.width - 16) - 8)), top: Math.max(8, Math.min(anchor.y - rect.top, rect.height - (client.hasSavedMessages() ? 116 : 72) - (onNewConversation ? 44 : 0) - (onNewGroup ? 44 : 0) - (onMaps ? 44 : 0) - (onPublicRooms ? 44 : 0) - 8)), width: Math.min(width, rect.width - 16), maxHeight: Math.max(44, rect.height - 16), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006' }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Account"), onPress: () => { pending.current = 'account'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "person-circle-outline", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("Account") })] }), onNewConversation && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("New chat"), onPress: () => { pending.current = 'new-chat'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "edit", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("New chat") })] }), onMaps && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Maps"), onPress: () => { pending.current = 'maps'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "location", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("Maps") })] }), onPublicRooms && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Public groups"), onPress: () => { pending.current = 'rooms'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "people-outline", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("Public groups") })] }), onNewGroup && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("New group"), onPress: () => { pending.current = 'group'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "people-outline", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("New group") })] }), client.hasSavedMessages() && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: uiText("Saved messages"), onPress: () => { pending.current = 'saved'; setOpen(false); }, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 10, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: "bookmark-outline", color: "#f5f5f5", size: 22 }), _jsx(Text, { style: { color: '#f5f5f5', fontSize: 15, fontWeight: '600' }, children: uiText("Saved") })] })] }) })] }) }), accountOpen && _jsx(MessagingAccountScreen, { account: account, companyName: companyName ?? design.name, onClose: () => setAccountOpen(false) })] });
|
|
989
991
|
}
|
|
990
992
|
function MessagingWaitingChanges() {
|
|
991
993
|
const { state, client, design } = useMessagingUI(), { text: uiText, count } = useRobonoLocale();
|
|
@@ -33,6 +33,8 @@ export interface MessagingMediaDevice {
|
|
|
33
33
|
/** Ordered local selections. No upload or send occurs in the picker. */
|
|
34
34
|
pickMany?(kind: Exclude<MediaKind, 'voice'>, settings: MessagingSettings, signal: AbortSignal, maximum: number, mixedLibrary?: boolean): Promise<LocalMessagingMedia[]>;
|
|
35
35
|
startRecording(maxDurationMs: number, signal: AbortSignal): Promise<void>;
|
|
36
|
+
/** True only during the operating system's microphone permission handoff. */
|
|
37
|
+
isRequestingRecordingPermission?(): boolean;
|
|
36
38
|
recordingStatus(): {
|
|
37
39
|
durationMs: number;
|
|
38
40
|
recording: boolean;
|
|
@@ -106,10 +108,15 @@ export declare class MessagingMediaController {
|
|
|
106
108
|
finishRecording(interrupted?: boolean): Promise<void>;
|
|
107
109
|
send(): Promise<void>;
|
|
108
110
|
/** View an owned cache copy of a draft. Closing never removes the original draft. */
|
|
109
|
-
previewLocal(file: LocalMessagingMedia, kind: MediaKind): Promise<void>;
|
|
111
|
+
previewLocal(file: LocalMessagingMedia, kind: MediaKind, message?: MessagingMessage): Promise<void>;
|
|
110
112
|
open(message: MessagingMessage): Promise<void>;
|
|
111
113
|
/** Save/share a local copy of a playing stream without replacing its playback source. */
|
|
112
|
-
exportFile<T>(save: (file: LocalMessagingMedia
|
|
114
|
+
exportFile<T>(save: (file: LocalMessagingMedia, context: {
|
|
115
|
+
message: MessagingMessage | null;
|
|
116
|
+
signal: AbortSignal;
|
|
117
|
+
}) => Promise<T>, options?: {
|
|
118
|
+
reauthorize?: boolean;
|
|
119
|
+
}): Promise<T>;
|
|
113
120
|
/** Recover a failed/expired native stream once through fresh authorized download. */
|
|
114
121
|
fallbackStream(uri: string, positionMs: number, autoPlay: boolean): Promise<void>;
|
|
115
122
|
private openMedia;
|
package/dist/media-controller.js
CHANGED
|
@@ -299,7 +299,7 @@ export class MessagingMediaController {
|
|
|
299
299
|
}
|
|
300
300
|
}
|
|
301
301
|
/** View an owned cache copy of a draft. Closing never removes the original draft. */
|
|
302
|
-
async previewLocal(file, kind) {
|
|
302
|
+
async previewLocal(file, kind, message) {
|
|
303
303
|
const intent = ++this.intent;
|
|
304
304
|
await this.closeCurrent();
|
|
305
305
|
if (intent !== this.intent || this.disposed) {
|
|
@@ -307,25 +307,30 @@ export class MessagingMediaController {
|
|
|
307
307
|
return;
|
|
308
308
|
}
|
|
309
309
|
this.conversationId = this.options.getConversationId();
|
|
310
|
+
this.openedMessage = message ?? null;
|
|
311
|
+
this.abort = new AbortController();
|
|
310
312
|
this.update({ phase: 'viewing', kind, file, error: null, progress: 0 });
|
|
311
313
|
}
|
|
312
314
|
open(message) { return this.openMedia(message); }
|
|
313
315
|
/** Save/share a local copy of a playing stream without replacing its playback source. */
|
|
314
|
-
async exportFile(save) {
|
|
315
|
-
const file = this.state.file, generation = this.generation, message = this.openedMessage;
|
|
316
|
+
async exportFile(save, options = {}) {
|
|
317
|
+
const file = this.state.file, generation = this.generation, message = this.openedMessage, conversationId = this.conversationId;
|
|
316
318
|
if (this.disposed || !file || this.state.phase !== 'viewing')
|
|
317
319
|
throw Error('The media viewer is closed.');
|
|
318
|
-
|
|
319
|
-
|
|
320
|
+
const signal = this.abort?.signal;
|
|
321
|
+
if (!signal)
|
|
322
|
+
throw Error('The attachment is unavailable.');
|
|
323
|
+
if (!file.stream && !options.reauthorize)
|
|
324
|
+
return save(file, { message, signal });
|
|
320
325
|
if (!message || !this.abort)
|
|
321
326
|
throw Error('The attachment is unavailable.');
|
|
322
|
-
const copy = await this.options.storage.download(message, { signal: this.abort.signal, onProgress: () => { } });
|
|
327
|
+
const copy = await this.options.storage.download(options.reauthorize ? { ...message, attachmentDownload: undefined } : message, { signal: this.abort.signal, onProgress: () => { } });
|
|
323
328
|
try {
|
|
324
|
-
if (!this.isCurrent(generation) || this.options.getConversationId() !==
|
|
329
|
+
if (!this.isCurrent(generation) || this.options.getConversationId() !== conversationId)
|
|
325
330
|
throw Error('The media viewer is closed.');
|
|
326
331
|
if (copy.stream)
|
|
327
332
|
throw Error('A local file is required to save this attachment.');
|
|
328
|
-
return await save(copy);
|
|
333
|
+
return await save(copy, { message, signal });
|
|
329
334
|
}
|
|
330
335
|
finally {
|
|
331
336
|
await this.options.device.remove(copy).catch(() => { });
|
|
@@ -335,7 +340,7 @@ export class MessagingMediaController {
|
|
|
335
340
|
fallbackStream(uri, positionMs, autoPlay) {
|
|
336
341
|
if (!this.state.file?.stream || this.state.file.uri !== uri || !this.openedMessage)
|
|
337
342
|
return Promise.resolve();
|
|
338
|
-
return this.openMedia(this.openedMessage, { download: true, positionMs, autoPlay });
|
|
343
|
+
return this.openMedia({ ...this.openedMessage, attachmentDownload: undefined }, { download: true, positionMs, autoPlay });
|
|
339
344
|
}
|
|
340
345
|
async openMedia(message, recovery) {
|
|
341
346
|
const intent = ++this.intent;
|
|
@@ -404,7 +409,7 @@ export class MessagingMediaController {
|
|
|
404
409
|
}
|
|
405
410
|
async interrupt() { if (this.state.phase === 'recording')
|
|
406
411
|
await this.finishRecording(true);
|
|
407
|
-
else if (this.state.phase === 'preparing')
|
|
412
|
+
else if (this.state.phase === 'preparing' && !this.options.device.isRequestingRecordingPermission?.())
|
|
408
413
|
await this.cancel(); }
|
|
409
414
|
async dispose() { this.disposed = true; for (const abort of this.preparedAborts.values())
|
|
410
415
|
abort.abort(); this.forwardUploads.clear(); await this.cancel(); await Promise.all([...this.prepared.keys()].map(id => this.releasePrepared(id))); this.listeners.clear(); }
|
package/dist/media-screening.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
|
+
// React Native AbortSignal implementations need not expose throwIfAborted/reason.
|
|
2
|
+
function checkCancelled(signal) {
|
|
3
|
+
if (signal.aborted)
|
|
4
|
+
throw Object.assign(Error('Cancelled'), { name: 'AbortError' });
|
|
5
|
+
}
|
|
1
6
|
/** Wait between short authenticated requests; never occupy a backend request for a video job. */
|
|
2
7
|
export async function awaitMediaScreening(request, signal, onPending, options = {}) {
|
|
3
8
|
const deadline = Date.now() + (options.timeoutMs ?? 600000);
|
|
4
9
|
while (true) {
|
|
5
|
-
signal
|
|
10
|
+
checkCancelled(signal);
|
|
6
11
|
try {
|
|
7
12
|
const value = await request();
|
|
13
|
+
checkCancelled(signal);
|
|
8
14
|
const error = value?.error;
|
|
9
15
|
if (error?.code === 'screening_pending')
|
|
10
16
|
throw Object.assign(Error(error.message), { code: error.code });
|
|
@@ -15,12 +21,12 @@ export async function awaitMediaScreening(request, signal, onPending, options =
|
|
|
15
21
|
catch (e) {
|
|
16
22
|
if (e.code !== 'screening_pending')
|
|
17
23
|
throw e;
|
|
18
|
-
signal
|
|
24
|
+
checkCancelled(signal);
|
|
19
25
|
onPending?.();
|
|
20
26
|
if (Date.now() >= deadline)
|
|
21
27
|
throw Object.assign(Error('This attachment is still being checked. Retry to check its status; it has not been sent.'), { code: 'screening_pending', retryable: true });
|
|
22
28
|
await new Promise((resolve, reject) => {
|
|
23
|
-
const cancel = () => { clearTimeout(timer); signal.removeEventListener('abort', cancel); reject(Error('Cancelled')); };
|
|
29
|
+
const cancel = () => { clearTimeout(timer); signal.removeEventListener('abort', cancel); reject(Object.assign(Error('Cancelled'), { name: 'AbortError' })); };
|
|
24
30
|
const timer = setTimeout(() => { signal.removeEventListener('abort', cancel); resolve(); }, options.pollMs ?? 3000);
|
|
25
31
|
signal.addEventListener('abort', cancel, { once: true });
|
|
26
32
|
if (signal.aborted)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
type Preview = {
|
|
2
|
+
uri?: string;
|
|
3
|
+
waveform?: number[];
|
|
4
|
+
};
|
|
5
|
+
/** Memory-only, bounded previews. Call clear on identity/permission boundaries.
|
|
6
|
+
* Different images can progress independently without unbounded native work. */
|
|
7
|
+
export declare class MessagingPreviewCache {
|
|
8
|
+
private options;
|
|
9
|
+
private now;
|
|
10
|
+
private entries;
|
|
11
|
+
private queue;
|
|
12
|
+
private running;
|
|
13
|
+
constructor(options?: {
|
|
14
|
+
concurrency: number;
|
|
15
|
+
maximum: number;
|
|
16
|
+
ttlMs: number;
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
}, now?: () => number);
|
|
19
|
+
get(key: string, load: (signal: AbortSignal) => Promise<Preview>): Promise<Preview>;
|
|
20
|
+
clear(): void;
|
|
21
|
+
private drop;
|
|
22
|
+
private pump;
|
|
23
|
+
}
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Memory-only, bounded previews. Call clear on identity/permission boundaries.
|
|
2
|
+
* Different images can progress independently without unbounded native work. */
|
|
3
|
+
export class MessagingPreviewCache {
|
|
4
|
+
options;
|
|
5
|
+
now;
|
|
6
|
+
entries = new Map();
|
|
7
|
+
queue = [];
|
|
8
|
+
running = 0;
|
|
9
|
+
constructor(options = { concurrency: 2, maximum: 60, ttlMs: 60000, timeoutMs: 15000 }, now = Date.now) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.now = now;
|
|
12
|
+
}
|
|
13
|
+
get(key, load) {
|
|
14
|
+
const previous = this.entries.get(key);
|
|
15
|
+
if (previous && previous.expires > this.now()) {
|
|
16
|
+
this.entries.delete(key);
|
|
17
|
+
this.entries.set(key, previous);
|
|
18
|
+
return previous.promise;
|
|
19
|
+
}
|
|
20
|
+
if (previous)
|
|
21
|
+
this.drop(previous);
|
|
22
|
+
let resolve;
|
|
23
|
+
const promise = new Promise(r => resolve = r);
|
|
24
|
+
const entry = { key, abort: new AbortController(), load, promise, resolve, expires: Infinity, started: false };
|
|
25
|
+
this.entries.set(key, entry);
|
|
26
|
+
this.queue.push(entry);
|
|
27
|
+
while (this.entries.size > this.options.maximum)
|
|
28
|
+
this.drop(this.entries.values().next().value);
|
|
29
|
+
this.pump();
|
|
30
|
+
return promise;
|
|
31
|
+
}
|
|
32
|
+
clear() { for (const entry of this.entries.values())
|
|
33
|
+
this.drop(entry); this.queue = []; }
|
|
34
|
+
drop(entry) { if (this.entries.get(entry.key) === entry)
|
|
35
|
+
this.entries.delete(entry.key); entry.abort.abort(); entry.resolve({}); }
|
|
36
|
+
pump() {
|
|
37
|
+
while (this.running < this.options.concurrency && this.queue.length) {
|
|
38
|
+
const entry = this.queue.shift();
|
|
39
|
+
if (entry.abort.signal.aborted)
|
|
40
|
+
continue;
|
|
41
|
+
this.running++;
|
|
42
|
+
entry.started = true;
|
|
43
|
+
// Some native thumbnail operations cannot be cancelled once started. Release
|
|
44
|
+
// the queue slot on cancellation/deadline and ignore their eventual result.
|
|
45
|
+
// Otherwise two stalled decoders would stop the entire conversation forever.
|
|
46
|
+
let onAbort;
|
|
47
|
+
const cancelled = new Promise(resolve => { onAbort = () => resolve({}); entry.abort.signal.addEventListener('abort', onAbort, { once: true }); });
|
|
48
|
+
const timer = setTimeout(() => this.drop(entry), this.options.timeoutMs);
|
|
49
|
+
void Promise.race([Promise.resolve().then(() => entry.abort.signal.aborted ? {} : entry.load(entry.abort.signal)), cancelled]).then(value => {
|
|
50
|
+
if (entry.abort.signal.aborted || this.entries.get(entry.key) !== entry)
|
|
51
|
+
return;
|
|
52
|
+
entry.expires = this.now() + this.options.ttlMs;
|
|
53
|
+
entry.resolve(value);
|
|
54
|
+
if (!value.uri && !value.waveform)
|
|
55
|
+
this.entries.delete(entry.key);
|
|
56
|
+
}, () => this.drop(entry)).finally(() => { clearTimeout(timer); entry.abort.signal.removeEventListener('abort', onAbort); this.running--; this.pump(); });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type Permission = {
|
|
2
|
+
granted: boolean;
|
|
3
|
+
};
|
|
4
|
+
/** Permission UI may temporarily background the app. Capture still requires a
|
|
5
|
+
* foreground app after the prompt closes; it never starts while suspended. */
|
|
6
|
+
export declare function prepareRecordingPermission(options: {
|
|
7
|
+
read: () => Promise<Permission>;
|
|
8
|
+
request: () => Promise<Permission>;
|
|
9
|
+
state: () => string | null;
|
|
10
|
+
subscribe: (changed: () => void) => () => void;
|
|
11
|
+
}, signal: AbortSignal): Promise<void>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Permission UI may temporarily background the app. Capture still requires a
|
|
2
|
+
* foreground app after the prompt closes; it never starts while suspended. */
|
|
3
|
+
export async function prepareRecordingPermission(options, signal) {
|
|
4
|
+
const check = () => { if (signal.aborted)
|
|
5
|
+
throw Object.assign(Error('Cancelled.'), { name: 'AbortError' }); };
|
|
6
|
+
check();
|
|
7
|
+
if (options.state() !== 'active')
|
|
8
|
+
throw Error('Return to the conversation to record a voice message.');
|
|
9
|
+
let permission = await options.read();
|
|
10
|
+
check();
|
|
11
|
+
if (!permission.granted) {
|
|
12
|
+
permission = await options.request();
|
|
13
|
+
check();
|
|
14
|
+
}
|
|
15
|
+
if (!permission.granted)
|
|
16
|
+
throw Error('Microphone permission is needed to record a voice message.');
|
|
17
|
+
if (options.state() === 'active')
|
|
18
|
+
return;
|
|
19
|
+
await new Promise((resolve, reject) => {
|
|
20
|
+
let remove = () => { };
|
|
21
|
+
const finish = (error) => { clearTimeout(timer); remove(); signal.removeEventListener('abort', cancel); error ? reject(error) : resolve(); };
|
|
22
|
+
const cancel = () => finish(Object.assign(Error('Cancelled.'), { name: 'AbortError' }));
|
|
23
|
+
const changed = () => { if (options.state() === 'active')
|
|
24
|
+
finish(); };
|
|
25
|
+
const timer = setTimeout(() => finish(Error('Return to the conversation and try recording again.')), 2000);
|
|
26
|
+
remove = options.subscribe(changed);
|
|
27
|
+
signal.addEventListener('abort', cancel, { once: true });
|
|
28
|
+
if (signal.aborted)
|
|
29
|
+
cancel();
|
|
30
|
+
else
|
|
31
|
+
changed();
|
|
32
|
+
});
|
|
33
|
+
check();
|
|
34
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robono/messaging-react-native",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Optional, composable direct and group messaging screens for Robono.",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"prepack": "npm run build && node ../../scripts/generate-sdk-file-sbom.mjs"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
|
-
"@robono/messaging": "^0.
|
|
58
|
+
"@robono/messaging": "^0.8.0",
|
|
59
59
|
"react": ">=18.2 <20",
|
|
60
60
|
"react-native": ">=0.76 <1",
|
|
61
61
|
"expo-audio": ">=55 <57",
|
|
@@ -75,12 +75,12 @@
|
|
|
75
75
|
"expo-blur": ">=55 <57",
|
|
76
76
|
"expo-notifications": ">=55 <57",
|
|
77
77
|
"expo-secure-store": ">=55 <57",
|
|
78
|
-
"@robono/location-react-native": "^0.4.
|
|
78
|
+
"@robono/location-react-native": "^0.4.1",
|
|
79
79
|
"expo-location": ">=55 <57",
|
|
80
80
|
"expo-constants": ">=55 <57",
|
|
81
81
|
"@robono/location": "^0.4.0",
|
|
82
82
|
"expo-haptics": ">=55 <57",
|
|
83
|
-
"@robono/calling": "^0.6.
|
|
83
|
+
"@robono/calling": "^0.6.1"
|
|
84
84
|
},
|
|
85
85
|
"license": "SEE LICENSE IN LICENSE",
|
|
86
86
|
"peerDependenciesMeta": {
|