@robono/messaging-react-native 0.1.0 → 0.2.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 (53) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +79 -4
  3. package/SBOM.spdx.json +832 -112
  4. package/dist/calling.js +2 -2
  5. package/dist/expo-download.d.ts +3 -0
  6. package/dist/expo-download.js +43 -0
  7. package/dist/expo-media.js +23 -0
  8. package/dist/expo-outbox.d.ts +13 -0
  9. package/dist/expo-outbox.js +81 -0
  10. package/dist/expo-saved.d.ts +24 -0
  11. package/dist/expo-saved.js +76 -0
  12. package/dist/expo-social.d.ts +3 -0
  13. package/dist/expo-social.js +21 -0
  14. package/dist/expo-thumbnails.d.ts +9 -0
  15. package/dist/expo-thumbnails.js +68 -0
  16. package/dist/expo.d.ts +9 -3
  17. package/dist/expo.js +143 -23
  18. package/dist/forward.d.ts +13 -0
  19. package/dist/forward.js +61 -0
  20. package/dist/icons.d.ts +1 -1
  21. package/dist/inbox.d.ts +16 -0
  22. package/dist/inbox.js +48 -0
  23. package/dist/index.d.ts +45 -2
  24. package/dist/index.js +376 -113
  25. package/dist/media-controller.d.ts +18 -0
  26. package/dist/media-controller.js +118 -3
  27. package/dist/media-readiness.d.ts +1 -1
  28. package/dist/media-ui.d.ts +16 -5
  29. package/dist/media-ui.js +15 -11
  30. package/dist/message-links.d.ts +5 -0
  31. package/dist/message-links.js +30 -0
  32. package/dist/message-order.d.ts +4 -0
  33. package/dist/message-order.js +17 -0
  34. package/dist/native-outbox.d.ts +49 -0
  35. package/dist/native-outbox.js +51 -0
  36. package/dist/pollVoteQueue.d.ts +3 -0
  37. package/dist/pollVoteQueue.js +29 -0
  38. package/dist/polls.d.ts +6 -2
  39. package/dist/polls.js +85 -18
  40. package/dist/popup.d.ts +9 -0
  41. package/dist/popup.js +7 -0
  42. package/dist/recent-media.js +1 -0
  43. package/dist/safety.d.ts +2 -1
  44. package/dist/safety.js +11 -5
  45. package/dist/saved.d.ts +10 -0
  46. package/dist/saved.js +46 -0
  47. package/dist/search.d.ts +9 -0
  48. package/dist/search.js +41 -0
  49. package/dist/social.d.ts +19 -0
  50. package/dist/social.js +35 -0
  51. package/dist/visual/RobonoIcon.js +2 -1
  52. package/dist/voice-composer.js +2 -2
  53. package/package.json +20 -4
package/dist/index.js CHANGED
@@ -1,17 +1,25 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { orderChatMessages } from './message-order.js';
3
+ import { MessagingPopup } from './popup.js';
4
+ import { messageLinks } from './message-links.js';
5
+ import { MessagingForward } from './forward.js';
6
+ import { SavedMessagesPopup } from './saved.js';
7
+ import { SocialMessagePreview } from './social.js';
8
+ import { MessagingSearch } from './search.js';
9
+ export { MessagingSearch } from './search.js';
2
10
  import { MessagingSafety } from './safety.js';
3
11
  export { MessagingSafety } from './safety.js';
4
12
  import { PollMessage, PollComposer } from "./polls.js";
5
13
  export { PollMessage, PollComposer } from "./polls.js";
6
14
  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";
15
+ import { Animated, ActivityIndicator, AppState, BackHandler, FlatList, Image, KeyboardAvoidingView, Keyboard, Linking, Modal, Platform, Pressable, ScrollView, Text, TextInput, View, useWindowDimensions, } from "react-native";
8
16
  import { useQuoteGesture } from "./use-quote-gesture.js";
9
17
  import { MessageIcon, duration, fileSize, chatSurface } from "./icons.js";
10
18
  import { VoiceMessageComposer } from "./voice-composer.js";
11
19
  export { VoiceMessageComposer, VoiceDraftPlayback } from "./voice-composer.js";
12
20
  import { VoicePlayback } from "./media-ui.js";
13
21
  export { VoicePlayback, MediaViewer, MediaPlaybackControls, ZoomablePhoto } from "./media-ui.js";
14
- import { defaultMessagingSettings, defaultAttachmentLimits, messageReactions, } from "@robono/messaging";
22
+ import { defaultMessagingSettings, defaultAttachmentLimits, messageReactions, savedMessageKey, } from "@robono/messaging";
15
23
  const Context = createContext(null);
