@robono/messaging-react-native 0.0.0-stage → 0.1.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +130 -0
  3. package/README.md +165 -2
  4. package/SBOM.spdx.json +1127 -0
  5. package/app.plugin.cjs +5 -0
  6. package/dist/call-message.d.ts +6 -0
  7. package/dist/call-message.js +109 -0
  8. package/dist/calling.d.ts +15 -0
  9. package/dist/calling.js +22 -0
  10. package/dist/expo-calling.d.ts +6 -0
  11. package/dist/expo-calling.js +7 -0
  12. package/dist/expo-media.d.ts +19 -0
  13. package/dist/expo-media.js +388 -0
  14. package/dist/expo.d.ts +36 -0
  15. package/dist/expo.js +429 -0
  16. package/dist/icons.d.ts +7 -0
  17. package/dist/icons.js +10 -0
  18. package/dist/index.d.ts +101 -0
  19. package/dist/index.js +433 -0
  20. package/dist/media-controller.d.ts +98 -0
  21. package/dist/media-controller.js +257 -0
  22. package/dist/media-readiness.d.ts +4 -0
  23. package/dist/media-readiness.js +4 -0
  24. package/dist/media-ui.d.ts +51 -0
  25. package/dist/media-ui.js +42 -0
  26. package/dist/polls.d.ts +21 -0
  27. package/dist/polls.js +54 -0
  28. package/dist/recent-media.d.ts +21 -0
  29. package/dist/recent-media.js +122 -0
  30. package/dist/safety.d.ts +11 -0
  31. package/dist/safety.js +47 -0
  32. package/dist/use-quote-gesture.d.ts +24 -0
  33. package/dist/use-quote-gesture.js +127 -0
  34. package/dist/visual/RobonoIcon.d.ts +232 -0
  35. package/dist/visual/RobonoIcon.js +20 -0
  36. package/dist/visual/icon-nodes.d.ts +3 -0
  37. package/dist/visual/icon-nodes.js +2 -0
  38. package/dist/visual/icon-paths.d.ts +221 -0
  39. package/dist/visual/icon-paths.js +221 -0
  40. package/dist/voice-composer.d.ts +37 -0
  41. package/dist/voice-composer.js +20 -0
  42. package/licenses/robono-icons-lucide-license.txt +43 -0
  43. package/licenses/robono-icons-weather-attribution.txt +1 -0
  44. package/licenses/robono-icons-weather-license.txt +21 -0
  45. package/package.json +110 -4
  46. package/plugin/preserve-call-audio.cjs +22 -0
