@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.
- package/CHANGELOG.md +5 -0
- package/LICENSE +130 -0
- package/README.md +165 -2
- package/SBOM.spdx.json +1127 -0
- package/app.plugin.cjs +5 -0
- package/dist/call-message.d.ts +6 -0
- package/dist/call-message.js +109 -0
- package/dist/calling.d.ts +15 -0
- package/dist/calling.js +22 -0
- package/dist/expo-calling.d.ts +6 -0
- package/dist/expo-calling.js +7 -0
- package/dist/expo-media.d.ts +19 -0
- package/dist/expo-media.js +388 -0
- package/dist/expo.d.ts +36 -0
- package/dist/expo.js +429 -0
- package/dist/icons.d.ts +7 -0
- package/dist/icons.js +10 -0
- package/dist/index.d.ts +101 -0
- package/dist/index.js +433 -0
- package/dist/media-controller.d.ts +98 -0
- package/dist/media-controller.js +257 -0
- package/dist/media-readiness.d.ts +4 -0
- package/dist/media-readiness.js +4 -0
- package/dist/media-ui.d.ts +51 -0
- package/dist/media-ui.js +42 -0
- package/dist/polls.d.ts +21 -0
- package/dist/polls.js +54 -0
- package/dist/recent-media.d.ts +21 -0
- package/dist/recent-media.js +122 -0
- package/dist/safety.d.ts +11 -0
- package/dist/safety.js +47 -0
- package/dist/use-quote-gesture.d.ts +24 -0
- package/dist/use-quote-gesture.js +127 -0
- package/dist/visual/RobonoIcon.d.ts +232 -0
- package/dist/visual/RobonoIcon.js +20 -0
- package/dist/visual/icon-nodes.d.ts +3 -0
- package/dist/visual/icon-nodes.js +2 -0
- package/dist/visual/icon-paths.d.ts +221 -0
- package/dist/visual/icon-paths.js +221 -0
- package/dist/voice-composer.d.ts +37 -0
- package/dist/voice-composer.js +20 -0
- package/licenses/robono-icons-lucide-license.txt +43 -0
- package/licenses/robono-icons-weather-attribution.txt +1 -0
- package/licenses/robono-icons-weather-license.txt +21 -0
- package/package.json +110 -4
- 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
|
+
}
|