16
24
  export function useMessagingUI() {
17
25
  const context = useContext(Context);
@@ -21,17 +29,70 @@ export function useMessagingUI() {
21
29
  }
22
30
  export function MessagingProvider({ children, ...props }) {
23
31
  const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
32
+ useEffect(() => {
33
+ const update = (value) => props.client.setPresenceActive(props.active !== false && (value === null || value === 'active'));
34
+ update(AppState.currentState);
35
+ const subscription = AppState.addEventListener('change', update);
36
+ return () => { subscription.remove(); props.client.setPresenceActive(false); };
37
+ }, [props.client, props.active]);
38
+ const [forwarding, setForwarding] = useState([]), [deleting, setDeleting] = useState([]);
39
+ const [selection, setSelection] = useState([]);
40
+ const [saved, setSaved] = useState([]), [savedOpen, setSavedOpen] = useState(false), [saving, setSaving] = useState(false), [savedError, setSavedError] = useState('');
41
+ const savingRef = useRef(false), savedContext = useRef({ client: props.client, conversationId: state.conversationId });
42
+ savedContext.current = { client: props.client, conversationId: state.conversationId };
43
+ useEffect(() => { let live = true; setSaved([]); setSavedOpen(false); if (props.client.hasSavedMessages())
44
+ void props.client.savedMessages().then(rows => { if (live)
45
+ setSaved(rows); }).catch(() => { if (live)
46
+ setSavedError('Saved messages could not be opened.'); }); return () => { live = false; }; }, [props.client, state.conversationId]);
47
+ async function toggleSaved(message) { if (savingRef.current)
48
+ return; const origin = savedContext.current, live = () => savedContext.current.client === origin.client && savedContext.current.conversationId === origin.conversationId; savingRef.current = true; setSaving(true); setSavedError(''); try {
49
+ const key = savedMessageKey(message);
50
+ if (saved.some(r => r.key === key))
51
+ await props.client.removeSavedMessage(key);
52
+ else
53
+ await props.client.saveMessage(message.id, state.conversation?.kind === 'group' ? state.conversation.name ?? 'Group' : props.contact.name);
54
+ const rows = await props.client.savedMessages();
55
+ if (live()) {
56
+ setSaved(rows);
57
+ setSelection([]);
58
+ }
59
+ }
60
+ catch (e) {
61
+ if (live())
62
+ setSavedError(e instanceof Error ? e.message : 'This message could not be saved.');
63
+ }
64
+ finally {
65
+ savingRef.current = false;
66
+ setSaving(false);
67
+ } }
68
+ const selected = selection.map(old => state.messages.find(m => m.id === old.id) ?? old).filter(m => !m.deleted && m.conversationId === state.conversationId);
69
+ useEffect(() => { setSelection([]); }, [state.conversationId, state.settings?.enabled, state.historyWindow, state.focus?.sequence]);
70
+ useEffect(() => { if (!selection.length)
71
+ return; const sub = BackHandler.addEventListener('hardwareBackPress', () => { setSelection([]); return true; }); return () => sub.remove(); }, [selection.length]);
72
+ useEffect(() => { if (Platform.OS !== 'web' || !selection.length)
73
+ return; const escape = (event) => { if (event.key === 'Escape') {
74
+ event.preventDefault();
75
+ setSelection([]);
76
+ } }; document.addEventListener('keydown', escape); return () => document.removeEventListener('keydown', escape); }, [selection.length]);
77
+ const [editing, setEditing] = useState(null);
24
78
  const [reply, setReply] = useState(null);
79
+ useEffect(() => { setEditing(null); }, [state.conversationId, state.settings?.enabled, state.messageActionsAvailable, state.conversation?.canSend]);
25
80
  useEffect(() => {
26
81
  void props.client.reportUsage({
27
82
  platform: Platform.OS === "ios"
28
83
  ? "ios"
29
84
  : Platform.OS === "android"
30
85
  ? "android"
31
- : "other",
86
+ : Platform.OS === "web" ? "web" : "other",
32
87
  components: ["screen"],
33
88
  }).catch(() => { });
34
89
  }, [props.client]);
90
+ useEffect(() => { setForwarding([]); setDeleting([]); }, [state.conversationId, state.settings?.enabled]);
91
+ useEffect(() => { const action = props.requestedMessageAction, m = state.messages.find(m => m.id === action?.messageId && !m.deleted); if (!action || !m)
92
+ return; if (action.kind === 'forward')
93
+ setForwarding([m]);
94
+ else
95
+ setDeleting([m]); }, [props.requestedMessageAction]);
35
96
  const value = {
36
97
  ...props,
37
98
  state,
@@ -39,26 +100,51 @@ export function MessagingProvider({ children, ...props }) {
39
100
  ? state.messages.find(m => m.id === reply.id && !m.deleted) ?? null
40
101
  : null,
41
102
  setReply,
103
+ editing: state.messageActionsAvailable && editing?.conversationId === state.conversationId && state.settings?.enabled && state.conversation?.canSend !== false && state.messages.some(m => m.id === editing.id && !m.deleted) ? editing : null,
104
+ setEditing, setForwarding, setDeleting, selection: selected, setSelection, saved, saving, toggleSaved, setSavedOpen,
42
105
  design: props.appearance ?? state.settings?.appearance ??
43
106
  defaultMessagingSettings().appearance,
44
107
  };
45
- return _jsx(Context.Provider, { value: value, children: children });
108
+ return _jsxs(Context.Provider, { value: value, children: [children, forwarding.length > 0 && _jsx(MessagingForward, { messages: forwarding, client: props.client, appearance: value.design, participants: props.participants, userId: props.userId, onClose: () => setForwarding([]) }), _jsx(MessageDeletion, { messages: deleting, onClose: () => setDeleting([]) }), savedOpen && _jsx(SavedMessagesPopup, { records: saved, appearance: value.design, onClose: () => setSavedOpen(false), onOpen: async (record) => { setSavedOpen(false); try {
109
+ if (!props.openSavedAttachment)
110
+ throw Error('Saved attachments cannot be opened here.');
111
+ await props.openSavedAttachment(record);
112
+ }
113
+ catch (e) {
114
+ setSavedError(e instanceof Error ? e.message : 'This saved attachment could not be opened.');
115
+ } }, onRemove: async (key) => { await props.client.removeSavedMessage(key); setSaved(await props.client.savedMessages()); } }), !!savedError && _jsxs(MessagingPopup, { background: value.design.colors.background, onClose: () => setSavedError(''), children: [_jsx(Text, { accessibilityRole: "alert", style: { color: value.design.colors.foreground }, children: savedError }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => setSavedError(''), style: { minHeight: 44, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: value.design.colors.accent }, children: "OK" }) })] })] });
116
+ }
117
+ export function messagingHeaderColors(design, group) {
118
+ const background = group ? '#1F5F7A' : design.colors.accent;
119
+ const rgb = background.slice(1).match(/.{2}/g)?.map(v => parseInt(v, 16)) ?? [0, 0, 0];
120
+ const foreground = (rgb[0] * 299 + rgb[1] * 587 + rgb[2] * 114) / 1000 > 150 ? '#050505' : '#F7F7F7';
121
+ return { background, foreground };
122
+ }
123
+ function MessagingActivity({ ink }) {
124
+ const { state, design, participants } = useMessagingUI(), [now, setNow] = useState(Date.now());
125
+ useEffect(() => { setNow(Date.now()); if (!state.presence?.peers.length)
126
+ return; const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, [state.presence]);
127
+ const typing = state.settings?.features.typing ? state.presence?.peers.filter(peer => peer.typingUntil > now) ?? [] : [];
128
+ const online = state.settings?.features.online && state.presence?.peers.some(peer => peer.onlineUntil > now);
129
+ const group = state.conversation?.kind === 'group';
130
+ const text = typing.length ? (group ? (typing.length > 2 ? 'Several people are typing…' : typing.map(peer => participants?.[peer.userId]?.name ?? 'Someone').join(' and ') + ' ' + (typing.length > 1 ? 'are' : 'is') + ' typing…') : 'Typing…') : !group && online ? 'Online now' : group ? `${state.conversation?.memberCount ?? state.conversation?.memberIds?.length ?? 0} members` : design.name;
131
+ return _jsx(Text, { numberOfLines: 1, style: { color: ink, opacity: .8, fontSize: 12 }, children: text });
46
132
  }
47
133
  export function MessagingHeader({ children } = {}) {
48
- const { contact, design, state, client, onBack, onContactOptions } = useMessagingUI();
49
- const [safetyOpen, setSafetyOpen] = useState(false);
50
- const c = design.colors;
134
+ const { contact, design, state, client, userId, participants, onBack, onContactOptions, selection, setSelection, setSavedOpen } = useMessagingUI();
135
+ const [safetyOpen, setSafetyOpen] = useState(false), [searchOpen, setSearchOpen] = useState(false);
136
+ const c = design.colors, header = messagingHeaderColors(design, state.conversation?.kind === 'group');
51
137
  return (_jsxs(View, { style: {
52
138
  flexDirection: "row",
53
139
  alignItems: "center",
54
140
  paddingHorizontal: 8,
55
141
  paddingVertical: 6,
56
142
  minHeight: 58,
57
- backgroundColor: chatSurface(c.background),
143
+ backgroundColor: header.background,
58
144
  gap: 8,
59
145
  borderBottomWidth: 1,
60
146
  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
147
+ }, children: [(onBack || selection.length > 0) && (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: selection.length ? 'Clear message selection' : 'Back', onPress: () => selection.length ? setSelection([]) : onBack?.(), style: { minWidth: 44, minHeight: 44, justifyContent: "center" }, children: _jsx(MessageIcon, { name: "back", color: header.foreground, size: 25 }) })), design.showAvatars && (contact.avatarUrl
62
148
  ? (_jsx(Image, { source: { uri: contact.avatarUrl }, style: { width: 36, height: 36, borderRadius: 18 } }))
63
149
  : (_jsx(View, { style: {
64
150
  width: 36,
@@ -67,7 +153,7 @@ export function MessagingHeader({ children } = {}) {
67
153
  backgroundColor: c.incoming,
68
154
  alignItems: "center",
69
155
  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) })] }));
156
+ }, children: _jsx(Text, { style: { color: c.incomingText }, children: contact.name.slice(0, 1) }) }))), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsx(Text, { numberOfLines: 1, style: { color: header.foreground, fontSize: 17, fontWeight: "600" }, children: state.conversation?.kind === 'group' ? state.conversation.name : contact.name }), _jsx(MessagingActivity, { ink: header.foreground })] }), design.logoUrl && (_jsx(Image, { source: { uri: design.logoUrl }, accessibilityLabel: design.name, style: { width: 32, height: 32 } })), children, client.hasSavedMessages() && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Saved messages", onPress: () => setSavedOpen(true), style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "bookmark", color: header.foreground, size: 22 }) }), (onContactOptions || state.conversation?.safety || state.searchAvailable) && (_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Contact options", onPress: onContactOptions ?? (() => setSafetyOpen(true)), style: { padding: 12 }, children: _jsx(MessageIcon, { name: "more", color: header.foreground, size: 22 }) })), searchOpen && state.searchAvailable && state.settings?.enabled && _jsx(MessagingSearch, { client: client, appearance: design, onClose: () => setSearchOpen(false), resolveName: id => id === userId ? 'You' : participants?.[id]?.name ?? (state.conversation?.kind === 'direct' ? contact.name : 'Chat member') }, state.conversationId), safetyOpen && _jsx(MessagingSafety, { client: client, state: state, appearance: design, name: state.conversation?.kind === 'group' ? (state.conversation.name ?? 'Conversation') : contact.name, onSearch: state.searchAvailable && state.settings?.enabled ? () => { setSafetyOpen(false); setSearchOpen(true); } : undefined, onClose: () => setSafetyOpen(false) })] }));
71
157
  }