package/dist/index.js ADDED
@@ -0,0 +1,433 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { MessagingSafety } from './safety.js';
3
+ export { MessagingSafety } from './safety.js';
4
+ import { PollMessage, PollComposer } from "./polls.js";
5
+ export { PollMessage, PollComposer } from "./polls.js";
6
+ import { createContext, useContext, useEffect, useRef, useState, useSyncExternalStore, } from "react";
7
+ import { Animated, AppState, BackHandler, FlatList, Image, KeyboardAvoidingView, Keyboard, Modal, Platform, Pressable, ScrollView, Text, TextInput, View, useWindowDimensions, } from "react-native";
8
+ import { useQuoteGesture } from "./use-quote-gesture.js";
9
+ import { MessageIcon, duration, fileSize, chatSurface } from "./icons.js";
10
+ import { VoiceMessageComposer } from "./voice-composer.js";
11
+ export { VoiceMessageComposer, VoiceDraftPlayback } from "./voice-composer.js";
12
+ import { VoicePlayback } from "./media-ui.js";
13
+ export { VoicePlayback, MediaViewer, MediaPlaybackControls, ZoomablePhoto } from "./media-ui.js";
14
+ import { defaultMessagingSettings, defaultAttachmentLimits, messageReactions, } from "@robono/messaging";
15
+ const Context = createContext(null);
16
+ export function useMessagingUI() {
17
+ const context = useContext(Context);
18
+ if (!context)
19
+ throw Error("Wrap messaging components in MessagingProvider.");
20
+ return context;
21
+ }
22
+ export function MessagingProvider({ children, ...props }) {
23
+ const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
24
+ const [reply, setReply] = useState(null);
25
+ useEffect(() => {
26
+ void props.client.reportUsage({
27
+ platform: Platform.OS === "ios"
28
+ ? "ios"
29
+ : Platform.OS === "android"
30
+ ? "android"
31
+ : "other",
32
+ components: ["screen"],
33
+ }).catch(() => { });
34
+ }, [props.client]);
35
+ const value = {
36
+ ...props,
37
+ state,
38
+ reply: reply?.conversationId === state.conversationId
39
+ ? state.messages.find(m => m.id === reply.id && !m.deleted) ?? null
40
+ : null,
41
+ setReply,
42
+ design: props.appearance ?? state.settings?.appearance ??
43
+ defaultMessagingSettings().appearance,
44
+ };
45
+ return _jsx(Context.Provider, { value: value, children: children });
46
+ }
47
+ export function MessagingHeader({ children } = {}) {
48
+ const { contact, design, state, client, onBack, onContactOptions } = useMessagingUI();
49
+ const [safetyOpen, setSafetyOpen] = useState(false);
50
+ const c = design.colors;
51
+ return (_jsxs(View, { style: {
52
+ flexDirection: "row",
53
+ alignItems: "center",
54
+ paddingHorizontal: 8,
55
+ paddingVertical: 6,
56
+ minHeight: 58,
57
+ backgroundColor: chatSurface(c.background),
58
+ gap: 8,
59
+ borderBottomWidth: 1,
60
+ borderBottomColor: c.foreground + "12",
61
+ }, children: [onBack && (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Back", onPress: onBack, style: { minWidth: 44, minHeight: 44, justifyContent: "center" }, children: _jsx(MessageIcon, { name: "back", color: c.foreground, size: 25 }) })), design.showAvatars && (contact.avatarUrl
62
+ ? (_jsx(Image, { source: { uri: contact.avatarUrl }, style: { width: 36, height: 36, borderRadius: 18 } }))
63
+ : (_jsx(View, { style: {
64
+ width: 36,
65
+ height: 36,
66
+ borderRadius: 18,
67
+ backgroundColor: c.incoming,
68
+ alignItems: "center",
69
+ justifyContent: "center",
70
+ }, children: _jsx(Text, { style: { color: c.incomingText }, children: contact.name.slice(0, 1) }) }))), _jsxs(View, { style: { flex: 1 }, children: [_jsx(Text, { style: { color: c.foreground, fontSize: 17, fontWeight: "600" }, children: state.conversation?.kind === 'group' ? state.conversation.name : contact.name }), _jsx(Text, { style: { color: c.secondary, fontSize: 12 }, children: state.conversation?.kind === 'group' ? `${state.conversation.memberIds?.length ?? 0} members` : design.name })] }), design.logoUrl && (_jsx(Image, { source: { uri: design.logoUrl }, accessibilityLabel: design.name, style: { width: 32, height: 32 } })), children, (onContactOptions || state.conversation?.safety) && (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Contact options", onPress: onContactOptions ?? (() => setSafetyOpen(true)), style: { padding: 12 }, children: _jsx(MessageIcon, { name: "more", color: c.foreground, size: 22 }) })), safetyOpen && _jsx(MessagingSafety, { client: client, state: state, appearance: design, name: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Conversation') : contact.name, onClose: () => setSafetyOpen(false) })] }));
71
+ }
72
+ const mediaNames = {
73
+ voice: "Voice message",
74
+ image: "Photo",
75
+ video: "Video",
76
+ document: "File",
77
+ };
78
+ function quoteSummary(message) { return message?.poll?.question ?? message?.text ?? (message && message.kind !== "text" && message.kind !== "poll" ? mediaNames[message.kind] : "Earlier message"); }
79
+ /** Thumbnails stay local to the view and are discarded on conversation/message changes. */
80
+ function MediaContent({ message, ink, footer, onOpen, onLongPress }) {
81
+ const { attachmentPreview, components, design } = useMessagingUI();
82
+ const [preview, setPreview] = useState({});
83
+ useEffect(() => {
84
+ let current = true;
85
+ setPreview({});
86
+ if (attachmentPreview)
87
+ void attachmentPreview(message).then(value => { if (current)
88
+ setPreview(value); }).catch(() => { });
89
+ return () => { current = false; };
90
+ }, [message.id, message.conversationId, message.media?.assetId, attachmentPreview]);
91
+ if (message.kind === 'voice') {
92
+ const Voice = components?.VoiceMessage;
93
+ return Voice ? _jsx(Voice, { message: message, ink: ink, accent: design.colors.accent, waveform: preview.waveform, footer: footer, onOpen: onOpen, onLongPress: onLongPress }) : _jsx(VoicePlayback, { ink: ink, accent: design.colors.accent, waveform: preview.waveform, footer: footer, durationMs: message.media?.durationMs, onToggle: onOpen, onLongPress: onLongPress });
94
+ }
95
+ if (message.kind === 'image' || message.kind === 'video')
96
+ return _jsx(View, { style: { width: 260, maxWidth: '100%', gap: 0 }, children: _jsxs(View, { style: { height: 186, borderRadius: 6, overflow: 'hidden', alignItems: 'center', justifyContent: 'center', backgroundColor: '#00000012' }, children: [preview.uri ? _jsx(Image, { source: { uri: preview.uri }, accessibilityLabel: message.media?.fileName ?? mediaNames[message.kind], resizeMode: "cover", onError: () => setPreview(value => ({ ...value, uri: undefined })), style: { width: '100%', height: '100%' } }) : _jsx(MessageIcon, { name: message.kind, color: ink, size: 36 }), message.kind === 'video' && _jsxs(_Fragment, { children: [_jsx(View, { style: { position: 'absolute', borderRadius: 24, padding: 10, backgroundColor: '#00000099' }, children: _jsx(MessageIcon, { name: "play", color: "#ffffff" }) }), _jsx(Text, { style: { position: 'absolute', left: 8, bottom: 8, color: '#ffffff', backgroundColor: '#000000aa', borderRadius: 4, paddingHorizontal: 5, fontSize: 12 }, children: duration(message.media?.durationMs) })] }), _jsx(View, { style: { position: 'absolute', right: 6, bottom: 6, paddingHorizontal: 6, paddingVertical: 3, borderRadius: 9, backgroundColor: '#00000070' }, children: footer })] }) });
97
+ return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 12, width: 246, minHeight: 62, padding: 10, borderRadius: 6, backgroundColor: ink + '09' }, children: [_jsx(MessageIcon, { name: "document", color: design.colors.accent, size: 30 }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { numberOfLines: 2, style: { color: ink, fontSize: 14, fontWeight: '600' }, children: message.media?.fileName ?? 'File' }), _jsxs(Text, { style: { color: ink, fontSize: 12, opacity: 0.75 }, children: [message.media?.fileName?.split('.').pop()?.toUpperCase(), " \u00B7 ", fileSize(message.media?.byteSize)] })] })] });
98
+ }
99
+ function MessageMetadata({ message, mine, ink }) {
100
+ const { state, design } = useMessagingUI();
101
+ const read = !!message.readAt && state.settings?.features.readReceipts;
102
+ return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 4, alignSelf: 'flex-end' }, children: [_jsx(Text, { style: { color: ink, fontSize: 11, lineHeight: 14, opacity: 0.64, fontVariant: ['tabular-nums'] }, children: new Date(message.createdAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) }), mine && _jsx(View, { accessibilityLabel: read ? 'Read' : 'Sent', children: _jsx(MessageIcon, { name: read ? 'read' : 'check', color: read ? design.colors.accent : ink, size: 15 }) })] });
103
+ }
104
+ export function MessagingBubble({ message }) {
105
+ const { userId, design, state, client, participants, setReply, onMessageOptions, openAttachment, language } = useMessagingUI();
106
+ const mine = message.senderId === userId, c = design.colors, ink = mine ? c.outgoingText : c.incomingText;
107
+ const visual = message.kind === 'image' || message.kind === 'video', voice = message.kind === 'voice';
108
+ const previous = state.messages[state.messages.findIndex(m => m.id === message.id) - 1];
109
+ const grouped = previous?.senderId === message.senderId && new Date(previous.createdAt).toDateString() === new Date(message.createdAt).toDateString();
110
+ const [error, setError] = useState(''), [busy, setBusy] = useState(false), [actions, setActions] = useState(false), [reportOpen, setReportOpen] = useState(false);
111
+ const anchor = useRef(null), viewport = useWindowDimensions();
112
+ const [menuPosition, setMenuPosition] = useState({ x: 12, y: 12 }), [hovered, setHovered] = useState(false);
113
+ const openMenu = () => anchor.current?.measureInWindow((x, y, width, height) => {
114
+ const menuHeight = 20 + (state.settings?.features.reactions ? 46 : 0) + (state.settings?.features.replies ? 54 : 0) + (!mine && state.conversation?.safety?.canReport ? 46 : 0) + (onMessageOptions ? 54 : 0) + 54;
115
+ const top = y >= menuHeight + 8 ? y - menuHeight - 6 : y + height + 6;
116
+ setMenuPosition({ x: Math.max(8, Math.min(x, viewport.width - 312)), y: Math.max(8, Math.min(top, viewport.height - menuHeight - 8)) });
117
+ setActions(true);
118
+ });
119
+ const [transformed, setTransformed] = useState(null), [transforming, setTransforming] = useState(false);
120
+ const canTranscript = voice && !!state.settings?.features.transcription && client.hasLanguageTools() && !message.deleted;
121
+ const canTranslate = ['text', 'voice'].includes(message.kind) && !!state.settings?.features.translation && client.hasLanguageTools() && !message.deleted;
122
+ useEffect(() => { setTransformed(null); }, [message.id, message.deleted, state.settings?.features.transcription, state.settings?.features.translation]);
123
+ async function transform(kind) { if (transformed?.kind === kind) {
124
+ setTransformed(null);
125
+ return;
126
+ } if (transforming)
127
+ return; setTransforming(true); setActions(false); setError(''); try {
128
+ setTransformed(await client.transform(message.id, kind, language ?? Intl.DateTimeFormat().resolvedOptions().locale));
129
+ }
130
+ catch {
131
+ setError('This message could not be converted. Try again.');
132
+ }
133
+ finally {
134
+ setTransforming(false);
135
+ } }
136
+ const canInteract = !!state.settings?.enabled && state.conversation?.canSend !== false && !message.deleted;
137
+ const quoting = !!state.settings?.features.replies && canInteract;
138
+ const quoteGesture = useQuoteGesture(quoting, mine, () => setReply(message));
139
+ async function open() { if (quoteGesture.suppressPress())
140
+ return; if (!openAttachment) {
141
+ setError('This attachment cannot be opened.');
142
+ return;
143
+ } setBusy(true); setError(''); try {
144
+ await openAttachment(message);
145
+ }
146
+ catch {
147
+ setError('The attachment could not be opened. Try again.');
148
+ }
149
+ finally {
150
+ setBusy(false);
151
+ } }
152
+ const menu = () => { if (quoteGesture.suppressPress())
153
+ return; if (canTranscript || canTranslate || canInteract && (quoting || state.settings?.features.reactions) || onMessageOptions || !mine && state.conversation?.safety?.canReport)
154
+ openMenu(); };
155
+ async function react(emoji) {
156
+ if (busy)
157
+ return;
158
+ setBusy(true);
159
+ setError('');
160
+ setActions(false);
161
+ const own = message.reactions?.find(r => r.userIds.includes(userId))?.emoji;
162
+ try {
163
+ await client.react(message.id, own === emoji ? null : emoji);
164
+ }
165
+ catch {
166
+ setError('Your reaction could not be saved. Try again.');
167
+ }
168
+ finally {
169
+ setBusy(false);
170
+ }
171
+ }
172
+ const footer = _jsx(MessageMetadata, { message: message, mine: mine, ink: visual ? '#ffffff' : ink });
173
+ const content = message.kind === 'poll' ? _jsx(PollMessage, { message: message, userId: userId, ink: ink, accent: c.accent, background: c.background, foreground: c.foreground, enabled: canInteract && !!state.settings?.features.poll, onVote: options => client.vote(message.id, options), onClose: () => client.closePoll(message.id) }) : message.kind === 'text' ? _jsxs(View, { testID: "message-original-text", style: { position: 'relative', paddingBottom: 2 }, children: [_jsxs(Text, { selectable: true, style: { color: ink, fontSize: 16, lineHeight: 22 }, children: [message.text, _jsx(Text, { accessible: false, style: { fontSize: 11, color: 'transparent' }, children: ' '.repeat(mine ? 15 : 12) })] }), _jsx(View, { pointerEvents: "none", testID: "message-timestamp", style: { position: 'absolute', right: 0, bottom: 0 }, children: footer })] }) : _jsx(MediaContent, { message: message, ink: ink, accent: c.accent, footer: footer, onOpen: () => void open(), onLongPress: menu });
174
+ return _jsxs(View, { ref: anchor, ...(Platform.OS === 'web' ? { onMouseEnter: () => setHovered(true), onMouseLeave: () => setHovered(false), onContextMenu: (event) => { event.preventDefault(); menu(); } } : {}), style: { alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '88%', marginTop: grouped ? 2 : 7, marginBottom: 0 }, children: [quoting && _jsx(Animated.View, { pointerEvents: "none", accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", testID: `quote-reveal-${mine ? 'outgoing' : 'incoming'}`, style: { ...quoteGesture.revealWebStyle, position: 'absolute', top: '50%', marginTop: -14, ...(mine ? { right: 12 } : { left: 12 }), width: 28, height: 28, alignItems: 'center', justifyContent: 'center', opacity: quoteGesture.revealOpacity }, children: _jsx(MessageIcon, { name: "quote", color: c.secondary, size: 23 }) }), _jsxs(Animated.View, { ref: quoteGesture.webRef, ...quoteGesture.handlers, accessibilityActions: quoting ? [{ name: "quote", label: "Quote message" }] : undefined, onAccessibilityAction: event => { if (quoting && event.nativeEvent.actionName === "quote")
175
+ setReply(message); }, testID: `message-${message.kind}-${mine ? 'outgoing' : 'incoming'}`, style: { ...quoteGesture.webStyle, transform: [{ translateX: quoteGesture.offset }], ...(Platform.OS === "web" && quoting ? { touchAction: "pan-y", userSelect: "none" } : {}), backgroundColor: mine ? c.outgoing : c.incoming, borderRadius: 8, borderTopRightRadius: mine && !grouped ? 0 : 8, borderTopLeftRadius: !mine && !grouped ? 0 : 8, padding: visual ? 3 : design.compact ? 6 : 8, boxShadow: '0 1px 1px #00000010' }, children: [!grouped && _jsx(View, { pointerEvents: "none", style: { position: 'absolute', top: 0, ...(mine ? { right: -6, borderRightWidth: 6 } : { left: -6, borderLeftWidth: 6 }), borderRightColor: 'transparent', borderLeftColor: 'transparent', borderTopWidth: 7, borderTopColor: mine ? c.outgoing : c.incoming } }), message.replyTo && _jsx(View, { style: { borderLeftWidth: 3, borderLeftColor: c.accent, paddingLeft: 9, paddingVertical: 5, marginBottom: 7 }, children: _jsx(Text, { numberOfLines: 2, style: { color: ink, fontSize: 12, opacity: 0.7 }, children: quoteSummary(state.messages.find(m => m.id === message.replyTo)) }) }), !mine && !grouped && state.conversation?.kind === 'group' && _jsx(Text, { style: { color: ink, fontSize: 12, fontWeight: '600', marginBottom: 5 }, children: message.deleted ? 'Former member' : participants?.[message.senderId]?.name ?? 'Member' }), voice || message.kind === 'poll' ? content : _jsx(Pressable, { accessibilityRole: message.kind === 'text' ? undefined : 'button', accessibilityLabel: message.kind === 'text' ? undefined : `${mediaNames[message.kind]}: ${message.media?.fileName ?? 'Open attachment'}`, disabled: busy, onLongPress: menu, onPress: message.kind === 'text' ? undefined : () => void open(), children: content }), message.kind !== 'text' && !voice && !visual && _jsx(View, { style: { marginTop: 4, alignSelf: 'flex-end' }, children: footer }), transformed && _jsx(Text, { testID: "message-language-result", style: { color: ink, fontSize: 13, lineHeight: 18, opacity: .8, marginTop: 4 }, children: transformed.text }), (canTranscript || canTranslate) && _jsxs(View, { style: { flexDirection: 'row', gap: 12, marginTop: 2 }, children: [canTranscript && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Read voice message", accessibilityState: { expanded: transformed?.kind === 'transcript' }, disabled: transforming, onPress: () => void transform('transcript'), style: { minHeight: 28, justifyContent: 'center' }, children: _jsx(Text, { style: { color: ink, fontSize: 11, opacity: .7 }, children: "Transcript" }) }), canTranslate && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Translate message", accessibilityState: { expanded: transformed?.kind === 'translation' }, disabled: transforming, onPress: () => void transform('translation'), style: { minHeight: 28, justifyContent: 'center' }, children: _jsx(Text, { style: { color: ink, fontSize: 11, opacity: .7 }, children: "Translate" }) })] }), transforming && _jsx(Text, { accessibilityLiveRegion: "polite", style: { color: ink, fontSize: 11 }, children: "Converting\u2026" }), busy && !voice && _jsx(Text, { style: { color: ink, fontSize: 12, marginTop: 4 }, children: "Opening\u2026" }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: ink, fontSize: 12 }, children: error }), Platform.OS === 'web' && canInteract && state.settings?.features.reactions && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "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, { style: { flexDirection: 'row', gap: 4, marginTop: 5, flexWrap: 'wrap' }, children: message.reactions.map(r => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `${r.emoji}, ${r.userIds.length} reaction${r.userIds.length === 1 ? '' : 's'}`, accessibilityState: { selected: r.userIds.includes(userId) }, disabled: busy || !canInteract || !state.settings?.features.reactions, onPress: () => void react(r.emoji), style: { flexDirection: 'row', gap: 4, minWidth: 40, minHeight: 32, paddingHorizontal: 7, alignItems: 'center', justifyContent: 'center', borderRadius: 16, backgroundColor: r.userIds.includes(userId) ? c.accent + '24' : c.background, borderWidth: 1, borderColor: r.userIds.includes(userId) ? c.accent : c.secondary + '30' }, children: [_jsx(Text, { style: { fontSize: 17 }, children: r.emoji }), _jsx(Text, { style: { color: c.foreground, fontSize: 12 }, children: r.userIds.length })] }, r.emoji)) }), _jsx(Modal, { visible: actions, transparent: true, animationType: "fade", onRequestClose: () => setActions(false), children: _jsx(Pressable, { accessibilityLabel: "Close message options", onPress: () => setActions(false), style: { flex: 1, backgroundColor: '#00000022' }, children: _jsxs(View, { style: { position: 'absolute', left: menuPosition.x, top: menuPosition.y, width: Math.min(304, viewport.width - 16), backgroundColor: c.background, borderRadius: 18, padding: 8, gap: 2, boxShadow: '0 4px 20px #0003' }, children: [canInteract && state.settings?.features.reactions && _jsx(View, { accessibilityLabel: "Choose a reaction", style: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 2 }, children: messageReactions.map(emoji => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: `React ${emoji}`, disabled: busy, onPress: () => void react(emoji), style: { minWidth: 42, minHeight: 42, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontSize: 28 }, children: emoji }) }, emoji)) }), quoting && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); setReply(message); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Quote" }) }), !mine && state.conversation?.safety?.canReport && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); setReportOpen(true); }, style: { padding: 12 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 15 }, children: "Report message" }) }), onMessageOptions && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); onMessageOptions(message); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "More options" }) }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => setActions(false), style: { padding: 16 }, children: _jsx(Text, { style: { color: c.secondary, fontSize: 16 }, children: "Cancel" }) })] }) }) }), reportOpen && _jsx(MessagingSafety, { client: client, state: state, appearance: design, name: participants?.[message.senderId]?.name ?? 'Contact', messageId: message.id, onClose: () => setReportOpen(false) })] })] });
176
+ }
177
+ export function MessagingList() {
178
+ const context = useMessagingUI();
179
+ const { state, client, components, design } = context;
180
+ const Message = components?.Message ?? MessagingBubble;
181
+ const current = useRef(context);
182
+ current.current = context;
183
+ const read = useRef("");
184
+ const pending = useRef("");
185
+ const onVisible = useRef(({ viewableItems }) => {
186
+ const ui = current.current;
187
+ if (ui.active === false || AppState.currentState !== "active" ||
188
+ !ui.state.settings?.features.readReceipts)
189
+ return;
190
+ const visible = viewableItems.filter((v) => v.isViewable && v.item.senderId !== ui.userId).map((v) => v.item).sort((a, b) => BigInt(a.id) > BigInt(b.id) ? -1 : 1);
191
+ const last = visible[0];
192
+ if (!last)
193
+ return;
194
+ const key = last.conversationId + ":" + last.id;
195
+ if (read.current === key || pending.current === key)
196
+ return;
197
+ pending.current = key;
198
+ void ui.client.markRead(last.id).then(() => {
199
+ read.current = key;
200
+ }).catch(() => { }).finally(() => {
201
+ if (pending.current === key)
202
+ pending.current = "";
203
+ });
204
+ }).current;
205
+ return (_jsx(FlatList, { data: [...state.messages].reverse(), inverted: true, keyExtractor: (m) => m.id, style: { flex: 1 }, keyboardShouldPersistTaps: "handled", contentContainerStyle: { paddingHorizontal: 12, paddingVertical: 10, flexGrow: 1, justifyContent: "flex-end" }, onViewableItemsChanged: onVisible, viewabilityConfig: {
206
+ itemVisiblePercentThreshold: 50,
207
+ minimumViewTime: 500,
208
+ }, renderItem: ({ item, index }) => {
209
+ const previous = [...state.messages].reverse()[index + 1];
210
+ const date = new Date(item.createdAt), start = !previous || date.toDateString() !== new Date(previous.createdAt).toDateString();
211
+ return _jsxs(View, { children: [start && _jsx(Text, { style: { alignSelf: 'center', fontSize: 12, color: design.colors.secondary, backgroundColor: chatSurface(design.colors.background), borderRadius: 7, paddingHorizontal: 12, paddingVertical: 5, marginTop: 4, marginBottom: 10 }, children: date.toDateString() === new Date().toDateString() ? 'Today' : date.toLocaleDateString([], { month: 'short', day: 'numeric' }) }), _jsx(Message, { message: item })] });
212
+ }, onEndReached: () => {
213
+ if (state.hasMore && !state.loading) {
214
+ void client.loadOlder().catch(() => { });
215
+ }
216
+ }, onEndReachedThreshold: 0.2, ListEmptyComponent: _jsx(Text, { style: { color: design.colors.secondary }, children: state.loading ? "Loading messages…" : design.labels.empty }) }));
217
+ }
218
+ /** One horizontal media row on phones and in the browser preview. */
219
+ function AttachmentStrip({ children, label, height, recent = false }) {
220
+ const scroll = useRef(null);
221
+ useEffect(() => {
222
+ if (Platform.OS !== 'web')
223
+ return;
224
+ const node = scroll.current?.getScrollableNode();
225
+ if (!node?.addEventListener)
226
+ return;
227
+ const wheel = (event) => {
228
+ if (event.ctrlKey || node.scrollWidth <= node.clientWidth)
229
+ return;
230
+ const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
231
+ if (!delta)
232
+ return;
233
+ event.preventDefault();
234
+ event.stopPropagation();
235
+ node.scrollLeft += delta * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? node.clientWidth : 1);
236
+ };
237
+ node.addEventListener('wheel', wheel, { passive: false });
238
+ return () => node.removeEventListener('wheel', wheel);
239
+ }, []);
240
+ return _jsx(ScrollView, { ref: scroll, horizontal: true, accessibilityLabel: label, directionalLockEnabled: true, alwaysBounceVertical: false, showsVerticalScrollIndicator: false, showsHorizontalScrollIndicator: false, style: { height, flexGrow: 0, flexShrink: 0, ...(recent ? { marginTop: 8 } : {}), ...(Platform.OS === 'web' ? { overflowY: 'hidden', touchAction: 'pan-x' } : {}) }, contentContainerStyle: { flexDirection: 'row', alignItems: 'flex-start', gap: 8, paddingHorizontal: recent ? 0 : 8, paddingBottom: recent ? 4 : 2 }, children: children });
241
+ }
242
+ export function MessagingComposer() {
243
+ const { state, client, design, pickAttachment, attachmentChoices, attachmentPreview, releaseAttachment, reply, setReply, components } = useMessagingUI();
244
+ const VoiceComposer = components?.VoiceComposer ?? VoiceMessageComposer;
245
+ const [draft, setDraft] = useState(""), [busy, setBusy] = useState(false), [error, setError] = useState("");
246
+ const [selected, setSelected] = useState([]);
247
+ const selectedRef = useRef(selected);
248
+ selectedRef.current = selected;
249
+ const selectionSequence = useRef(0);
250
+ useEffect(() => { setSelected(items => { for (const item of items)
251
+ void releaseAttachment?.(item.media); return []; }); }, [state.conversationId]);
252
+ useEffect(() => { setSelected(items => items.filter(item => { const allowed = state.settings?.enabled && state.conversation?.canSend !== false && state.settings.features[item.kind]; if (!allowed)
253
+ void releaseAttachment?.(item.media); return allowed; })); if (!state.settings?.enabled || state.conversation?.canSend === false)
254
+ setDraft(''); }, [state.settings, state.conversation?.canSend]);
255
+ async function sendSelected() {
256
+ for (const item of selectedRef.current) {
257
+ // The client owns failed sends and exposes Retry; never leave a second draft copy.
258
+ setSelected(items => items.filter(value => value.id !== item.id));
259
+ try {
260
+ await submit({ kind: item.kind, media: item.media });
261
+ }
262
+ finally {
263
+ void releaseAttachment?.(item.media);
264
+ }
265
+ }
266
+ }
267
+ const [pollOpen, setPollOpen] = useState(false);
268
+ useEffect(() => setPollOpen(false), [state.conversationId]);
269
+ const [attachmentsOpen, setAttachmentsOpen] = useState(false);
270
+ const [attachmentFilter, setAttachmentFilter] = useState(null);
271
+ useEffect(() => { if (!attachmentsOpen)
272
+ setAttachmentFilter(null); }, [attachmentsOpen]);
273
+ useEffect(() => { if (attachmentFilter && !state.settings?.features[attachmentFilter])
274
+ setAttachmentFilter(null); }, [attachmentFilter, state.settings]);
275
+ const [inputHeight, setInputHeight] = useState(42);
276
+ useEffect(() => { if (!draft)
277
+ setInputHeight(42); }, [draft]);
278
+ const inFlight = useRef(false);
279
+ const voiceInFlight = useRef(false);
280
+ const [voicePending, setVoicePending] = useState(false);
281
+ const input = useRef(null);
282
+ useEffect(() => { if (!attachmentsOpen)
283
+ return; const back = BackHandler.addEventListener("hardwareBackPress", () => { setAttachmentsOpen(false); return true; }); return () => back.remove(); }, [attachmentsOpen]);
284
+ const c = design.colors, features = state.settings?.features, enabled = state.settings?.enabled;
285
+ const library = { image: !!features?.image && design.showPhotoLibrary !== false, video: !!features?.video && design.showVideoLibrary !== false, document: !!features?.document };
286
+ async function submit(content) {
287
+ await client.send({
288
+ ...content,
289
+ ...(reply && features?.replies ? { replyTo: reply.id } : {}),
290
+ });
291
+ setReply(null);
292
+ }
293
+ async function send() {
294
+ if (inFlight.current || (!draft.trim() && !selected.length))
295
+ return;
296
+ inFlight.current = true;
297
+ if (!selected.length && draft.trim()) {
298
+ // The client adds its pending bubble immediately. Network acknowledgement
299
+ // must not lock the composer or clear text/quotes typed for the next message.
300
+ const conversationId = state.conversationId;
301
+ const content = { kind: 'text', text: draft, ...(reply && features?.replies ? { replyTo: reply.id } : {}) };
302
+ setDraft('');
303
+ setReply(null);
304
+ setError('');
305
+ requestAnimationFrame(() => { inFlight.current = false; });
306
+ try {
307
+ await client.send(content);
308
+ }
309
+ catch {
310
+ if (client.getSnapshot().conversationId === conversationId)
311
+ setError('Message not sent. Use Retry to send it again.');
312
+ }
313
+ return;
314
+ }
315
+ setBusy(true);
316
+ setError("");
317
+ const text = draft;
318
+ try {
319
+ await sendSelected();
320
+ setDraft("");
321
+ if (text.trim())
322
+ await submit({ kind: "text", text });
323
+ }
324
+ catch {
325
+ setError("Message not sent. Use Retry to send it again.");
326
+ }
327
+ finally {
328
+ inFlight.current = false;
329
+ setBusy(false);
330
+ }
331
+ }
332
+ async function pick(kind, source, attachmentId, intent = "send") {
333
+ if (intent === 'send')
334
+ setAttachmentsOpen(false);
335
+ if (inFlight.current)
336
+ return;
337
+ if (!pickAttachment) {
338
+ setError("Attachments are unavailable.");
339
+ return;
340
+ }
341
+ const conversationId = state.conversationId;
342
+ const recording = kind === 'voice';
343
+ if (recording && voiceInFlight.current)
344
+ return;
345
+ const ownsBusy = !recording;
346
+ if (recording) {
347
+ voiceInFlight.current = true;
348
+ setVoicePending(true);
349
+ }
350
+ else {
351
+ inFlight.current = true;
352
+ setBusy(true);
353
+ }
354
+ setError("");
355
+ try {
356
+ const media = await pickAttachment(kind, kind === "voice"
357
+ ? { maxDurationMs: (state.settings?.voice?.maxDurationSeconds ?? 60) * 1000, intent: 'attach' }
358
+ : { ...(source ? { source } : {}), ...(attachmentId ? { attachmentId } : {}), ...(!source ? { intent } : {}), ...(state.settings?.attachments ?? defaultAttachmentLimits())[kind],
359
+ ...(kind === "video" ? { maxDurationMs: (state.settings?.attachments ?? defaultAttachmentLimits()).video.maxDurationSeconds * 1000 } : {}) });
360
+ if (media && client.getSnapshot().conversationId === conversationId) {
361
+ setAttachmentsOpen(false);
362
+ {
363
+ const actualKind = source === 'camera' ? (media.mimeType.startsWith('video/') ? 'video' : 'image') : kind;
364
+ const current = client.getSnapshot();
365
+ if (!current.settings?.enabled || current.conversation?.canSend === false || !current.settings.features[actualKind]) {
366
+ void releaseAttachment?.(media);
367
+ return;
368
+ }
369
+ const id = 'attachment-' + (++selectionSequence.current);
370
+ const thumbnail = attachmentChoices?.find(item => item.id === attachmentId)?.thumbnailUri;
371
+ setSelected(items => [...items, { id, kind: actualKind, media, thumbnail }]);
372
+ if (!thumbnail && attachmentPreview) {
373
+ const message = { id, conversationId: conversationId, kind: actualKind, media, senderId: '', clientMessageId: id, createdAt: new Date().toISOString(), readAt: null };
374
+ void attachmentPreview(message).then(result => setSelected(items => items.map(item => item.id === id ? { ...item, thumbnail: result.uri } : item))).catch(() => { });
375
+ }
376
+ return;
377
+ }
378
+ }
379
+ }
380
+ catch (e) {
381
+ setError(e instanceof Error ? e.message : "The attachment could not be sent.");
382
+ }
383
+ finally {
384
+ if (recording) {
385
+ voiceInFlight.current = false;
386
+ setVoicePending(false);
387
+ }
388
+ if (ownsBusy) {
389
+ inFlight.current = false;
390
+ setBusy(false);
391
+ }
392
+ }
393
+ }
394
+ function removeSelected(id) { const item = selectedRef.current.find(item => item.id === id); setSelected(items => items.filter(item => item.id !== id)); if (item)
395
+ void releaseAttachment?.(item.media); }
396
+ function renderAttachments(voiceReview, blocked = busy) {
397
+ return selected.length > 0 || voiceReview ? _jsxs(AttachmentStrip, { label: "Pending attachments", height: selected.some(item => item.kind === 'document') ? 104 : 86, children: [selected.map(item => item.kind === 'voice' && components?.VoiceAttachment ? _jsx(components.VoiceAttachment, { media: item.media, appearance: design, disabled: blocked, onRemove: () => removeSelected(item.id) }, item.id) : _jsxs(View, { style: { width: 88, paddingTop: 8, paddingRight: 10 }, children: [_jsxs(View, { style: { width: 76, height: 76, borderRadius: 9, overflow: 'hidden', backgroundColor: c.incoming, borderWidth: 1, borderColor: c.secondary + '40', alignItems: 'center', justifyContent: 'center' }, children: [item.thumbnail ? _jsx(Image, { source: { uri: item.thumbnail }, accessibilityLabel: item.media.fileName ?? mediaNames[item.kind], resizeMode: "cover", style: { width: '100%', height: '100%' } }) : _jsx(MessageIcon, { name: item.kind === 'voice' ? 'mic' : item.kind, color: c.accent, size: 28 }), item.kind === 'video' && _jsx(View, { style: { position: 'absolute', bottom: 0, left: 0, right: 0, padding: 4, backgroundColor: '#0009' }, children: _jsx(Text, { style: { color: '#fff', fontSize: 10 }, children: duration(item.media.durationMs) }) })] }), item.kind === 'document' && _jsx(Text, { numberOfLines: 1, style: { fontSize: 10, color: c.foreground, marginTop: 3 }, children: item.media.fileName ?? mediaNames[item.kind] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Remove ' + (item.media.fileName ?? mediaNames[item.kind]), disabled: blocked, onPress: () => removeSelected(item.id), style: { position: 'absolute', right: 0, top: 0, width: 30, height: 30, alignItems: 'center', justifyContent: 'center' }, children: _jsx(View, { style: { backgroundColor: c.foreground, borderRadius: 11, padding: 3 }, children: _jsx(MessageIcon, { name: "close", size: 16, color: c.background }) }) })] }, item.id)), voiceReview] }) : null;
398
+ }
399
+ function renderTextComposer(voice = { hasVoice: false, blocked: false }) {
400
+ const blocked = voice.blocked || busy;
401
+ return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 }, children: [(library.image || library.video || library.document || features?.poll) && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Attach", accessibilityState: { expanded: attachmentsOpen }, disabled: busy || blocked, onPress: () => { if (attachmentsOpen) {
402
+ setAttachmentsOpen(false);
403
+ input.current?.focus();
404
+ }
405
+ else {
406
+ Keyboard.dismiss();
407
+ setAttachmentsOpen(true);
408
+ } }, style: { width: 40, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: attachmentsOpen ? "keyboard" : "plus", color: c.foreground, size: 27 }) }), features?.text && _jsx(View, { style: { flex: 1, minWidth: 0, flexDirection: 'row', alignItems: 'flex-end', borderWidth: 1, borderColor: c.secondary + '30', backgroundColor: chatSurface(c.background), borderRadius: 23 }, children: _jsx(TextInput, { ref: input, accessibilityLabel: design.labels.compose, placeholder: design.labels.compose, placeholderTextColor: c.secondary, value: draft, editable: !blocked, onChangeText: setDraft, onContentSizeChange: e => setInputHeight(Math.max(42, Math.min(120, e.nativeEvent.contentSize.height))), multiline: true, numberOfLines: 1, maxLength: 10000, onFocus: () => setAttachmentsOpen(false), style: { ...(Platform.OS === 'web' ? { outlineStyle: 'none' } : {}), flex: 1, minWidth: 0, minHeight: 42, height: inputHeight, maxHeight: 120, color: c.foreground, fontSize: 16, lineHeight: 21, paddingHorizontal: 13, paddingVertical: 10 } }) }), !features?.text && _jsx(View, { style: { flex: 1 } }), (features?.image || features?.video) && design.showCamera && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Camera", disabled: busy || blocked, onPress: () => void pick(features?.image ? 'image' : 'video', 'camera'), style: { width: 42, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "camera", color: c.foreground, size: 24 }) }), features?.voice && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Voice message", disabled: voicePending || busy || blocked, onPress: () => void pick('voice'), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', opacity: voicePending || busy || blocked ? 0.5 : 1 }, children: _jsx(MessageIcon, { name: "mic", color: c.foreground, size: 25 }) }), (features?.text || selected.length > 0) && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: design.labels.send, disabled: blocked || (!draft.trim() && !voice.sendVoice && !selected.length), onPress: () => { if (voice.sendVoice)
409
+ voice.sendVoice();
410
+ else
411
+ void send(); }, style: { width: 42, height: 42, marginLeft: 4, borderRadius: 21, backgroundColor: c.accent, alignItems: 'center', justifyContent: 'center', opacity: blocked || (!draft.trim() && !voice.sendVoice && !selected.length) ? 0.4 : 1 }, children: _jsx(MessageIcon, { name: "send", color: "#ffffff", size: 21 }) })] });
412
+ }
413
+ if (!enabled || state.conversation?.canSend === false) {
414
+ return (_jsx(Text, { style: { padding: 16, color: c.secondary }, children: state.conversation?.safety?.blockedByMe ? 'You blocked this contact. Use contact options to unblock.' : state.conversation?.canSend === false ? (state.conversation.kind === 'group' ? 'Only members allowed to send can post in this group.' : 'Messaging is unavailable for this conversation.') : design.labels.unavailable }));
415
+ }
416
+ return (_jsxs(View, { style: { paddingHorizontal: 6, paddingTop: 5, paddingBottom: 6, gap: 6, backgroundColor: chatSurface(c.background) }, children: [reply && (_jsxs(View, { style: { flexDirection: "row", gap: 8 }, children: [_jsxs(Text, { style: { flex: 1, color: c.secondary }, children: ["Quoting: ", quoteSummary(reply)] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Cancel quote", onPress: () => setReply(null), children: _jsx(Text, { style: { color: c.foreground }, children: "Cancel" }) })] })), state.settings?.history?.mode === 'last_two' && _jsx(Text, { style: { color: c.secondary, fontSize: 11, textAlign: 'center' }, children: "Only the latest two messages are kept." }), 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: _jsxs(_Fragment, { children: [(library.image || library.video || library.document || features?.poll) && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "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 }) }), (features?.image || features?.video) && design.showCamera && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Camera", disabled: busy, onPress: () => void pick(features?.image ? 'image' : 'video', 'camera'), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "camera", color: c.foreground, size: 23 }) })] }) }) : _jsxs(_Fragment, { children: [renderAttachments(), renderTextComposer()] }), attachmentsOpen && (library.image || library.video || library.document || features?.poll) && _jsxs(View, { accessibilityLabel: "Attachments", style: { paddingTop: 24, paddingBottom: 16, paddingHorizontal: 8, backgroundColor: chatSurface(c.background) }, children: [_jsxs(View, { style: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'flex-start' }, children: [features?.poll && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: "Create poll", disabled: busy, onPress: () => { setAttachmentsOpen(false); setPollOpen(true); }, style: { flex: 1, minWidth: 0, minHeight: 84, alignItems: 'center', gap: 3 }, children: [_jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: '#db7c2412', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "poll", color: "#b76920", size: 28 }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 13 }, children: "Poll" })] }), ['image', 'video', 'document'].filter(kind => library[kind]).map(kind => {
417
+ const color = { image: '#1677e8', video: '#7b58cf', document: '#079ec3' }[kind];
418
+ const label = { image: 'Photos', video: 'Videos', document: 'Document' }[kind];
419
+ return _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: mediaNames[kind], accessibilityState: { selected: attachmentFilter === kind }, disabled: busy, onPress: () => { if (!attachmentChoices)
420
+ void pick(kind);
421
+ else
422
+ setAttachmentFilter(attachmentFilter === kind ? null : kind); }, style: { flex: 1, minWidth: 0, minHeight: 84, alignItems: 'center', gap: 3 }, children: [_jsx(View, { style: { width: 56, height: 56, borderRadius: 28, backgroundColor: color + (attachmentFilter === kind ? '28' : '12'), borderWidth: attachmentFilter === kind ? 2 : 0, borderColor: color, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: kind, color: color, size: 28 }) }), _jsx(Text, { style: { color: c.foreground, fontSize: 13 }, children: label })] }, kind);
423
+ })] }), !!attachmentChoices?.some(item => library[item.kind]) && _jsx(AttachmentStrip, { label: "Recent attachments", height: 116, recent: true, children: attachmentChoices.filter(item => library[item.kind] && (!attachmentFilter || item.kind === attachmentFilter)).map(item => _jsxs(View, { style: { width: 112, height: 112, borderRadius: 10, overflow: 'hidden', backgroundColor: c.incoming, borderWidth: 1, borderColor: c.secondary + '20' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Choose ${item.name}`, disabled: busy, onPress: () => void pick(item.kind, undefined, item.id), style: { width: '100%', height: '100%' }, children: [item.thumbnailUri ? _jsx(Image, { source: { uri: item.thumbnailUri }, accessibilityLabel: item.name, resizeMode: "cover", style: { width: '100%', height: '100%' } }) : _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 9, gap: 8 }, children: [_jsx(MessageIcon, { name: item.kind, color: c.accent, size: 30 }), _jsx(Text, { numberOfLines: 2, style: { color: c.incomingText, fontSize: 11, textAlign: 'center' }, children: item.name })] }), item.kind === 'video' && _jsxs(View, { style: { position: 'absolute', bottom: 0, left: 0, right: 0, backgroundColor: '#0008', flexDirection: 'row', alignItems: 'center', gap: 5, padding: 6 }, children: [_jsx(MessageIcon, { name: "play", size: 13, color: "#fff" }), _jsx(Text, { style: { color: '#fff', fontSize: 11 }, children: duration(item.durationMs) })] })] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: `Preview ${item.name}`, disabled: busy, onPress: () => void pick(item.kind, undefined, item.id, 'preview'), style: { position: 'absolute', top: 0, right: 0, width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(View, { style: { width: 30, height: 30, borderRadius: 15, backgroundColor: '#111b', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "search", size: 18, color: "#fff" }) }) })] }, item.id)) }), attachmentFilter && !attachmentChoices?.some(item => library[item.kind] && item.kind === attachmentFilter) && _jsxs(Text, { style: { color: c.secondary, paddingVertical: 16, textAlign: 'center' }, children: ["No recent ", attachmentFilter === 'image' ? 'photos' : attachmentFilter === 'video' ? 'videos' : 'files', "."] })] }), pollOpen && features?.poll && _jsx(PollComposer, { appearance: design, onSend: poll => submit({ kind: 'poll', poll }), onCancel: () => setPollOpen(false) }), error && (_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }))] }));
424
+ }
425
+ function Content() {
426
+ const { state, client, components, design, style } = useMessagingUI();
427
+ const Header = components?.Header ?? MessagingHeader, List = components?.List ?? MessagingList, Composer = components?.Composer ?? MessagingComposer;
428
+ return (_jsxs(KeyboardAvoidingView, { behavior: Platform.OS === "ios" ? "padding" : undefined, style: [{ flex: 1, backgroundColor: design.colors.background }, style], children: [_jsx(Header, {}), _jsx(List, {}), state.error && (_jsx(Text, { accessibilityRole: "alert", style: { padding: 12, color: design.colors.foreground }, children: state.error })), state.pending.map((p) => (_jsxs(View, { style: { padding: 8, alignSelf: "flex-end" }, children: [_jsxs(Text, { style: { color: design.colors.foreground }, children: [p.content.text ?? "Attachment", " \u00B7", " ", p.status === "sending" ? "Sending…" : "Not sent"] }), p.status === "failed" && (_jsx(Pressable, { accessibilityRole: "button", onPress: () => void client.retry(p.clientMessageId).catch(() => { }), children: _jsx(Text, { style: { color: design.colors.accent, padding: 8 }, children: design.labels.retry }) }))] }, p.clientMessageId))), _jsx(Composer, {}, state.conversationId)] }));
429
+ }
430
+ /** Embed inside your app's safe area and navigation. No engine, push or global audio side effects. */
431
+ export function MessagingScreen(props) {
432
+ return (_jsx(MessagingProvider, { ...props, children: _jsx(Content, {}) }));
433
+ }
@@ -0,0 +1,98 @@
1
+ import { type MessageKind, type MessageMedia, type MessagingMessage, type MessagingSettings } from '@robono/messaging';
2
+ export type MediaKind = Exclude<MessageKind, 'text' | 'poll'>;
3
+ export interface LocalMessagingMedia extends Omit<MessageMedia, 'assetId'> {
4
+ uri: string;
5
+ /** Ephemeral authorized remote playback source. Never persist it in chat history. */
6
+ stream?: {
7
+ headers?: Record<string, string>;
8
+ };
9
+ resumeAtMs?: number;
10
+ autoPlay?: boolean;
11
+ }
12
+ export interface MediaTransferOptions {
13
+ signal: AbortSignal;
14
+ onProgress: (fraction: number) => void;
15
+ }
16
+ export interface MessagingMediaStorage {
17
+ /** Keep uploadId stable on retry. Verify bytes/type/duration on the backend before returning. */
18
+ upload(file: LocalMessagingMedia, context: MediaTransferOptions & {
19
+ uploadId: string;
20
+ conversationId: string;
21
+ kind: MediaKind;
22
+ }): Promise<MessageMedia>;
23
+ download(message: MessagingMessage, context: MediaTransferOptions): Promise<LocalMessagingMedia>;
24
+ /** Optional native streaming for voice/video. Return null to use a verified download. */
25
+ stream?(message: MessagingMessage, context: MediaTransferOptions): Promise<LocalMessagingMedia | null>;
26
+ /** Revoke/delete an unfinished upload. Must be idempotent and safe after an interrupted response. */
27
+ discard(uploadId: string): Promise<void>;
28
+ }
29
+ export interface MessagingMediaDevice {
30
+ pick(kind: Exclude<MediaKind, 'voice'>, settings: MessagingSettings, signal: AbortSignal, source?: "camera" | "call-video"): Promise<LocalMessagingMedia | null>;
31
+ startRecording(maxDurationMs: number, signal: AbortSignal): Promise<void>;
32
+ recordingStatus(): {
33
+ durationMs: number;
34
+ recording: boolean;
35
+ limitReached?: boolean;
36
+ level?: number;
37
+ };
38
+ stopRecording(): Promise<LocalMessagingMedia | null>;
39
+ /** Delete only cache copies owned by this adapter, never originals in the user's library. */
40
+ remove(file: LocalMessagingMedia): Promise<void>;
41
+ }
42
+ export interface MessagingMediaState {
43
+ phase: 'idle' | 'choosing' | 'preparing' | 'recording' | 'review' | 'uploading' | 'opening' | 'viewing' | 'failed';
44
+ kind: MediaKind;
45
+ file: LocalMessagingMedia | null;
46
+ elapsedMs: number;
47
+ maxDurationMs: number;
48
+ progress: number;
49
+ error: string | null;
50
+ retryUpload: boolean;
51
+ waveform?: number[];
52
+ }
53
+ /** Single attachment lifecycle shared by iOS and Android. Cancellation invalidates every late result. */
54
+ export declare class MessagingMediaController {
55
+ private options;
56
+ private state;
57
+ private listeners;
58
+ private abort;
59
+ private intent;
60
+ private generation;
61
+ private resolve;
62
+ private timer;
63
+ private stopping;
64
+ private settings;
65
+ private conversationId;
66
+ private uploadId;
67
+ private disposed;
68
+ private closing;
69
+ private stageRecording;
70
+ private openedMessage;
71
+ private prepared;
72
+ getPrepared: (assetId: string) => LocalMessagingMedia | undefined;
73
+ releasePrepared(assetId: string): Promise<void>;
74
+ constructor(options: {
75
+ device: MessagingMediaDevice;
76
+ storage: MessagingMediaStorage;
77
+ createId: () => string;
78
+ getSettings: () => MessagingSettings;
79
+ getConversationId: () => string;
80
+ });
81
+ getSnapshot: () => MessagingMediaState;
82
+ subscribe: (f: () => void) => () => void;
83
+ private update;
84
+ private isCurrent;
85
+ pick(kind: MediaKind, source?: "camera" | "call-video", selectionIntent?: "preview" | "send" | "attach"): Promise<MessageMedia | null>;
86
+ private check;
87
+ finishRecording(interrupted?: boolean): Promise<void>;
88
+ send(): Promise<void>;
89
+ open(message: MessagingMessage): Promise<void>;
90
+ /** Recover a failed/expired native stream once through fresh authorized download. */
91
+ fallbackStream(uri: string, positionMs: number, autoPlay: boolean): Promise<void>;
92
+ private openMedia;
93
+ cancel(): Promise<void>;
94
+ private closeCurrent;
95
+ interrupt(): Promise<void>;
96
+ dispose(): Promise<void>;
97
+ private error;
98
+ }