@robono/messaging-react-native 0.11.1 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { MessagingRoomModeration, MessagingRoomRemoval } from './room-moderation.js';
3
+ export { MessagingConnectionState, MessagingSignIn, MessagingPhoneInput, MessagingChoicePicker } from './sign-in.js';
3
4
  import { CustomMessage } from './custom-message.js';
4
5
  import { MessagingTypography } from './typography.js';
5
6
  import { Text, TextInput } from './typography.js';
@@ -349,11 +350,13 @@ function MessageMetadata({ mine, ink, pendingStatus }) {
349
350
  }
350
351
  export function MessagingBubble({ message, pendingStatus, previousMessage, nextMessage }) {
351
352
  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;
353
+ 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();
354
+ const deliveryStatus = pendingStatus ?? (message.senderId === userId && message.delivery?.state === 'pending' ? 'sending' : message.senderId === userId && message.delivery?.state === 'failed' ? 'failed' : undefined);
355
+ const candidateExtra = !message.custom && !pendingStatus && !message.deleted ? extrasState?.messages[message.id] : undefined;
356
+ const extra = candidateExtra?.address && (linkHandling?.addresses === false || linkHandling?.openAddress) ? undefined : candidateExtra;
354
357
  const extraRef = useRef(null), socialRef = useRef(null);
355
358
  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));
359
+ 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
360
  const contextContactName = contact.name;
358
361
  const mine = message.senderId === userId, c = design.colors, ink = message.kind === 'poll' ? c.foreground : mine ? c.outgoingText : c.incomingText;
359
362
  const bubbleRadius = design.presentation?.bubbleShape === 'square' ? 2 : design.presentation?.bubbleShape === 'rounded' ? 16 : 8;
@@ -443,14 +446,14 @@ export function MessagingBubble({ message, pendingStatus, previousMessage, nextM
443
446
  setBusy(false);
444
447
  }
445
448
  }
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); };
449
+ 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);
450
+ 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
451
  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
452
  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
453
  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
454
  const canCopy = !!message.text && (!!copyText || Platform.OS === 'web');
452
455
  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 });
456
+ const footer = _jsx(MessageMetadata, { message: message, mine: mine, ink: visual ? '#ffffff' : ink, pendingStatus: deliveryStatus });
454
457
  const bigEmoji = message.kind === 'text' && !message.replyTo && !extra && !canTranslate && !transformed && isBigEmojiText(message.text);
455
458
  const showingTransform = !!transformed && !showOriginal && (transformed.kind === 'transcript' || canTranslate);
456
459
  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 +462,11 @@ export function MessagingBubble({ message, pendingStatus, previousMessage, nextM
459
462
  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
463
  const regionProps = { message, mine, appearance: design, pendingStatus };
461
464
  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())
465
+ 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
466
  return; if (extra?.address)
464
467
  extraRef.current?.open();
465
468
  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) })] });
469
+ 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
470
  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
471
  quoteGesture.webRef.current = node; }, ...quoteGesture.handlers, accessibilityActions: quoting ? [{ name: "quote", label: uiText("Quote message") }] : undefined, onAccessibilityAction: event => { if (quoting && event.nativeEvent.actionName === "quote")
469
472
  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 +972,15 @@ export function MessagingComposer() {
969
972
  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
973
  }
971
974
  /** Account navigation. Mount in the inbox/app toolbar, never in a contact menu. */
972
- export function MessagingAccountMenu({ onNewGroup, onMaps, onPublicRooms } = {}) {
975
+ export function MessagingAccountMenu({ onNewConversation, onNewGroup, onMaps, onPublicRooms } = {}) {
973
976
  const { text: uiText, locale: uiLocale, number: uiNumber, count: uiCount } = useRobonoLocale();
974
977
  const { client, design, setSavedOpen, account, companyName } = useMessagingUI();
975
978
  const [open, setOpen] = useState(false), [accountOpen, setAccountOpen] = useState(false), pending = useRef(null);
976
979
  const button = useRef(null), viewport = useWindowDimensions(), [anchor, setAnchor] = useState({ x: 12, y: 12 });
977
980
  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); });