72
158
  const mediaNames = {
73
159
  voice: "Voice message",
@@ -76,6 +162,22 @@ const mediaNames = {
76
162
  document: "File",
77
163
  };
78
164
  function quoteSummary(message) { return message?.poll?.question ?? message?.text ?? (message && message.kind !== "text" && message.kind !== "poll" ? mediaNames[message.kind] : "Earlier message"); }
165
+ /** The same compact quoted preview in the composer and delivered bubble. */
166
+ function QuotedPreview({ message, ink }) {
167
+ const { attachmentPreview, userId, participants, contact, state, design } = useMessagingUI();
168
+ const [thumbnail, setThumbnail] = useState();
169
+ useEffect(() => {
170
+ let current = true;
171
+ setThumbnail(undefined);
172
+ if (message && !message.deleted && ['image', 'video'].includes(message.kind) && attachmentPreview)
173
+ void attachmentPreview(message).then(preview => { if (current)
174
+ setThumbnail(preview.uri); }).catch(() => { });
175
+ return () => { current = false; };
176
+ }, [message?.id, message?.conversationId, message?.media?.assetId, message?.deleted, attachmentPreview]);
177
+ const name = message && !message.deleted ? (message.senderId === userId ? 'You' : participants?.[message.senderId]?.name ?? (state.conversation?.kind === 'direct' ? contact.name : 'Chat member')) : null;
178
+ const media = message && !message.deleted && message.kind !== 'text' && message.kind !== 'poll';
179
+ return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 7, minWidth: 0 }, children: [media && (thumbnail ? _jsx(Image, { accessibilityLabel: "Quoted attachment thumbnail", source: { uri: thumbnail }, onError: () => setThumbnail(undefined), resizeMode: "cover", style: { width: 28, height: 28, borderRadius: 3 } }) : _jsx(MessageIcon, { name: message.kind === 'voice' ? 'mic' : message.kind, color: design.colors.accent, size: 24 })), _jsxs(Text, { numberOfLines: 2, style: { flexShrink: 1, color: ink, fontSize: 12, lineHeight: 15, opacity: .8 }, children: [name && _jsxs(Text, { style: { fontWeight: '600' }, children: [name, ": "] }), message?.deleted ? 'Message deleted' : quoteSummary(message), message?.kind === 'voice' && message.media?.durationMs ? ` · ${duration(message.media.durationMs)}` : ''] })] });
180
+ }
79
181
  /** Thumbnails stay local to the view and are discarded on conversation/message changes. */
80
182
  function MediaContent({ message, ink, footer, onOpen, onLongPress }) {
81
183
  const { attachmentPreview, components, design } = useMessagingUI();
@@ -90,53 +192,70 @@ function MediaContent({ message, ink, footer, onOpen, onLongPress }) {
90
192
  }, [message.id, message.conversationId, message.media?.assetId, attachmentPreview]);
91
193
  if (message.kind === 'voice') {
92
194
  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 });
195
+ return Voice ? _jsx(Voice, { message: message, ink: ink, accent: design.colors.accent, waveform: preview.waveform ?? message.media?.waveform, showWaveform: design.showWaveform !== false, footer: footer, onOpen: onOpen, onLongPress: onLongPress }) : _jsx(VoicePlayback, { ink: ink, accent: design.colors.accent, waveform: preview.waveform ?? message.media?.waveform, showWaveform: design.showWaveform !== false, footer: footer, durationMs: message.media?.durationMs, onToggle: onOpen, onLongPress: onLongPress });
94
196
  }
95
197
  if (message.kind === 'image' || message.kind === 'video')
96
198
  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
199
  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
200
  }
99
- function MessageMetadata({ message, mine, ink }) {
201
+ function messageTimeLabel(message) { return (message.editedAt && !message.deleted ? 'edited · ' : '') + new Date(message.createdAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); }
202
+ function MessageMetadata({ message, mine, ink, pendingStatus }) {
100
203
  const { state, design } = useMessagingUI();
101
204
  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 }) })] });
205
+ 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: messageTimeLabel(message) }), mine && _jsx(View, { accessibilityLabel: pendingStatus === 'sending' ? 'Sending message' : pendingStatus === 'failed' ? 'Message not sent' : read ? 'Read' : 'Sent', children: _jsx(MessageIcon, { name: pendingStatus === 'sending' ? 'clock' : pendingStatus === 'failed' ? 'warning' : read ? 'read' : 'check', color: pendingStatus === 'failed' ? '#c94040' : read ? design.colors.accent : ink, size: 15 }) })] });
103
206
  }
104
- export function MessagingBubble({ message }) {
105
- const { userId, design, state, client, participants, setReply, onMessageOptions, openAttachment, language } = useMessagingUI();
207
+ export function MessagingBubble({ message, pendingStatus }) {
208
+ const { userId, design, state, client, participants, setReply, setEditing, setForwarding, setDeleting, setSelection, saved, saving, toggleSaved, onMessageOptions, openAttachment, language, contact, copyText, components } = useMessagingUI();
209
+ const socialOrigin = useMessagingUI().socialOrigin ?? 'https://robono.com';
210
+ const contextContactName = contact.name;
106
211
  const mine = message.senderId === userId, c = design.colors, ink = mine ? c.outgoingText : c.incomingText;
107
212
  const visual = message.kind === 'image' || message.kind === 'video', voice = message.kind === 'voice';
108
213
  const previous = state.messages[state.messages.findIndex(m => m.id === message.id) - 1];
109
214
  const grouped = previous?.senderId === message.senderId && new Date(previous.createdAt).toDateString() === new Date(message.createdAt).toDateString();
110
215
  const [error, setError] = useState(''), [busy, setBusy] = useState(false), [actions, setActions] = useState(false), [reportOpen, setReportOpen] = useState(false);
216
+ const afterMenu = useRef(null);
217
+ const menuDismissed = () => { const next = afterMenu.current; afterMenu.current = null; next?.(); };
218
+ const leaveMenu = (next) => { afterMenu.current = next; setActions(false); if (Platform.OS !== 'ios')
219
+ menuDismissed(); };
220
+ useEffect(() => () => { afterMenu.current = null; }, [client, message.id]);
221
+ const canChange = mine && !pendingStatus && !message.deleted && !!state.messageActionsAvailable && !!state.settings?.enabled && state.conversation?.canSend !== false && !message.readAt;
111
222
  const anchor = useRef(null), viewport = useWindowDimensions();
112
- const [menuPosition, setMenuPosition] = useState({ x: 12, y: 12 }), [hovered, setHovered] = useState(false);
223
+ const [menuPosition, setMenuPosition] = useState({ x: 12, y: 12, above: false, available: 400 }), [hovered, setHovered] = useState(false);
224
+ const [footerWidth, setFooterWidth] = useState(0);
113
225
  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)) });
226
+ const center = Math.max(16, Math.min(y + height / 2, viewport.height - 16)), above = center > viewport.height * .52;
227
+ setMenuPosition({ x: Math.max(8, Math.min(x, viewport.width - 312)), y: center, above, available: Math.max(60, (above ? center : viewport.height - center) - 16) });
117
228
  setActions(true);
