@robono/messaging-react-native 0.11.0 → 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/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 extra = !message.custom && !pendingStatus && !message.deleted ? extrasState?.messages[message.id] : undefined;
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: 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 || !pendingStatus) && _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())
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") }) })] }), pendingStatus === '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) })] });
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) => Promise<T>): Promise<T>;
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;
@@ -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
- if (!file.stream)
319
- return save(file);
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() !== message.conversationId)
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(); }
@@ -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.throwIfAborted();
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.throwIfAborted();
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.11.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.7.1",
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.0",
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.0"
83
+ "@robono/calling": "^0.6.1"
84
84
  },
85
85
  "license": "SEE LICENSE IN LICENSE",
86
86
  "peerDependenciesMeta": {
@@ -108,9 +108,6 @@
108
108
  "react-native-safe-area-context": {
109
109
  "optional": true
110
110
  },
111
- "@robono/calling-react-native": {
112
- "optional": true
113
- },
114
111
  "expo-clipboard": {
115
112
  "optional": true
116
113
  },