981
+ 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
982
  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)); };
983
+ 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
984
  useEffect(() => { if (!open && Platform.OS !== 'ios')
982
985
  dismissed(); }, [open]);
983
986
  useEffect(() => { if (!open || Platform.OS !== 'web')
@@ -985,7 +988,7 @@ export function MessagingAccountMenu({ onNewGroup, onMaps, onPublicRooms } = {})
985
988
  event.stopPropagation();
986
989
  setOpen(false);
987
990
  } }; 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) })] });
991
+ 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
992
  }
990
993
  function MessagingWaitingChanges() {
991
994
  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
+ }
@@ -0,0 +1,76 @@
1
+ import React, { type ReactNode } from 'react';
2
+ import { type MessagingAppearance } from '@robono/messaging';
3
+ /** Optional presentation only. The customer's existing identity provider owns all verification. */
4
+ export interface MessagingSignInProps {
5
+ appearance?: MessagingAppearance;
6
+ logo?: ReactNode;
7
+ stage: 'phone' | 'code' | 'profile';
8
+ /** Country selector and phone input supplied by the host, using MessagingPhoneInput if desired. */
9
+ phoneField: ReactNode;
10
+ code: string;
11
+ onCodeChange: (code: string) => void;
12
+ name: string;
13
+ onNameChange: (name: string) => void;
14
+ profileFields?: ReactNode;
15
+ consent: {
16
+ checked: boolean;
17
+ onChange: (checked: boolean) => void;
18
+ title: string;
19
+ details: ReactNode;
20
+ };
21
+ busy?: boolean;
22
+ disabled?: boolean;
23
+ error?: string;
24
+ /** Remaining lifetime reported by the identity provider. Never invent a verification deadline. */
25
+ secondsRemaining?: number;
26
+ onSubmit: () => void;
27
+ onChangePhone: () => void;
28
+ labels?: Partial<Record<'phoneSubtitle' | 'codeSubtitle' | 'profileSubtitle' | 'phone' | 'name' | 'namePlaceholder' | 'continue' | 'verify' | 'sendAgain' | 'busy' | 'changePhone' | 'code' | 'expired', string>>;
29
+ }
30
+ /** Original Messenger's card, typography, consent row and code cells, shared by web and native. */
31
+ export declare function MessagingSignIn(p: MessagingSignInProps): React.JSX.Element;
32
+ export interface MessagingPhoneInputProps {
33
+ value: string;
34
+ onChange: (value: string) => void;
35
+ countryName: string;
36
+ callingCode: string;
37
+ flag: string;
38
+ onChooseCountry: () => void;
39
+ disabled?: boolean;
40
+ placeholder?: string;
41
+ label?: string;
42
+ appearance?: MessagingAppearance;
43
+ }
44
+ /** Same field proportions and divider as Messenger, with host-owned country data. */
45
+ export declare function MessagingPhoneInput(p: MessagingPhoneInputProps): React.JSX.Element;
46
+ export interface MessagingChoice {
47
+ value: string;
48
+ label: string;
49
+ detail?: string;
50
+ symbol?: string;
51
+ }
52
+ export interface MessagingChoicePickerProps {
53
+ visible: boolean;
54
+ title: string;
55
+ closeLabel?: string;
56
+ choices: ReadonlyArray<MessagingChoice>;
57
+ selected: string;
58
+ onSelect: (value: string) => void;
59
+ onClose: () => void;
60
+ appearance?: MessagingAppearance;
61
+ }
62
+ /** The same compact choice list for customer sign-in, language and country fields. */
63
+ export declare function MessagingChoicePicker(p: MessagingChoicePickerProps): React.JSX.Element;
64
+ /** The same connection/recovery screen for customer websites and phone apps. */
65
+ export interface MessagingConnectionStateProps {
66
+ appearance?: MessagingAppearance;
67
+ error?: string;
68
+ onRetry?: () => void;
69
+ onSignOut?: () => void;
70
+ labels?: {
71
+ loading?: string;
72
+ retry?: string;
73
+ signOut?: string;
74
+ };
75
+ }
76
+ export declare function MessagingConnectionState({ appearance, error, onRetry, onSignOut, labels }: MessagingConnectionStateProps): React.JSX.Element;
@@ -0,0 +1,46 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { ActivityIndicator, FlatList, Image, Keyboard, KeyboardAvoidingView, Platform, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
4
+ import { defaultMessagingSettings } from '@robono/messaging';
5
+ import { RobonoIcon } from './visual/RobonoIcon.js';
6
+ import { MessagingMenuSurface, MessagingPopupHost } from './popup.js';
7
+ function surfaces(background) {
8
+ const hex = /^#[\da-f]{6}$/i.test(background) ? background : '#050505';
9
+ const rgb = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16));
10
+ const sign = rgb.reduce((a, b) => a + b, 0) > 384 ? -1 : 1;
11
+ const offset = (n) => '#' + rgb.map(v => Math.max(0, Math.min(255, v + sign * n)).toString(16).padStart(2, '0')).join('');
12
+ return { card: offset(12), field: offset(24), border: offset(37) };
13
+ }
14
+ /** Original Messenger's card, typography, consent row and code cells, shared by web and native. */
15
+ export function MessagingSignIn(p) {
16
+ const a = p.appearance ?? defaultMessagingSettings().appearance, c = a.colors, surface = surfaces(c.background);
17
+ const labels = { phoneSubtitle: 'Enter your mobile number to continue.', codeSubtitle: 'Enter the 6-digit code.', profileSubtitle: 'Finish setting up your account.', phone: 'Mobile number', name: 'Name', namePlaceholder: 'Your name', continue: 'Continue', verify: 'Verify', sendAgain: 'Send a new code', busy: 'Checking...', changePhone: 'Use a different number', code: 'Verification code', expired: 'Code expired', ...p.labels };
18
+ const input = useRef(null), [focused, setFocused] = useState(false);
19
+ const expired = p.secondsRemaining !== undefined && p.secondsRemaining <= 0;
20
+ const disabled = !!p.busy || !!p.disabled || (p.stage === 'phone' && !p.consent.checked) || (p.stage === 'code' && !expired && p.code.length !== 6) || (p.stage === 'profile' && !p.name.trim());
21
+ const label = p.busy ? labels.busy : p.stage === 'code' ? (expired ? labels.sendAgain : labels.verify) : labels.continue;
22
+ return _jsx(MessagingPopupHost, { children: _jsx(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: { flex: 1, minHeight: 0, backgroundColor: c.background }, children: _jsx(ScrollView, { keyboardShouldPersistTaps: "handled", contentContainerStyle: s.page, children: _jsxs(Pressable, { accessible: false, onPress: Keyboard.dismiss, style: [s.card, { backgroundColor: surface.card, borderColor: surface.border }], children: [p.logo ? _jsx(View, { style: s.logo, children: p.logo }) : a.logoUrl ? _jsx(Image, { accessibilityLabel: a.name, source: { uri: a.logoUrl }, style: s.logo, resizeMode: "contain" }) : _jsx(Text, { style: [s.brand, { color: c.foreground }], children: a.name }), _jsx(Text, { style: { color: c.secondary, fontSize: 16 }, children: p.stage === 'phone' ? labels.phoneSubtitle : p.stage === 'code' ? labels.codeSubtitle : labels.profileSubtitle }), p.stage === 'phone' ? _jsxs(_Fragment, { children: [_jsx(Text, { style: [s.label, { color: c.foreground }], children: labels.phone }), p.phoneField, _jsxs(View, { style: s.consentRow, children: [_jsx(Pressable, { accessibilityRole: "checkbox", accessibilityLabel: p.consent.title, accessibilityState: { checked: p.consent.checked, disabled: !!p.busy }, disabled: p.busy, hitSlop: 8, onPress: () => { Keyboard.dismiss(); p.consent.onChange(!p.consent.checked); }, style: [s.checkbox, { borderColor: c.accent, backgroundColor: p.consent.checked ? c.accent : 'transparent' }], children: p.consent.checked && _jsx(RobonoIcon, { name: "checkmark", size: 17, color: c.background }) }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 4 }, children: [_jsx(Text, { style: [s.consentTitle, { color: c.foreground }], children: p.consent.title }), _jsx(Text, { style: [s.consentDetails, { color: c.secondary }], children: p.consent.details })] })] })] }) : p.stage === 'profile' ? _jsxs(_Fragment, { children: [_jsx(Text, { style: [s.label, { color: c.foreground }], children: labels.name }), _jsx(TextInput, { accessibilityLabel: labels.name, value: p.name, onChangeText: p.onNameChange, editable: !p.busy, maxLength: 80, autoCapitalize: "words", textContentType: "name", placeholder: labels.namePlaceholder, placeholderTextColor: c.secondary, style: [s.input, { color: c.foreground, backgroundColor: surface.field, borderColor: surface.border }] }), p.profileFields] }) : _jsxs(_Fragment, { children: [_jsxs(Pressable, { accessible: false, onPress: () => input.current?.focus(), style: { minHeight: 58, position: 'relative' }, children: [_jsx(View, { accessible: false, style: s.codeRow, children: Array.from({ length: 6 }, (_, i) => _jsx(View, { style: [s.codeCell, { backgroundColor: surface.field, borderColor: focused && i === Math.min(p.code.length, 5) ? c.accent : p.code[i] ? c.secondary : surface.border, borderWidth: focused && i === Math.min(p.code.length, 5) ? 2 : 1 }], children: _jsx(Text, { style: { fontSize: 28, fontWeight: '700', color: c.foreground }, children: p.code[i] ?? '' }) }, i)) }), _jsx(TextInput, { ref: input, accessibilityLabel: labels.code, value: p.code, onChangeText: v => p.onCodeChange(v.replace(/\D/g, '').slice(0, 6)), onFocus: () => setFocused(true), onBlur: () => setFocused(false), editable: !p.busy && !expired, keyboardType: "number-pad", autoComplete: "sms-otp", textContentType: "oneTimeCode", maxLength: 6, caretHidden: true, style: s.hiddenInput })] }), p.secondsRemaining !== undefined && _jsxs(View, { style: s.countdown, children: [_jsx(RobonoIcon, { name: "time-outline", size: 17, color: c.secondary }), _jsx(Text, { accessibilityLiveRegion: expired ? 'polite' : 'none', style: { color: expired ? '#EF4444' : c.secondary, fontSize: 15, fontWeight: '700', fontVariant: ['tabular-nums'] }, children: expired ? labels.expired : Math.floor(p.secondsRemaining / 60) + ':' + String(p.secondsRemaining % 60).padStart(2, '0') })] })] }), !!p.error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#EF4444', fontSize: 14, lineHeight: 19 }, children: p.error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label, accessibilityState: { disabled, busy: !!p.busy }, disabled: disabled, onPress: p.onSubmit, style: [s.button, { backgroundColor: c.accent, opacity: disabled ? .45 : 1 }], children: _jsx(Text, { style: { color: c.background, fontWeight: '800' }, children: label }) }), p.stage !== 'phone' && _jsx(Pressable, { disabled: p.busy, accessibilityRole: "button", onPress: p.onChangePhone, children: _jsx(Text, { style: { color: c.accent, fontWeight: '600', textAlign: 'center', paddingVertical: 12 }, children: labels.changePhone }) })] }) }) }) });
23
+ }
24
+ /** Same field proportions and divider as Messenger, with host-owned country data. */
25
+ export function MessagingPhoneInput(p) { const c = (p.appearance ?? defaultMessagingSettings().appearance).colors, surface = surfaces(c.background); return _jsxs(View, { style: [s.phoneRow, { backgroundColor: surface.field, borderColor: surface.border }], children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Country, ' + p.countryName, disabled: p.disabled, hitSlop: 4, onPress: () => { Keyboard.dismiss(); p.onChooseCountry(); }, style: s.country, children: [_jsx(Text, { style: { fontSize: 19 }, children: p.flag }), _jsx(Text, { style: { fontSize: 17, fontWeight: '700', color: c.foreground }, children: p.callingCode }), _jsx(RobonoIcon, { name: "chevron-down", size: 16, color: c.secondary })] }), _jsx(View, { style: [s.divider, { backgroundColor: surface.border }] }), _jsx(TextInput, { accessibilityLabel: p.label ?? 'Mobile number', value: p.value, onChangeText: p.onChange, editable: !p.disabled, keyboardType: "phone-pad", autoComplete: "tel-national", textContentType: "telephoneNumber", maxLength: 20, placeholder: p.placeholder, placeholderTextColor: c.secondary, style: { flex: 1, minHeight: 52, fontSize: 17, color: c.foreground } })] }); }
26
+ /** The same compact choice list for customer sign-in, language and country fields. */
27
+ export function MessagingChoicePicker(p) {
28
+ const c = (p.appearance ?? defaultMessagingSettings().appearance).colors, surface = surfaces(c.background);
29
+ return _jsx(MessagingMenuSurface, { visible: p.visible, onClose: p.onClose, children: () => _jsx(View, { style: { flex: 1, justifyContent: 'center', padding: 16, backgroundColor: 'rgba(0,0,0,.72)' }, children: _jsxs(View, { accessibilityViewIsModal: true, style: { maxHeight: '82%', width: '100%', maxWidth: 480, alignSelf: 'center', borderRadius: 8, borderWidth: 1, borderColor: surface.border, backgroundColor: c.background, overflow: 'hidden' }, children: [_jsxs(View, { style: { minHeight: 58, paddingStart: 16, paddingEnd: 58, justifyContent: 'center', borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: surface.border, backgroundColor: surface.card }, children: [_jsx(Text, { accessibilityRole: "header", style: { color: c.foreground, fontSize: 18, fontWeight: '800' }, children: p.title }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: p.closeLabel ?? 'Close ' + p.title, onPress: p.onClose, style: { position: 'absolute', top: 0, end: 0, width: 58, height: 58, alignItems: 'center', justifyContent: 'center' }, children: _jsx(RobonoIcon, { name: "close", size: 22, color: c.foreground }) })] }), _jsx(FlatList, { data: p.choices, keyExtractor: row => row.value, keyboardShouldPersistTaps: "handled", contentContainerStyle: { padding: 8 }, renderItem: ({ item }) => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: item.label + (item.detail ? ', ' + item.detail : ''), accessibilityState: { selected: p.selected === item.value }, onPress: () => p.onSelect(item.value), style: { minHeight: 58, borderRadius: 8, flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 8, backgroundColor: p.selected === item.value ? surface.field : 'transparent' }, children: [item.symbol && _jsx(Text, { style: { width: 31, fontSize: 21 }, children: item.symbol }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { style: { color: c.foreground, fontSize: 16, fontWeight: '800' }, children: item.label }), item.detail && _jsx(Text, { style: { color: c.secondary, fontSize: 13, marginTop: 2 }, children: item.detail })] }), p.selected === item.value && _jsx(RobonoIcon, { name: "checkmark", size: 22, color: c.accent })] }) })] }) }) });
30
+ }
31
+ // Measurements copied from src/AppRoot.tsx authCard/phoneInput/codeCell/smsConsent styles.
32
+ // Keep behavior and presentation here: the consumer app must not fork these screens.
33
+ const s = StyleSheet.create({
34
+ page: { flexGrow: 1, justifyContent: 'center', padding: 24 }, card: { width: '100%', maxWidth: 480, alignSelf: 'center', borderRadius: 8, padding: 24, gap: 12, borderWidth: 1 },
35
+ logo: { width: 168, height: 48, alignSelf: 'center', marginBottom: 4 }, brand: { fontSize: 24, fontWeight: '800', textAlign: 'center', marginBottom: 4 },
36
+ label: { fontSize: 14, fontWeight: '700', marginBottom: -8 }, input: { minHeight: 52, borderWidth: 1, borderColor: '#2A2A2A', borderRadius: 8, paddingHorizontal: 16, fontSize: 17, backgroundColor: '#1D1D1D' },
37
+ phoneRow: { minHeight: 52, borderWidth: 1, borderColor: '#2A2A2A', borderRadius: 8, paddingHorizontal: 12, backgroundColor: '#1D1D1D', flexDirection: 'row', alignItems: 'center', direction: 'ltr' },
38
+ country: { minHeight: 50, flexDirection: 'row', alignItems: 'center', gap: 5, paddingRight: 8 }, divider: { width: StyleSheet.hairlineWidth, height: 28, marginRight: 12, backgroundColor: '#2A2A2A' },
39
+ consentRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 8 }, checkbox: { width: 28, height: 28, borderWidth: 2, borderRadius: 6, alignItems: 'center', justifyContent: 'center', marginTop: 2 }, consentTitle: { fontSize: 14, fontWeight: '800', lineHeight: 19 }, consentDetails: { fontSize: 12, lineHeight: 17 },
40
+ codeRow: { minHeight: 58, flexDirection: 'row', justifyContent: 'center', gap: 8, direction: 'ltr' }, codeCell: { flex: 1, maxWidth: 52, minHeight: 58, borderRadius: 8, alignItems: 'center', justifyContent: 'center', backgroundColor: '#1D1D1D' }, hiddenInput: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0, zIndex: 2, color: 'transparent', backgroundColor: 'transparent', opacity: .01, fontSize: 1 },
41
+ countdown: { alignSelf: 'center', flexDirection: 'row', alignItems: 'center', gap: 6, direction: 'ltr' }, button: { minHeight: 48, borderRadius: 8, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 16 },
42
+ });
43
+ export function MessagingConnectionState({ appearance, error, onRetry, onSignOut, labels }) {
44
+ const colors = (appearance ?? defaultMessagingSettings().appearance).colors;
45
+ return _jsxs(View, { style: { flex: 1, minHeight: 160, backgroundColor: colors.background, alignItems: 'center', justifyContent: 'center', padding: 24, gap: 16 }, children: [!error && _jsx(ActivityIndicator, { color: colors.accent }), _jsx(Text, { accessibilityRole: error ? 'alert' : undefined, accessibilityLiveRegion: "polite", style: { color: colors.foreground, fontSize: 17, lineHeight: 25, textAlign: 'center', maxWidth: 400 }, children: error || labels?.loading || 'Opening Messenger…' }), error && onRetry && _jsx(Pressable, { accessibilityRole: "button", onPress: onRetry, style: { minHeight: 48, minWidth: 180, paddingHorizontal: 24, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.accent, borderRadius: 8 }, children: _jsx(Text, { style: { color: colors.background, fontSize: 16, fontWeight: '700' }, children: labels?.retry || 'Try again' }) }), error && onSignOut && _jsx(Pressable, { accessibilityRole: "button", onPress: onSignOut, style: { minHeight: 44, justifyContent: 'center', paddingHorizontal: 16 }, children: _jsx(Text, { style: { color: colors.accent, fontSize: 16 }, children: labels?.signOut || 'Sign out' }) })] });
46
+ }