118
229
  });
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) {
230
+ const [transformed, setTransformed] = useState(null), [transforming, setTransforming] = useState(false), [translationChoices, setTranslationChoices] = useState(false);
231
+ const TranslationVoice = components?.TranslationVoice;
232
+ const canTranscript = !pendingStatus && voice && !!state.settings?.features.transcription && client.hasLanguageTools() && !message.deleted;
233
+ const canTranslate = !pendingStatus && ['text', 'voice'].includes(message.kind) && !!state.settings?.features.translation && client.hasLanguageTools() && !message.deleted;
234
+ const transformGeneration = useRef(0);
235
+ useEffect(() => { transformGeneration.current++; setTransformed(null); setTransforming(false); setTranslationChoices(false); return () => { transformGeneration.current++; }; }, [message.id, message.deleted, message.text, message.media?.assetId, language, state.settings?.enabled, state.settings?.features.transcription, state.settings?.features.translation]);
236
+ useEffect(() => { if (!transformed?.audio)
237
+ return; const timer = setTimeout(() => setTransformed(null), Math.max(0, Date.parse(transformed.audio.expiresAt) - Date.now())); return () => clearTimeout(timer); }, [transformed]);
238
+ async function transform(kind, output = 'text') { setTranslationChoices(false); if (transformed?.kind === kind && !!transformed.audio === (output === 'voice')) {
124
239
  setTransformed(null);
125
240
  return;
126
241
  } if (transforming)
127
- return; setTransforming(true); setActions(false); setError(''); try {
128
- setTransformed(await client.transform(message.id, kind, language ?? Intl.DateTimeFormat().resolvedOptions().locale));
242
+ return; const generation = ++transformGeneration.current; setTransforming(true); setActions(false); setError(''); try {
243
+ const result = await client.transform(message.id, kind, language ?? Intl.DateTimeFormat().resolvedOptions().locale, output);
244
+ if (generation === transformGeneration.current)
245
+ setTransformed(result);
129
246
  }
130
247
  catch {
131
- setError('This message could not be converted. Try again.');
248
+ if (generation === transformGeneration.current)
249
+ setError('This message could not be converted. Try again.');
132
250
  }
133
251
  finally {
134
- setTransforming(false);
252
+ if (generation === transformGeneration.current)
253
+ setTransforming(false);
135
254
  } }
136
- const canInteract = !!state.settings?.enabled && state.conversation?.canSend !== false && !message.deleted;
255
+ const canInteract = !pendingStatus && !!state.settings?.enabled && state.conversation?.canSend !== false && !message.deleted;
137
256
  const quoting = !!state.settings?.features.replies && canInteract;
138
257
  const quoteGesture = useQuoteGesture(quoting, mine, () => setReply(message));
139
- async function open() { if (quoteGesture.suppressPress())
258
+ async function open() { if (pendingStatus || quoteGesture.suppressPress())
140
259
  return; if (!openAttachment) {
141
260
  setError('This attachment cannot be opened.');
142
261
  return;
@@ -149,8 +268,8 @@ export function MessagingBubble({ message }) {
149
268
  finally {
150
269
  setBusy(false);
151
270
  } }
152
- const menu = () => { if (quoteGesture.suppressPress())
153
- return; if (canTranscript || canTranslate || canInteract && (quoting || state.settings?.features.reactions) || onMessageOptions || !mine && state.conversation?.safety?.canReport)
271
+ const menu = () => { if (pendingStatus === 'sending' || quoteGesture.suppressPress())
272
+ return; if (!pendingStatus && !message.deleted || canChange || pendingStatus === 'failed' || canTranscript || canTranslate || canInteract && (quoting || state.settings?.features.reactions) || message.text && (copyText || Platform.OS === 'web') || onMessageOptions || !mine && state.conversation?.safety?.canReport)
154
273
  openMenu(); };
155
274
  async function react(emoji) {
156
275
  if (busy)
@@ -169,15 +288,85 @@ export function MessagingBubble({ message }) {
169
288
  setBusy(false);
170
289
  }
171
290
  }
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) })] })] });
291
+ const renderText = (text) => messageLinks(text).map((part, index) => part.url ? _jsx(Text, { accessibilityRole: "link", onPress: event => { event.stopPropagation(); void Linking.openURL(part.url).catch(() => setError('This link could not be opened.')); }, style: { textDecorationLine: 'underline' }, children: part.text }, index) : part.text);
292
+ const canCopy = !!message.text && (!!copyText || Platform.OS === 'web');
293
+ const reactionPicker = 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)) });
294
+ const footer = _jsx(MessageMetadata, { message: message, mine: mine, ink: visual ? '#ffffff' : ink, pendingStatus: pendingStatus });
295
+ const content = message.kind === 'poll' ? _jsx(PollMessage, { message: message, userId: userId, ink: ink, accent: c.accent, background: c.background, foreground: c.foreground, resolveVoterName: id => id === userId ? 'You' : participants?.[id]?.name ?? (state.conversation?.kind === 'direct' ? contextContactName : 'Chat member'), 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: [renderText(message.text ?? ''), _jsx(View, { pointerEvents: "none", accessible: false, accessibilityElementsHidden: true, importantForAccessibility: "no-hide-descendants", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : {}), style: { width: (footerWidth || (messageTimeLabel(message).length * 6.5 + (mine ? 19 : 0)) * (viewport.fontScale || 1)) + 6, height: 1 } })] }), _jsx(View, { pointerEvents: "none", testID: "message-timestamp", onLayout: e => { const width = e.nativeEvent.layout.width; setFooterWidth(old => Math.abs(old - width) < .5 ? old : width); }, 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 });
296
+ 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", ...(Platform.OS === 'web' ? { 'aria-hidden': true } : { accessible: false, accessibilityElementsHidden: true, importantForAccessibility: 'no-hide-descendants' }), testID: `quote-reveal-${mine ? 'outgoing' : 'incoming'}`, style: { ...quoteGesture.revealWebStyle, position: 'absolute', top: '50%', marginTop: -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")
297
+ 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 : 6, 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(Pressable, { accessibilityRole: "button", accessibilityLabel: "Go to quoted message", onPress: () => { void client.focusMessage(message.replyTo).catch(e => setError(e instanceof Error ? e.message : 'This message is unavailable.')); }, style: { borderLeftWidth: 3, borderLeftColor: c.accent, paddingLeft: 9, paddingVertical: 5, marginBottom: 7 }, children: _jsx(QuotedPreview, { message: state.messages.find(m => m.id === message.replyTo) ?? state.quotedMessages?.find(m => m.id === message.replyTo), ink: ink }) }), !mine && !grouped && state.conversation?.kind === 'group' && _jsx(Text, { style: { color: ink, fontSize: 12, fontWeight: '600', marginBottom: 5 }, children: message.deleted ? '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 }), !pendingStatus && state.settings?.enabled && state.socialPreviewAvailable && components?.SocialPlayer && _jsx(SocialMessagePreview, { message: message, client: client, Player: components.SocialPlayer, origin: socialOrigin, accent: c.accent, foreground: c.foreground, background: c.background }), message.kind !== 'text' && !!message.text && _jsx(Text, { selectable: true, style: { color: ink, fontSize: 16, lineHeight: 22, marginTop: 4 }, children: renderText(message.text) }), message.kind !== 'text' && !voice && !visual && _jsx(View, { style: { marginTop: 4, alignSelf: 'flex-end' }, children: footer }), transformed && !transformed.audio && _jsx(Text, { testID: "message-language-result", style: { color: ink, fontSize: 13, lineHeight: 18, opacity: .8, marginTop: 4 }, children: transformed.text }), transformed?.audio && TranslationVoice && _jsxs(View, { testID: "message-translated-voice", style: { marginTop: 4 }, children: [_jsx(TranslationVoice, { transform: transformed, ink: ink, accent: c.accent, showWaveform: design.showWaveform !== false }), _jsx(Text, { style: { color: ink, opacity: .6, fontSize: 10 }, children: "Translated voice" })] }), translationChoices && _jsxs(MessagingPopup, { background: chatSurface(c.background), onClose: () => setTranslationChoices(false), children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 }, children: [_jsx(Text, { accessibilityRole: "header", style: { flex: 1, color: c.foreground, fontSize: 16, fontWeight: '600' }, children: "Translate" }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close translation options", onPress: () => setTranslationChoices(false), style: { minHeight: 44, minWidth: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", color: c.foreground }) })] }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void transform('translation', 'text'), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 16 }, children: "Translate to text" }) }), TranslationVoice && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void transform('translation', 'voice'), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 16 }, children: "Translate to voice" }) })] }), (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: () => setTranslationChoices(true), 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 && _jsxs(MessagingPopup, { background: chatSurface(c.background), onClose: () => setError(''), children: [_jsx(Text, { accessibilityRole: "header", style: { color: c.foreground, fontSize: 17, fontWeight: '600' }, children: "Message" }), _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, fontSize: 15, lineHeight: 21, marginTop: 8 }, children: error }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close message alert", onPress: () => setError(''), style: { alignSelf: 'flex-end', minHeight: 44, justifyContent: 'center', paddingHorizontal: 8, marginTop: 8 }, children: _jsx(Text, { style: { color: c.accent }, children: "OK" }) })] }), pendingStatus === 'failed' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Retry sending message", onPress: () => void client.retry(message.clientMessageId).catch(() => { }), style: { minHeight: 32, justifyContent: 'center', alignSelf: 'flex-end' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 12 }, children: design.labels.retry }) }), Platform.OS === 'web' && 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, onDismiss: menuDismissed, 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, ...(menuPosition.above ? { bottom: viewport.height - menuPosition.y + 8 } : { top: menuPosition.y + 8 }), width: Math.min(304, viewport.width - 16), maxHeight: menuPosition.available, backgroundColor: c.background, borderRadius: 18, boxShadow: '0 4px 20px #0003', overflow: 'hidden' }, children: [!menuPosition.above && reactionPicker, _jsxs(ScrollView, { style: { maxHeight: Math.max(44, menuPosition.available - (reactionPicker ? 50 : 0)) }, contentContainerStyle: { padding: 8, gap: 2 }, children: [canCopy && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); void (copyText ? copyText(message.text) : navigator.clipboard.writeText(message.text)).catch(() => setError('The text could not be copied.')); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Copy" }) }), quoting && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); setReply(message); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Quote" }) }), canInteract && client.canForward(message) && _jsx(Pressable, { accessibilityRole: "button", onPress: () => leaveMenu(() => setForwarding([message])), style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Forward" }) }), !pendingStatus && !message.deleted && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); setSelection([message]); Keyboard.dismiss(); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Select" }) }), !pendingStatus && !message.deleted && message.kind !== 'poll' && client.hasSavedMessages() && _jsx(Pressable, { accessibilityRole: "button", disabled: saving, onPress: () => { setActions(false); void toggleSaved(message); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: saved.some(r => r.key === savedMessageKey(message)) ? 'Remove from Saved' : 'Save' }) }), canChange && message.kind !== 'poll' && state.settings?.features[message.kind] && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); setEditing(message); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Edit" }) }), canChange && _jsx(Pressable, { accessibilityRole: "button", onPress: () => leaveMenu(() => setDeleting([message])), style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Delete" }) }), pendingStatus === 'failed' && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { setActions(false); void client.discardPending(message.clientMessageId).catch(() => setError('The unsent message could not be removed.')); }, style: { padding: 16 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Remove unsent message" }) }), !mine && state.conversation?.safety?.canReport && _jsx(Pressable, { accessibilityRole: "button", onPress: () => leaveMenu(() => setReportOpen(true)), style: { padding: 12 }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 15 }, children: "Report message" }) }), !pendingStatus && onMessageOptions && _jsx(Pressable, { accessibilityRole: "button", onPress: () => leaveMenu(() => 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" }) })] }), menuPosition.above && reactionPicker] }) }) }), reportOpen && _jsx(MessagingSafety, { client: client, state: state, appearance: design, name: participants?.[message.senderId]?.name ?? 'Contact', messageId: message.id, onClose: () => setReportOpen(false) })] })] });
298
+ }
299
+ function MessageDeletion({ messages, onClose }) {
300
+ const { client, design } = useMessagingUI(), [busy, setBusy] = useState(false), [error, setError] = useState('');
301
+ useEffect(() => setError(''), [messages]);
302
+ if (!messages.length)
303
+ return null;
304
+ const c = design.colors;
305
+ return _jsxs(MessagingPopup, { background: chatSurface(c.background), onClose: () => { if (!busy)
306
+ onClose(); }, children: [_jsxs(Text, { accessibilityRole: "header", style: { color: c.foreground, fontSize: 17, fontWeight: '600' }, children: ["Delete ", messages.length === 1 ? 'message' : messages.length + ' messages', "?"] }), _jsx(Text, { style: { color: c.secondary, marginTop: 8 }, children: "Delete for everyone? Only messages that have not been read, heard or opened can be deleted." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', justifyContent: 'flex-end', gap: 20, marginTop: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: error ? 'Close message alert' : undefined, disabled: busy, onPress: onClose, style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground }, children: error ? 'OK' : 'Cancel' }) }), !error && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Delete for everyone", disabled: busy, onPress: () => { setBusy(true); void (async () => { try {
307
+ for (const message of messages)
308
+ await client.deleteMessage(message.id, message.revision ?? 0);
309
+ onClose();
310
+ }
311
+ catch (e) {
312
+ setError(e instanceof Error ? e.message : 'This message could not be deleted.');
313
+ }
314
+ finally {
315
+ setBusy(false);
316
+ } })(); }, style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Delete" }) })] })] });
317
+ }
318
+ function MessageSelection() {
319
+ const { selection, setSelection, setForwarding, setDeleting, setEditing, state, client, design, userId, copyText, saved, saving, toggleSaved } = useMessagingUI();
320
+ const [error, setError] = useState('');
321
+ if (!selection.length)
322
+ return null;
323
+ const c = design.colors;
324
+ const ordered = orderChatMessages([...selection].sort((a, b) => BigInt(a.id) < BigInt(b.id) ? -1 : BigInt(a.id) > BigInt(b.id) ? 1 : 0)), enabled = !!state.settings?.enabled && state.conversation?.canSend !== false;
325
+ const forward = enabled && ordered.every(m => client.canForward(m)), remove = enabled && state.messageActionsAvailable && ordered.every(m => m.senderId === userId && !m.readAt && !m.deleted);
326
+ return _jsxs(View, { accessibilityLabel: "Selected message actions", style: { flexDirection: 'row', alignItems: 'center', minHeight: 48, paddingHorizontal: 4, gap: 0, backgroundColor: c.background, borderTopWidth: 1, borderTopColor: c.secondary + '25' }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Clear message selection", onPress: () => setSelection([]), style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", color: c.foreground }) }), _jsx(Text, { accessibilityLiveRegion: "polite", accessibilityLabel: selection.length + ' selected', style: { flex: 1, color: c.foreground, fontSize: 15 }, children: selection.length }), selection.length === 1 && !!ordered[0].text && (copyText || Platform.OS === 'web') && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Copy selected message text", onPress: () => { void (copyText ? copyText(ordered[0].text) : navigator.clipboard.writeText(ordered[0].text)).then(() => setSelection([])).catch(() => setError('The text could not be copied.')); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "copy", color: c.accent }) }), selection.length === 1 && client.hasSavedMessages() && ordered[0].kind !== 'poll' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: saved.some(r => r.key === savedMessageKey(ordered[0])) ? 'Remove selected message from Saved' : 'Save selected message', disabled: saving, onPress: () => void toggleSaved(ordered[0]), style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "bookmark", color: c.accent }) }), forward && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Forward selected messages", onPress: () => { setForwarding(ordered); setSelection([]); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "forward", color: c.accent }) }), remove && selection.length === 1 && ordered[0].kind !== 'poll' && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Edit selected message", onPress: () => { setEditing(ordered[0]); setSelection([]); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "edit", color: c.accent }) }), remove && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Delete selected messages", onPress: () => { setDeleting(ordered); setSelection([]); }, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "trash", color: c.accent }) }), !!error && _jsxs(MessagingPopup, { background: c.background, onClose: () => setError(''), children: [_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => setError(''), style: { minHeight: 44, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "OK" }) })] })] });
176
327
  }
177
328
  export function MessagingList() {
178
329
  const context = useMessagingUI();
179
- const { state, client, components, design } = context;
180
- const Message = components?.Message ?? MessagingBubble;
330
+ const { state, client, components, design, userId, selection, setSelection } = context;
331
+ const items = orderChatMessages([...state.messages, ...state.pending.map(p => ({
332
+ ...p.content, id: 'pending:' + p.clientMessageId, clientMessageId: p.clientMessageId, conversationId: state.conversationId, senderId: userId,
333
+ createdAt: p.createdAt ?? new Date().toISOString(), readAt: null, pendingStatus: p.status,
334
+ }))]).reverse();
335
+ const list = useRef(null);
336
+ const nearLatest = useRef(true), lastNewest = useRef(''), lastPending = useRef('');
337
+ const [away, setAway] = useState(false), [newMessages, setNewMessages] = useState(false);
338
+ const jumpToLatest = (animated = true) => { if (state.historyWindow) {
339
+ void client.goToLatest().then(() => { list.current?.scrollToOffset({ offset: 0, animated }); nearLatest.current = true; setAway(false); setNewMessages(false); }).catch(() => { });
340
+ return;
341
+ } list.current?.scrollToOffset({ offset: 0, animated }); nearLatest.current = true; setAway(false); setNewMessages(false); };
342
+ useEffect(() => { nearLatest.current = true; lastNewest.current = ''; lastPending.current = ''; setAway(false); setNewMessages(false); list.current?.scrollToOffset({ offset: 0, animated: false }); }, [state.conversationId]);
343
+ const [highlight, setHighlight] = useState(null);
344
+ const scrollTarget = useRef(null);
345
+ useEffect(() => {
346
+ if (!state.focus)
347
+ return;
348
+ const index = items.findIndex(m => m.id === state.focus.messageId);
349
+ if (index < 0)
350
+ return;
351
+ nearLatest.current = false;
352
+ setAway(true);
353
+ setHighlight(state.focus.messageId);
354
+ scrollTarget.current = { index, attempts: 0 };
355
+ const frame = requestAnimationFrame(() => list.current?.scrollToIndex({ index, viewPosition: .5, animated: false }));
356
+ const timer = setTimeout(() => { setHighlight(null); scrollTarget.current = null; }, 2500);
357
+ return () => { cancelAnimationFrame(frame); clearTimeout(timer); };
358
+ }, [state.focus]);
359
+ const newest = items[0]?.clientMessageId ?? '', outgoing = state.pending.at(-1)?.clientMessageId ?? '';
360
+ useEffect(() => {
361
+ if (newest && newest !== lastNewest.current) {
362
+ if (!state.historyWindow && nearLatest.current || (outgoing && outgoing !== lastPending.current))
363
+ requestAnimationFrame(() => jumpToLatest(false));
364
+ else if (!state.historyWindow)
365
+ setNewMessages(true);
366
+ }
367
+ lastNewest.current = newest;
368
+ lastPending.current = outgoing;
369
+ }, [newest, outgoing]);
181
370
  const current = useRef(context);
182
371
  current.current = context;
183
372
  const read = useRef("");
@@ -185,7 +374,7 @@ export function MessagingList() {
185
374
  const onVisible = useRef(({ viewableItems }) => {
186
375
  const ui = current.current;
187
376
  if (ui.active === false || AppState.currentState !== "active" ||
188
- !ui.state.settings?.features.readReceipts)
377
+ !ui.state.inboxAvailable && !ui.state.settings?.features.readReceipts)
189
378
  return;
190
379
  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
380
  const last = visible[0];
@@ -202,18 +391,23 @@ export function MessagingList() {
202
391
  pending.current = "";
203
392
  });
204
393
  }).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 }) }));
394
+ return (_jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(FlatList, { ref: list, testID: "messaging-history", maintainVisibleContentPosition: { minIndexForVisible: 0 }, initialNumToRender: 30, maxToRenderPerBatch: 20, onScrollToIndexFailed: info => { const target = scrollTarget.current; if (!target || target.attempts++ >= 5)
395
+ return; list.current?.scrollToOffset({ offset: info.averageItemLength * info.index, animated: false }); setTimeout(() => { if (scrollTarget.current === target)
396
+ list.current?.scrollToIndex({ index: target.index, viewPosition: .5, animated: false }); }, 60); }, onScroll: event => { const near = event.nativeEvent.contentOffset.y < 80; nearLatest.current = near; setAway(!near); if (near)
397
+ setNewMessages(false); }, scrollEventThrottle: 32, data: items, inverted: true, keyExtractor: (m) => m.senderId + ':' + m.clientMessageId, style: { flex: 1 }, keyboardShouldPersistTaps: "handled", contentContainerStyle: { paddingHorizontal: 12, paddingVertical: 10, flexGrow: 1, justifyContent: "flex-end" }, onViewableItemsChanged: onVisible, viewabilityConfig: {
398
+ itemVisiblePercentThreshold: 50,
399
+ minimumViewTime: 500,
400
+ }, renderItem: ({ item, index }) => {
401
+ const previous = items[index + 1];
402
+ const date = new Date(item.createdAt), start = !previous || date.toDateString() !== new Date(previous.createdAt).toDateString();
403
+ const checked = selection.some(m => m.id === item.id), selectable = selection.length > 0 && !item.pendingStatus && !item.deleted;
404
+ const toggle = () => setSelection(checked ? selection.filter(m => m.id !== item.id) : [...selection, item]);
405
+ return _jsxs(View, { testID: highlight === item.id ? 'focused-message' : undefined, style: highlight === item.id || checked ? { backgroundColor: design.colors.accent + '20', borderRadius: 8 } : undefined, 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(View, { style: { paddingLeft: selection.length ? 32 : 0 }, pointerEvents: selection.length ? 'none' : 'auto', accessibilityElementsHidden: selection.length > 0, importantForAccessibility: selection.length ? 'no-hide-descendants' : 'auto', ...(Platform.OS === 'web' && selection.length ? { 'aria-hidden': true } : {}), children: components?.Message ? _jsx(components.Message, { message: item }) : _jsx(MessagingBubble, { message: item, pendingStatus: item.pendingStatus }) }), selectable && _jsx(Pressable, { accessibilityRole: "checkbox", accessibilityLabel: 'Select message: ' + (item.text || item.media?.fileName || mediaNames[item.kind] || 'Message'), accessibilityState: { checked }, onPress: toggle, style: { position: 'absolute', inset: 0, justifyContent: 'center' }, children: _jsx(View, { style: { width: 24, height: 24, borderRadius: 12, borderWidth: 1, borderColor: design.colors.accent, backgroundColor: checked ? design.colors.accent : design.colors.background, alignItems: 'center', justifyContent: 'center' }, children: checked && _jsx(MessageIcon, { name: "check", size: 16, color: "#ffffff" }) }) })] });
406
+ }, onEndReached: () => {
407
+ if (state.hasMore && !state.loading && !state.historyError) {
408
+ void client.loadOlder().catch(() => { });
409
+ }
410
+ }, onEndReachedThreshold: 0.2, ListHeaderComponent: state.historyWindow && state.hasNewer ? _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Load newer messages", disabled: state.loading, onPress: () => void client.loadNewer().catch(() => { }), style: { padding: 10, minHeight: 44, alignItems: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: "Load newer messages" }) }) : null, ListFooterComponent: state.messages.length > 0 && (state.loading || state.historyError || state.hasMore) ? _jsx(View, { style: { padding: 10, alignItems: 'center' }, children: state.loading ? _jsx(ActivityIndicator, { accessibilityLabel: "Loading earlier messages", color: design.colors.accent }) : _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: state.historyError ? 'Retry earlier messages' : 'Load earlier messages', onPress: () => void client.loadOlder().catch(() => { }), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: design.colors.accent }, children: state.historyError ? 'Earlier messages could not load. Retry' : 'Load earlier messages' }) }) }) : null, ListEmptyComponent: _jsx(Text, { style: { color: design.colors.secondary }, children: state.loading ? "Loading messages…" : design.labels.empty }) }), (away || state.historyWindow) && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: newMessages ? 'New messages. Go to latest' : 'Go to latest message', onPress: () => jumpToLatest(), style: { position: 'absolute', left: '50%', marginLeft: -22, bottom: 8, width: 44, height: 44, justifyContent: 'center', alignItems: 'center' }, children: _jsx(MessageIcon, { name: "arrow-down", color: design.colors.accent, size: 28 }) }), _jsx(MessageSelection, {})] }));
217
411
  }
218
412
  /** One horizontal media row on phones and in the browser preview. */
219
413
  function AttachmentStrip({ children, label, height, recent = false }) {
@@ -240,30 +434,62 @@ function AttachmentStrip({ children, label, height, recent = false }) {
240
434
  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
435
  }
242
436
  export function MessagingComposer() {
243
- const { state, client, design, pickAttachment, attachmentChoices, attachmentPreview, releaseAttachment, reply, setReply, components } = useMessagingUI();
437
+ const { state, client, design, pickAttachment, attachmentChoices, attachmentPreview, releaseAttachment, prepareAttachment, retainDraftAttachments, reply, setReply, editing, setEditing, components, openAttachment, userId } = useMessagingUI();
244
438
  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;
439
+ const [savedDraft, setDraftState] = useState(state.draft ?? ""), [busy, setBusy] = useState(false), [error, setError] = useState("");
440
+ const [editDraft, setEditDraft] = useState('');
441
+ const draft = editing ? editDraft : savedDraft;
442
+ const setDraft = (value) => { if (editing)
443
+ setEditDraft(value);
444
+ else {
445
+ setDraftState(value);
446
+ client.setDraft(value);
447
+ } };
448
+ useEffect(() => { setEditDraft(editing?.text ?? ''); setError(''); if (editing) {
449
+ setAttachmentsOpen(false);
450
+ requestAnimationFrame(() => input.current?.focus());
451
+ } }, [editing]);
452
+ useEffect(() => setDraftState(state.draft ?? ''), [state.draft, state.conversationId]);
453
+ const [normalSelected, setNormalSelected] = useState([]);
249
454
  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)
455
+ useEffect(() => { setNormalSelected(items => { if (!retainDraftAttachments)
456
+ for (const item of items)
457
+ void releaseAttachment?.(item.media); return []; }); }, [state.conversationId]);
458
+ useEffect(() => {
459
+ if (!state.draftAttachments)
460
+ return;
461
+ let active = true;
462
+ const items = state.draftAttachments.filter(item => item.media && item.kind !== 'text' && item.kind !== 'poll').map(item => ({ id: item.media.assetId, kind: item.kind, media: item.media }));
463
+ setNormalSelected(items);
464
+ for (const item of items)
465
+ if (attachmentPreview && ['image', 'video'].includes(item.kind))
466
+ void attachmentPreview({ ...item, id: item.id, conversationId: state.conversationId, clientMessageId: item.id, senderId: '', createdAt: new Date().toISOString(), readAt: null }).then(preview => { if (active)
467
+ setNormalSelected(current => current.map(value => value.id === item.id ? { ...value, thumbnail: preview?.uri } : value)); }).catch(() => { });
468
+ return () => { active = false; };
469
+ }, [state.draftAttachments, attachmentPreview]);
470
+ useEffect(() => { setNormalSelected(items => items.filter(item => { const allowed = state.settings?.enabled && state.conversation?.canSend !== false && state.settings.features[item.kind]; if (!allowed)
253
471
  void releaseAttachment?.(item.media); return allowed; })); if (!state.settings?.enabled || state.conversation?.canSend === false)
254
472
  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
- }
473
+ const [editSelected, setEditSelected] = useState([]);
474
+ const editSelectedRef = useRef(editSelected);
475
+ editSelectedRef.current = editSelected;
476
+ const editingRef = useRef(editing);
477
+ editingRef.current = editing;
478
+ const selected = editing ? editSelected : normalSelected;
479
+ const setSelected = editing ? setEditSelected : setNormalSelected;
480
+ const selectedRef = useRef(selected);
481
+ selectedRef.current = selected;
482
+ useEffect(() => {
483
+ let active = true;
484
+ const original = editing?.media && editing.kind !== 'text' && editing.kind !== 'poll' ? { id: editing.id, kind: editing.kind, media: editing.media } : null;
485
+ setEditSelected(original ? [original] : []);
486
+ if (original && attachmentPreview)
487
+ void attachmentPreview(editing).then(preview => { if (active)
488
+ setEditSelected(items => items.map(item => item.id === original.id ? { ...item, thumbnail: preview.uri } : item)); }).catch(() => { });
489
+ return () => { active = false; for (const item of editSelectedRef.current)
490
+ if (item.media.assetId !== editing?.media?.assetId)
491
+ void releaseAttachment?.(item.media); };
492
+ }, [editing?.id]);
267
493
  const [pollOpen, setPollOpen] = useState(false);
268
494
  useEffect(() => setPollOpen(false), [state.conversationId]);
269
495
  const [attachmentsOpen, setAttachmentsOpen] = useState(false);
@@ -284,52 +510,75 @@ export function MessagingComposer() {
284
510
  const c = design.colors, features = state.settings?.features, enabled = state.settings?.enabled;
285
511
  const library = { image: !!features?.image && design.showPhotoLibrary !== false, video: !!features?.video && design.showVideoLibrary !== false, document: !!features?.document };
286
512
  async function submit(content) {
287
- await client.send({
513
+ const outgoing = {
288
514
  ...content,
289
515
  ...(reply && features?.replies ? { replyTo: reply.id } : {}),
290
- });
516
+ };
517
+ const prepare = content.media && prepareAttachment && content.kind !== 'text' && content.kind !== 'poll'
518
+ ? async () => ({ ...outgoing, media: await prepareAttachment(content.kind, content.media) })
519
+ : undefined;
291
520
  setReply(null);
521
+ await client.send(outgoing, prepare);
522
+ if (content.media)
523
+ void releaseAttachment?.(content.media);
292
524
  }
293
525
  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);
526
+ if (editing) {
527
+ if (inFlight.current || !selected.length && !draft.trim())
528
+ return;
529
+ inFlight.current = true;
530
+ setBusy(true);
304
531
  setError('');
305
- requestAnimationFrame(() => { inFlight.current = false; });
532
+ const current = editing;
306
533
  try {
307
- await client.send(content);
534
+ const item = selectedRef.current[0];
535
+ const replacement = item ? (item.media.assetId === current.media?.assetId ? undefined : { kind: item.kind, media: prepareAttachment ? await prepareAttachment(item.kind, item.media) : item.media }) : null;
536
+ if (editingRef.current?.id !== current.id || client.getSnapshot().conversationId !== current.conversationId)
537
+ return;
538
+ await client.editMessage(current.id, draft, current.revision ?? 0, replacement);
539
+ if (client.getSnapshot().conversationId === current.conversationId)
540
+ setEditing(null);
308
541
  }
309
- catch {
310
- if (client.getSnapshot().conversationId === conversationId)
311
- setError('Message not sent. Use Retry to send it again.');
542
+ catch (e) {
543
+ if (client.getSnapshot().conversationId === current.conversationId)
544
+ setError(e instanceof Error ? e.message : 'This message could not be changed.');
545
+ }
546
+ finally {
547
+ inFlight.current = false;
548
+ setBusy(false);
312
549
  }
313
550
  return;
314
551
  }
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
- }
552
+ if (inFlight.current || (!draft.trim() && !selected.length))
553
+ return;
554
+ inFlight.current = true;
555
+ const conversationId = state.conversationId;
556
+ const quote = reply && features?.replies ? { replyTo: reply.id } : {};
557
+ const attachments = [...selectedRef.current];
558
+ const batchId = attachments.length > 1 ? 'batch-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2) : null;
559
+ const outgoing = attachments.length ? attachments.map((item, index) => ({ kind: item.kind, media: item.media, ...(batchId ? { attachmentBatch: { id: batchId, index, count: attachments.length } } : {}), ...(index === 0 ? quote : {}), ...(index === attachments.length - 1 && draft.trim() ? { text: draft.trim() } : {}) })) : [{ kind: 'text', text: draft.trim(), ...quote }];
560
+ setSelected([]);
561
+ setDraft('');
562
+ setReply(null);
563
+ setError('');
564
+ // Put every item in the chat immediately; uploading must not lock the composer.
565
+ requestAnimationFrame(() => { inFlight.current = false; });
566
+ const results = await Promise.allSettled(outgoing.map(async (content) => {
567
+ const prepare = content.media && prepareAttachment && content.kind !== 'text' && content.kind !== 'poll'
568
+ ? async () => ({ ...content, media: await prepareAttachment(content.kind, content.media) })
569
+ : undefined;
570
+ await client.send(content, prepare);
571
+ if (content.media)
572
+ void releaseAttachment?.(content.media);
573
+ }));
574
+ if (client.getSnapshot().conversationId === conversationId && results.some(result => result.status === 'rejected') && !client.getSnapshot().pending.some(item => item.status === 'failed'))
575
+ setError('The message could not be prepared. Please try again.');
331
576
  }
332
577
  async function pick(kind, source, attachmentId, intent = "send") {
578
+ if (!editing && selectedRef.current.length >= 10) {
579
+ setError('Choose up to 10 attachments per message.');
580
+ return;
581
+ }
333
582
  if (intent === 'send')
334
583
  setAttachmentsOpen(false);
335
584
  if (inFlight.current)
@@ -339,6 +588,7 @@ export function MessagingComposer() {
339
588
  return;
340
589
  }
341
590
  const conversationId = state.conversationId;
591
+ const editId = editing?.id;
342
592
  const recording = kind === 'voice';
343
593
  if (recording && voiceInFlight.current)
344
594
  return;
@@ -357,6 +607,10 @@ export function MessagingComposer() {
357
607
  ? { maxDurationMs: (state.settings?.voice?.maxDurationSeconds ?? 60) * 1000, intent: 'attach' }
358
608
  : { ...(source ? { source } : {}), ...(attachmentId ? { attachmentId } : {}), ...(!source ? { intent } : {}), ...(state.settings?.attachments ?? defaultAttachmentLimits())[kind],
359
609
  ...(kind === "video" ? { maxDurationMs: (state.settings?.attachments ?? defaultAttachmentLimits()).video.maxDurationSeconds * 1000 } : {}) });
610
+ if (media && (client.getSnapshot().conversationId !== conversationId || editingRef.current?.id !== editId)) {
611
+ void releaseAttachment?.(media);
612
+ return;
613
+ }
360
614
  if (media && client.getSnapshot().conversationId === conversationId) {
361
615
  setAttachmentsOpen(false);
362
616
  {
@@ -368,7 +622,11 @@ export function MessagingComposer() {
368
622
  }
369
623
  const id = 'attachment-' + (++selectionSequence.current);
370
624
  const thumbnail = attachmentChoices?.find(item => item.id === attachmentId)?.thumbnailUri;
371
- setSelected(items => [...items, { id, kind: actualKind, media, thumbnail }]);
625
+ if (editId)
626
+ for (const previous of selectedRef.current)
627
+ if (previous.media.assetId !== editing?.media?.assetId)
628
+ void releaseAttachment?.(previous.media);
629
+ setSelected(items => [...(editId ? [] : items), { id, kind: actualKind, media, thumbnail }]);
372
630
  if (!thumbnail && attachmentPreview) {
373
631
  const message = { id, conversationId: conversationId, kind: actualKind, media, senderId: '', clientMessageId: id, createdAt: new Date().toISOString(), readAt: null };
374
632
  void attachmentPreview(message).then(result => setSelected(items => items.map(item => item.id === id ? { ...item, thumbnail: result.uri } : item))).catch(() => { });
@@ -391,43 +649,48 @@ export function MessagingComposer() {
391
649
  }
392
650
  }
393
651
  }
394
- function removeSelected(id) { const item = selectedRef.current.find(item => item.id === id); setSelected(items => items.filter(item => item.id !== id)); if (item)
652
+ function removeSelected(id) { const item = selectedRef.current.find(item => item.id === id); setSelected(items => items.filter(item => item.id !== id)); if (item && (!editing || item.media.assetId !== editing.media?.assetId))
395
653
  void releaseAttachment?.(item.media); }
396
654
  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;
655
+ 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' && item.media.assetId !== editing?.media?.assetId && 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(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Preview ' + (item.media.fileName ?? mediaNames[item.kind]), disabled: blocked || !openAttachment, onPress: () => void openAttachment?.({ ...item, id: item.id, clientMessageId: item.id, conversationId: state.conversationId, senderId: userId, createdAt: new Date().toISOString(), readAt: null }).catch(() => setError('This attachment could not be opened.')), 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
656
  }
399
657
  function renderTextComposer(voice = { hasVoice: false, blocked: false }) {
400
658
  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) {
659
+ return _jsxs(View, { style: { flexDirection: 'row', alignItems: 'flex-end', gap: 2 }, children: [(library.image || library.video || library.document || !editing && features?.poll) && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Attach", accessibilityState: { expanded: attachmentsOpen }, disabled: busy || blocked, onPress: () => { if (attachmentsOpen) {
402
660
  setAttachmentsOpen(false);
403
661
  input.current?.focus();
404
662
  }
405
663
  else {
406
664
  Keyboard.dismiss();
407
665
  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)
666
+ } }, 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, ...(Platform.OS === 'web' ? { onKeyPress: (event) => { const native = event.nativeEvent; if (native.key === 'Enter' && !native.shiftKey && !native.isComposing && native.keyCode !== 229) {
667
+ event.preventDefault();
668
+ if (!blocked)
669
+ void send();
670
+ } } } : {}), 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: editing ? "Save message" : design.labels.send, disabled: blocked || (!draft.trim() && !voice.sendVoice && !selected.length), onPress: () => { if (voice.sendVoice)
409
671
  voice.sendVoice();
410
672
  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 }) })] });
673
+ 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: editing ? "check" : "send", color: "#ffffff", size: 21 }) })] });
412
674
  }
413
675
  if (!enabled || state.conversation?.canSend === false) {
414
676
  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
677
  }
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 => {
678
+ return (_jsxs(View, { style: { paddingHorizontal: 6, paddingTop: 5, paddingBottom: 6, gap: 6, backgroundColor: chatSurface(c.background) }, children: [editing && _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsxs(Text, { numberOfLines: 1, style: { flex: 1, color: c.secondary }, children: ["Editing: ", editing.text || editing.media?.fileName] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Cancel editing", disabled: busy, onPress: () => setEditing(null), style: { minHeight: 36, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Cancel" }) })] }), !editing && reply && (_jsxs(View, { testID: "composer-quote", style: { flexDirection: "row", gap: 8 }, children: [_jsx(View, { style: { flex: 1 }, children: _jsx(QuotedPreview, { message: reply, ink: c.secondary }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "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." }), editing ? _jsxs(_Fragment, { children: [renderAttachments(), renderTextComposer()] }) : features?.voice ? _jsx(VoiceComposer, { appearance: design, maxSeconds: state.settings?.voice?.maxDurationSeconds ?? 60, busy: busy, onRecord: () => { Keyboard.dismiss(); void pick('voice'); }, renderAttachments: renderAttachments, renderTextComposer: features?.text || selected.length ? renderTextComposer : undefined, actions: _jsxs(_Fragment, { children: [(library.image || library.video || library.document || !editing && 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: [!editing && features?.poll && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: state.conversation?.kind === 'group' ? "Create poll" : "Ask Yes/No question", 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: state.conversation?.kind === 'group' ? 'Poll' : 'Yes/No' })] }), ['image', 'video', 'document'].filter(kind => library[kind]).map(kind => {
417
679
  const color = { image: '#1677e8', video: '#7b58cf', document: '#079ec3' }[kind];
418
680
  const label = { image: 'Photos', video: 'Videos', document: 'Document' }[kind];
419
681
  return _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: mediaNames[kind], accessibilityState: { selected: attachmentFilter === kind }, disabled: busy, onPress: () => { if (!attachmentChoices)
420
682
  void pick(kind);
421
683
  else
422
684
  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 }))] }));
685
+ })] }), !!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, { yesNo: state.conversation?.kind !== 'group', appearance: design, onSend: poll => submit({ kind: 'poll', poll }), onCancel: () => setPollOpen(false) }), error && (_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }))] }));
424
686
  }
425
687
  function Content() {
426
688
  const { state, client, components, design, style } = useMessagingUI();
427
689
  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)] }));
690
+ 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 })), _jsx(Composer, {}, state.conversationId)] }));
429
691
  }
430
692
  /** Embed inside your app's safe area and navigation. No engine, push or global audio side effects. */
431
693
  export function MessagingScreen(props) {
432
694
  return (_jsx(MessagingProvider, { ...props, children: _jsx(Content, {}) }));
433
695
  }
696
+ export { MessagingInbox } from './inbox.js';