@robono/messaging-react-native 0.0.0-stage → 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 (76) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/LICENSE +130 -0
  3. package/README.md +240 -2
  4. package/SBOM.spdx.json +1847 -0
  5. package/app.plugin.cjs +5 -0
  6. package/dist/call-message.d.ts +6 -0
  7. package/dist/call-message.js +109 -0
  8. package/dist/calling.d.ts +15 -0
  9. package/dist/calling.js +22 -0
  10. package/dist/expo-calling.d.ts +6 -0
  11. package/dist/expo-calling.js +7 -0
  12. package/dist/expo-download.d.ts +3 -0
  13. package/dist/expo-download.js +43 -0
  14. package/dist/expo-media.d.ts +19 -0
  15. package/dist/expo-media.js +411 -0
  16. package/dist/expo-outbox.d.ts +13 -0
  17. package/dist/expo-outbox.js +81 -0
  18. package/dist/expo-saved.d.ts +24 -0
  19. package/dist/expo-saved.js +76 -0
  20. package/dist/expo-social.d.ts +3 -0
  21. package/dist/expo-social.js +21 -0
  22. package/dist/expo-thumbnails.d.ts +9 -0
  23. package/dist/expo-thumbnails.js +68 -0
  24. package/dist/expo.d.ts +42 -0
  25. package/dist/expo.js +549 -0
  26. package/dist/forward.d.ts +13 -0
  27. package/dist/forward.js +61 -0
  28. package/dist/icons.d.ts +7 -0
  29. package/dist/icons.js +10 -0
  30. package/dist/inbox.d.ts +16 -0
  31. package/dist/inbox.js +48 -0
  32. package/dist/index.d.ts +144 -0
  33. package/dist/index.js +696 -0
  34. package/dist/media-controller.d.ts +116 -0
  35. package/dist/media-controller.js +372 -0
  36. package/dist/media-readiness.d.ts +4 -0
  37. package/dist/media-readiness.js +4 -0
  38. package/dist/media-ui.d.ts +62 -0
  39. package/dist/media-ui.js +46 -0
  40. package/dist/message-links.d.ts +5 -0
  41. package/dist/message-links.js +30 -0
  42. package/dist/message-order.d.ts +4 -0
  43. package/dist/message-order.js +17 -0
  44. package/dist/native-outbox.d.ts +49 -0
  45. package/dist/native-outbox.js +51 -0
  46. package/dist/pollVoteQueue.d.ts +3 -0
  47. package/dist/pollVoteQueue.js +29 -0
  48. package/dist/polls.d.ts +25 -0
  49. package/dist/polls.js +121 -0
  50. package/dist/popup.d.ts +9 -0
  51. package/dist/popup.js +7 -0
  52. package/dist/recent-media.d.ts +21 -0
  53. package/dist/recent-media.js +123 -0
  54. package/dist/safety.d.ts +12 -0
  55. package/dist/safety.js +53 -0
  56. package/dist/saved.d.ts +10 -0
  57. package/dist/saved.js +46 -0
  58. package/dist/search.d.ts +9 -0
  59. package/dist/search.js +41 -0
  60. package/dist/social.d.ts +19 -0
  61. package/dist/social.js +35 -0
  62. package/dist/use-quote-gesture.d.ts +24 -0
  63. package/dist/use-quote-gesture.js +127 -0
  64. package/dist/visual/RobonoIcon.d.ts +232 -0
  65. package/dist/visual/RobonoIcon.js +21 -0
  66. package/dist/visual/icon-nodes.d.ts +3 -0
  67. package/dist/visual/icon-nodes.js +2 -0
  68. package/dist/visual/icon-paths.d.ts +221 -0
  69. package/dist/visual/icon-paths.js +221 -0
  70. package/dist/voice-composer.d.ts +37 -0
  71. package/dist/voice-composer.js +20 -0
  72. package/licenses/robono-icons-lucide-license.txt +43 -0
  73. package/licenses/robono-icons-weather-attribution.txt +1 -0
  74. package/licenses/robono-icons-weather-license.txt +21 -0
  75. package/package.json +126 -4
  76. package/plugin/preserve-call-audio.cjs +22 -0
@@ -0,0 +1,30 @@
1
+ /** Only explicit web links become actions. Never interpret arbitrary URI schemes. */
2
+ export function messageLinks(text) {
3
+ const parts = [];
4
+ let offset = 0;
5
+ for (const match of text.matchAll(/(?:https?:\/\/|www\.)[^\s<>]+/gi)) {
6
+ const start = match.index, raw = match[0];
7
+ let label = raw.replace(/[.,!?;:]+$/, '');
8
+ while (/[)\]}]$/.test(label)) {
9
+ const close = label.at(-1), open = { ')': '(', ']': '[', '}': '{' }[close];
10
+ if (label.split(close).length <= label.split(open).length)
11
+ break;
12
+ label = label.slice(0, -1);
13
+ }
14
+ if (!label)
15
+ continue;
16
+ try {
17
+ const url = new URL(/^www\./i.test(label) ? 'https://' + label : label);
18
+ if (!['https:', 'http:'].includes(url.protocol) || url.username || url.password)
19
+ continue;
20
+ if (start > offset)
21
+ parts.push({ text: text.slice(offset, start) });
22
+ parts.push({ text: label, url: url.href });
23
+ offset = start + label.length;
24
+ }
25
+ catch { }
26
+ }
27
+ if (offset < text.length)
28
+ parts.push({ text: text.slice(offset) });
29
+ return parts.length ? parts : [{ text }];
30
+ }
@@ -0,0 +1,4 @@
1
+ import type { MessagingMessage } from '@robono/messaging';
2
+ /** Upload completion order must not reorder the attachments selected together.
3
+ * Transport state remains in server sequence order for reconnect cursors. */
4
+ export declare function orderChatMessages<T extends MessagingMessage>(messages: readonly T[]): T[];
@@ -0,0 +1,17 @@
1
+ /** Upload completion order must not reorder the attachments selected together.
2
+ * Transport state remains in server sequence order for reconnect cursors. */
3
+ export function orderChatMessages(messages) {
4
+ const first = new Map();
5
+ const key = (m) => m.attachmentBatch ? JSON.stringify([m.senderId, m.attachmentBatch.id]) : null;
6
+ for (let i = 0; i < messages.length; i++) {
7
+ const k = key(messages[i]);
8
+ if (k && !first.has(k))
9
+ first.set(k, i);
10
+ }
11
+ return messages.map((message, index) => ({ message, index })).sort((a, b) => {
12
+ const ak = key(a.message), bk = key(b.message);
13
+ if (ak && ak === bk)
14
+ return a.message.attachmentBatch.index - b.message.attachmentBatch.index || a.index - b.index;
15
+ return (ak ? first.get(ak) : a.index) - (bk ? first.get(bk) : b.index);
16
+ }).map(item => item.message);
17
+ }
@@ -0,0 +1,49 @@
1
+ import type { MessageContent, MessageMedia, MessagingOutbox, SavedOutgoingMessage } from '@robono/messaging';
2
+ import type { LocalMessagingMedia } from './media-controller.js';
3
+ export interface NativeDraftFiles {
4
+ read(scope: string, conversation: string): Promise<NativeDraftState>;
5
+ write(scope: string, conversation: string, state: NativeDraftState): Promise<void>;
6
+ copy(scope: string, conversation: string, id: string, source: LocalMessagingMedia): Promise<string>;
7
+ file(scope: string, conversation: string, id: string): Promise<string | null>;
8
+ remove(scope: string, conversation: string, id: string): Promise<void>;
9
+ clear(scope: string, conversation?: string): Promise<void>;
10
+ }
11
+ export interface NativeDraftState {
12
+ draft: string;
13
+ messages: Array<SavedOutgoingMessage & {
14
+ fileId?: string;
15
+ }>;
16
+ files: Record<string, {
17
+ kind: MessageContent['kind'];
18
+ media: MessageMedia;
19
+ }>;
20
+ }
21
+ export declare const emptyNativeDraft: () => NativeDraftState;
22
+ /** Durable outgoing content only, isolated by endpoint, namespace and signed-in user. */
23
+ export declare class NativeMessagingOutbox implements MessagingOutbox {
24
+ private files;
25
+ private generation;
26
+ private scope;
27
+ constructor(endpoint: string, userId: string, files: NativeDraftFiles, namespace?: string);
28
+ private serial;
29
+ load(cid: string): Promise<{
30
+ draft: string;
31
+ messages: {
32
+ clientMessageId: string;
33
+ createdAt: string;
34
+ content: MessageContent;
35
+ needsPreparation?: boolean;
36
+ }[];
37
+ attachments: {
38
+ kind: "text" | "voice" | "image" | "video" | "document" | "poll";
39
+ media: MessageMedia;
40
+ }[];
41
+ }>;
42
+ put(cid: string, message: SavedOutgoingMessage): Promise<void>;
43
+ remove(cid: string, id: string): Promise<void>;
44
+ draft(cid: string, text: string): Promise<void>;
45
+ clear(cid?: string): Promise<void>;
46
+ saveFile(cid: string, media: MessageMedia, file: LocalMessagingMedia, kind: MessageContent['kind']): Promise<void>;
47
+ file(cid: string, id: string): Promise<LocalMessagingMedia | undefined>;
48
+ releaseFile(cid: string, id: string): Promise<void>;
49
+ }
@@ -0,0 +1,51 @@
1
+ export const emptyNativeDraft = () => ({ draft: '', messages: [], files: {} });
2
+ const queues = new Map();
3
+ const epochs = new Map();
4
+ /** Durable outgoing content only, isolated by endpoint, namespace and signed-in user. */
5
+ export class NativeMessagingOutbox {
6
+ files;
7
+ generation = 0;
8
+ scope;
9
+ constructor(endpoint, userId, files, namespace = 'default') {
10
+ this.files = files;
11
+ this.scope = JSON.stringify([endpoint, namespace, userId]);
12
+ }
13
+ serial(work) { const task = (queues.get(this.scope) ?? Promise.resolve()).catch(() => { }).then(work); queues.set(this.scope, task); void task.finally(() => { if (queues.get(this.scope) === task)
14
+ queues.delete(this.scope); }).catch(() => { }); return task; }
15
+ load(cid) { return this.serial(async () => { const saved = await this.files.read(this.scope, cid), used = new Set(saved.messages.map(m => m.fileId)); return { draft: saved.draft, messages: saved.messages.map(({ fileId, ...message }) => message), attachments: Object.entries(saved.files).filter(([id]) => !used.has(id)).map(([, f]) => ({ kind: f.kind, media: f.media })) }; }); }
16
+ put(cid, message) { return this.serial(async () => { const state = await this.files.read(this.scope, cid), previous = state.messages.find(m => m.clientMessageId === message.clientMessageId); state.messages = state.messages.filter(m => m.clientMessageId !== message.clientMessageId); state.messages.push({ ...message, fileId: message.needsPreparation ? message.content.media?.assetId : previous?.fileId }); await this.files.write(this.scope, cid, state); }); }
17
+ remove(cid, id) { return this.serial(async () => { const state = await this.files.read(this.scope, cid), fileId = state.messages.find(m => m.clientMessageId === id)?.fileId; state.messages = state.messages.filter(m => m.clientMessageId !== id); if (fileId && !state.messages.some(m => m.fileId === fileId)) {
18
+ delete state.files[fileId];
19
+ await this.files.write(this.scope, cid, state);
20
+ await this.files.remove(this.scope, cid, fileId);
21
+ }
22
+ else
23
+ await this.files.write(this.scope, cid, state); }); }
24
+ draft(cid, text) { return this.serial(async () => { const state = await this.files.read(this.scope, cid); state.draft = text.slice(0, 10000); await this.files.write(this.scope, cid, state); }); }
25
+ clear(cid) { this.generation++; epochs.set(this.scope, (epochs.get(this.scope) ?? 0) + 1); return this.serial(() => this.files.clear(this.scope, cid)); }
26
+ saveFile(cid, media, file, kind) {
27
+ const generation = this.generation, epoch = epochs.get(this.scope) ?? 0;
28
+ const current = () => generation === this.generation && epoch === (epochs.get(this.scope) ?? 0);
29
+ return this.serial(async () => {
30
+ if (!current())
31
+ throw Error('The chat closed before the attachment was saved.');
32
+ await this.files.copy(this.scope, cid, media.assetId, file);
33
+ try {
34
+ if (!current())
35
+ throw Error('The chat closed before the attachment was saved.');
36
+ const state = await this.files.read(this.scope, cid);
37
+ state.files[media.assetId] = { kind, media };
38
+ await this.files.write(this.scope, cid, state);
39
+ }
40
+ catch (error) {
41
+ await this.files.remove(this.scope, cid, media.assetId).catch(() => { });
42
+ throw error;
43
+ }
44
+ });
45
+ }
46
+ file(cid, id) { return this.serial(async () => { const saved = (await this.files.read(this.scope, cid)).files[id]; if (!saved)
47
+ return undefined; const uri = await this.files.file(this.scope, cid, id); if (!uri)
48
+ throw Error('This saved attachment is missing. Remove it and choose it again.'); return { ...saved.media, uri }; }); }
49
+ releaseFile(cid, id) { return this.serial(async () => { const state = await this.files.read(this.scope, cid); if (state.messages.some(m => m.fileId === id))
50
+ return; delete state.files[id]; await this.files.write(this.scope, cid, state); await this.files.remove(this.scope, cid, id); }); }
51
+ }
@@ -0,0 +1,3 @@
1
+ /** Show the latest selection immediately, but serialize complete selections so
2
+ * an older, slower request can never overwrite a newer vote on the server. */
3
+ export declare function createPollVoteQueue(save: (choices: number[]) => Promise<void>, pending: (choices: number[] | null) => void, failed: () => void): (choices: number[]) => Promise<void>;
@@ -0,0 +1,29 @@
1
+ /** Show the latest selection immediately, but serialize complete selections so
2
+ * an older, slower request can never overwrite a newer vote on the server. */
3
+ export function createPollVoteQueue(save, pending, failed) {
4
+ let running = false, next;
5
+ return async (choices) => {
6
+ next = [...choices];
7
+ pending(next);
8
+ if (running)
9
+ return;
10
+ running = true;
11
+ try {
12
+ while (next) {
13
+ const current = next;
14
+ next = undefined;
15
+ try {
16
+ await save(current);
17
+ }
18
+ catch {
19
+ if (!next)
20
+ failed();
21
+ }
22
+ }
23
+ }
24
+ finally {
25
+ running = false;
26
+ pending(null);
27
+ }
28
+ };
29
+ }
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ import { type MessagingAppearance, type MessagingMessage, type MessagePoll } from '@robono/messaging';
3
+ /** Optional reusable poll content. Votes are submitted as a complete selection, not a toggle request. */
4
+ export declare function PollMessage({ message, userId, ink, accent, enabled, onVote, onClose, resolveVoterName, background, foreground, renderDetails, detailsOpen, onDetailsOpenChange }: {
5
+ detailsOpen?: boolean;
6
+ onDetailsOpenChange?: (open: boolean) => void;
7
+ renderDetails?: (content: React.ReactNode, onClose: () => void) => React.ReactNode;
8
+ message: MessagingMessage;
9
+ userId: string;
10
+ ink: string;
11
+ accent: string;
12
+ enabled: boolean;
13
+ onVote: (options: number[]) => Promise<void>;
14
+ onClose: () => Promise<void>;
15
+ resolveVoterName?: (userId: string) => string;
16
+ background?: string;
17
+ foreground?: string;
18
+ }): React.JSX.Element | null;
19
+ export declare function PollComposer({ appearance, onSend, onCancel, retrySafe, yesNo }: {
20
+ yesNo?: boolean;
21
+ retrySafe?: boolean;
22
+ appearance: MessagingAppearance;
23
+ onSend: (poll: MessagePoll) => Promise<void>;
24
+ onCancel: () => void;
25
+ }): React.JSX.Element;
package/dist/polls.js ADDED
@@ -0,0 +1,121 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useRef, useState } from 'react';
3
+ import { createPollVoteQueue } from './pollVoteQueue';
4
+ import { ActivityIndicator, KeyboardAvoidingView, Platform, Pressable, ScrollView, Switch, Text, TextInput, View } from 'react-native';
5
+ import { validatePoll } from '@robono/messaging';
6
+ const PollArea = View;
7
+ import { MessagingPopup as PollModal } from './popup.js';
8
+ /** Optional reusable poll content. Votes are submitted as a complete selection, not a toggle request. */
9
+ export function PollMessage({ message, userId, ink, accent, enabled, onVote, onClose, resolveVoterName, background = '#101010', foreground = '#fff', renderDetails, detailsOpen, onDetailsOpenChange }) {
10
+ const [busy, setBusy] = useState(false), [error, setError] = useState(''), [localShowVotes, setLocalShowVotes] = useState(false);
11
+ const showVotes = detailsOpen ?? localShowVotes;
12
+ const setShowVotes = (open) => { setLocalShowVotes(open); onDetailsOpenChange?.(open); };
13
+ const [pending, setPending] = useState(null);
14
+ const current = useRef({ onVote });
15
+ current.current = { onVote };
16
+ const selection = useRef([]);
17
+ const queue = useRef(null);
18
+ if (!queue.current)
19
+ queue.current = createPollVoteQueue(choices => current.current.onVote(choices), choices => { if (choices)
20
+ selection.current = choices; setPending(choices); }, () => setError('The poll could not be updated. Try again.'));
21
+ const actionBusy = useRef(false);
22
+ const poll = message.poll;
23
+ if (!poll)
24
+ return null;
25
+ if (poll.kind === 'yes_no')
26
+ return _jsx(YesNoQuestion, { message, userId, ink, accent, enabled, onVote, onClose, resolveVoterName, renderDetails, detailsOpen, onDetailsOpenChange, background, foreground });
27
+ const savedVotes = message.pollState?.votes ?? [], selected = pending ?? savedVotes.find(v => v.userId === userId)?.options ?? [], closed = !!message.pollState?.closed;
28
+ selection.current = selected;
29
+ const votes = pending === null ? savedVotes : [...savedVotes.filter(v => v.userId !== userId), ...(pending.length ? [{ userId, options: pending }] : [])];
30
+ async function act(work) { if (actionBusy.current)
31
+ return; actionBusy.current = true; setBusy(true); setError(''); try {
32
+ await work();
33
+ }
34
+ catch {
35
+ setError('The poll could not be updated. Try again.');
36
+ }
37
+ finally {
38
+ actionBusy.current = false;
39
+ setBusy(false);
40
+ } }
41
+ const details = _jsxs(View, { style: { gap: 14 }, children: [_jsxs(View, { style: { gap: 4 }, children: [_jsx(Text, { style: { fontSize: 17, lineHeight: 23, fontWeight: '600', color: foreground }, children: poll.question }), _jsxs(Text, { style: { fontSize: 12, lineHeight: 17, color: foreground, opacity: .65 }, children: [votes.length, " ", votes.length === 1 ? 'person voted' : 'people voted', " \u00B7 ", closed ? 'Closed' : poll.multiple ? 'Multiple answers' : 'One answer per person'] })] }), _jsx(View, { style: { gap: 14 }, children: poll.options.map((option, index) => {
42
+ const people = votes.filter(v => v.options.includes(index));
43
+ const names = people.map(v => v.userId === userId ? 'You' : resolveVoterName?.(v.userId) || 'Chat member').join(', ');
44
+ return _jsxs(View, { style: { gap: 5 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'baseline', gap: 12 }, children: [_jsx(Text, { style: { flex: 1, color: foreground, fontSize: 15, lineHeight: 21, fontWeight: '600' }, children: option }), _jsx(Text, { style: { color: foreground, opacity: .65, fontSize: 13 }, children: people.length })] }), _jsxs(View, { style: { height: 4, borderRadius: 2, overflow: 'hidden' }, children: [_jsx(View, { style: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, backgroundColor: foreground, opacity: .12 } }), _jsx(View, { style: { height: 4, width: `${votes.length ? people.length / votes.length * 100 : 0}%`, backgroundColor: accent } })] }), !!names && _jsx(Text, { style: { color: foreground, opacity: .7, fontSize: 13, lineHeight: 18 }, children: names })] }, index);
45
+ }) }), message.senderId === userId && !closed && enabled && _jsx(Pressable, { accessibilityRole: "button", disabled: busy || pending !== null, onPress: () => void act(onClose), style: { minHeight: 36, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 14 }, children: "End poll" }) }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: foreground, fontSize: 13, lineHeight: 18 }, children: error })] });
46
+ return _jsxs(_Fragment, { children: [_jsxs(Pressable, { accessible: false, onPress: event => { event.stopPropagation(); setShowVotes(true); }, accessibilityLabel: `Poll: ${poll.question}. Open votes`, style: { width: 270, maxWidth: '100%', gap: 0, padding: 0 }, children: [_jsxs(Text, { accessibilityRole: "button", onPress: () => setShowVotes(true), style: { fontSize: 17, lineHeight: 23, fontWeight: '600', color: ink }, accessibilityHint: closed ? 'Poll closed' : poll.multiple ? 'Choose one or more answers' : 'Choose one answer', children: [poll.question, closed ? ' · Closed' : ''] }), poll.options.map((label, index) => {
47
+ const count = votes.filter(v => v.options.includes(index)).length, checked = selected.includes(index);
48
+ return _jsxs(View, { style: { minHeight: 36, paddingVertical: 3, gap: 2 }, children: [_jsxs(View, { style: { flexDirection: 'row', gap: 10, alignItems: 'center' }, children: [_jsx(Pressable, { accessibilityRole: poll.multiple ? 'checkbox' : 'radio', accessibilityLabel: `${label}, ${count} ${count === 1 ? 'vote' : 'votes'}`, "aria-checked": checked, accessibilityState: { checked, disabled: !enabled || closed || busy, busy: pending !== null }, hitSlop: 4, disabled: !enabled || closed || busy, onPress: event => { event.stopPropagation(); if (!enabled || closed || busy || actionBusy.current)
49
+ return; const previous = selection.current; setError(''); void queue.current(previous.includes(index) ? previous.filter(x => x !== index) : poll.multiple ? [...previous, index] : [index]); }, style: { width: 20, height: 20, borderRadius: poll.multiple ? 4 : 10, borderWidth: 2, borderColor: checked ? accent : ink, alignItems: 'center', justifyContent: 'center' }, children: checked && _jsx(View, { style: { width: 10, height: 10, borderRadius: poll.multiple ? 2 : 5, backgroundColor: accent } }) }), _jsx(Text, { style: { flex: 1, color: ink, fontSize: 16 }, children: label }), _jsx(Text, { style: { color: ink, fontSize: 13 }, children: count })] }), _jsx(View, { pointerEvents: "none", style: { height: 3, marginLeft: 30, backgroundColor: ink + '20', borderRadius: 2, overflow: 'hidden' }, children: _jsx(View, { style: { height: 3, width: `${votes.length ? count / votes.length * 100 : 0}%`, backgroundColor: accent } }) })] }, index);
50
+ }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: ink }, children: error })] }), showVotes && (renderDetails ? renderDetails(details, () => setShowVotes(false)) : _jsx(PollModal, { background: background, onClose: () => setShowVotes(false), children: _jsxs(PollArea, { style: { flexShrink: 1, backgroundColor: background }, children: [_jsxs(View, { style: { height: 56, paddingHorizontal: 20, flexDirection: 'row', alignItems: 'center', gap: 16 }, children: [_jsx(Text, { accessibilityRole: "header", style: { color: foreground, fontSize: 22, fontWeight: '600', flex: 1 }, children: "Votes" }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close votes", onPress: () => setShowVotes(false), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 17 }, children: "Done" }) })] }), _jsx(ScrollView, { contentContainerStyle: { padding: 16 }, children: details })] }) }))] });
51
+ }
52
+ /** Recipient-only decision. Keep the attempted answer on an uncertain failure so
53
+ * retry cannot silently switch an answer the server may already have accepted. */
54
+ function YesNoQuestion({ message, userId, ink, accent, enabled, onVote, onClose, resolveVoterName, renderDetails, detailsOpen, onDetailsOpenChange, background = '#111111', foreground = '#fff' }) {
55
+ const [attempt, setAttempt] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState(''), [localOpen, setLocalOpen] = useState(false);
56
+ const inFlight = useRef(false), firstChoice = useRef(null);
57
+ const poll = message.poll, answer = message.pollState?.votes.find(v => v.userId !== message.senderId)?.options[0];
58
+ const closed = !!message.pollState?.closed, chosen = answer ?? attempt, mine = message.senderId === userId;
59
+ const open = detailsOpen ?? localOpen;
60
+ const setOpen = (value) => { setLocalOpen(value); onDetailsOpenChange?.(value); };
61
+ async function choose(index) {
62
+ if (inFlight.current || !enabled || mine || closed)
63
+ return;
64
+ if (firstChoice.current !== null && firstChoice.current !== index)
65
+ return;
66
+ firstChoice.current = index;
67
+ inFlight.current = true;
68
+ setAttempt(index);
69
+ setBusy(true);
70
+ setError('');
71
+ setOpen(false);
72
+ try {
73
+ await onVote([index]);
74
+ }
75
+ catch {
76
+ setError('Answer not confirmed. Tap to retry.');
77
+ }
78
+ finally {
79
+ inFlight.current = false;
80
+ setBusy(false);
81
+ }
82
+ }
83
+ const respondent = message.pollState?.votes.find(v => v.userId !== message.senderId)?.userId;
84
+ const details = _jsxs(View, { style: { gap: 12 }, children: [_jsx(Text, { style: { color: foreground, fontSize: 17, lineHeight: 23, fontWeight: '600' }, children: poll.question }), _jsx(Text, { style: { color: foreground, fontSize: 15, lineHeight: 21 }, children: answer !== undefined ? `${respondent === userId ? 'You' : resolveVoterName?.(respondent) || 'Recipient'} answered ${poll.options[answer]}.` : closed ? 'Closed without an answer.' : attempt !== null ? 'Confirming your answer…' : 'Waiting for an answer.' }), mine && !closed && enabled && _jsx(Pressable, { accessibilityRole: "button", onPress: () => { if (inFlight.current)
85
+ return; inFlight.current = true; void onClose().catch(() => setError('Could not close the question. Try again.')).finally(() => { inFlight.current = false; }); }, style: { minHeight: 36, alignSelf: 'flex-end', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: "End question" }) })] });
86
+ return _jsxs(_Fragment, { children: [_jsxs(Pressable, { accessible: false, onPress: e => { e.stopPropagation(); setOpen(true); }, style: { maxWidth: '100%', width: 270 }, children: [_jsx(Text, { accessibilityRole: "button", onPress: () => setOpen(true), style: { fontSize: 17, lineHeight: 23, fontWeight: '600', color: ink }, children: poll.question }), _jsx(View, { style: { flexDirection: 'row', gap: 20, paddingVertical: 6 }, children: poll.options.map((label, index) => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: label, accessibilityState: { checked: chosen === index, disabled: !enabled || mine || closed || attempt !== null, busy }, disabled: !enabled || mine || closed || attempt !== null, onPress: e => { e.stopPropagation(); void choose(index); }, style: { flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 24 }, children: [_jsx(View, { style: { width: 20, height: 20, borderRadius: 10, borderWidth: 2, borderColor: chosen === index ? accent : ink, alignItems: 'center', justifyContent: 'center', opacity: chosen === undefined && mine ? .5 : 1 }, children: chosen === index && _jsx(View, { style: { width: 10, height: 10, borderRadius: 5, backgroundColor: accent } }) }), _jsx(Text, { style: { color: ink, fontSize: 16, fontWeight: chosen === index ? '600' : '400' }, children: label })] }, index)) }), !!error && !closed && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Retry answer", disabled: busy || closed || attempt === null, onPress: e => { e.stopPropagation(); if (attempt !== null)
87
+ void choose(attempt); }, children: _jsx(Text, { accessibilityRole: "alert", style: { color: ink, fontSize: 13 }, children: error }) })] }), open && (renderDetails ? renderDetails(details, () => setOpen(false)) : _jsxs(PollModal, { background: background, onClose: () => setOpen(false), children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', marginBottom: 12 }, children: [_jsx(Text, { accessibilityRole: "header", style: { flex: 1, color: foreground, fontSize: 16, fontWeight: '600' }, children: "Answer" }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close answer", onPress: () => setOpen(false), style: { minWidth: 44, minHeight: 44, justifyContent: 'center', alignItems: 'center' }, children: _jsx(Text, { style: { color: foreground, fontSize: 24 }, children: "\u00D7" }) })] }), _jsx(ScrollView, { children: details })] }))] });
88
+ }
89
+ export function PollComposer({ appearance, onSend, onCancel, retrySafe = false, yesNo = false }) {
90
+ const [question, setQuestion] = useState(''), [options, setOptions] = useState(['', '']), [multiple, setMultiple] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(''), [submitted, setSubmitted] = useState(false);
91
+ const c = appearance.colors;
92
+ async function send() { if (busy || submitted)
93
+ return; let poll; try {
94
+ poll = validatePoll(yesNo ? { question, options: ['Yes', 'No'], multiple: false, kind: 'yes_no' } : { question, options, multiple });
95
+ }
96
+ catch (e) {
97
+ setError(e.message);
98
+ return;
99
+ } setBusy(true); setSubmitted(true); try {
100
+ await onSend(poll);
101
+ onCancel();
102
+ }
103
+ catch {
104
+ if (retrySafe)
105
+ setSubmitted(false);
106
+ setError(retrySafe ? 'The poll was not sent. Try again.' : 'The poll was not sent. Check the conversation for a retry.');
107
+ }
108
+ finally {
109
+ setBusy(false);
110
+ } }
111
+ const inputStyle = { fontSize: 17, color: c.foreground, minHeight: 48, borderBottomWidth: 1, borderBottomColor: c.secondary + '40', paddingVertical: 12 };
112
+ const web = true;
113
+ let ready = false;
114
+ try {
115
+ validatePoll(yesNo ? { question, options: ['Yes', 'No'], multiple: false, kind: 'yes_no' } : { question, options, multiple });
116
+ ready = true;
117
+ }
118
+ catch { }
119
+ return _jsx(PollModal, { form: true, background: c.background, onClose: () => { if (!busy)
120
+ onCancel(); }, children: _jsxs(PollArea, { style: { ...(web ? { flexShrink: 1 } : { flex: 1 }), backgroundColor: c.background }, children: [_jsxs(View, { style: { height: web ? 36 : 56, paddingHorizontal: web ? 0 : 16, flexDirection: 'row', alignItems: 'center', gap: 16 }, children: [!web && _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: onCancel, style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: "Cancel" }) }), _jsx(Text, { accessibilityRole: "header", style: { flex: 1, fontSize: 20, fontWeight: '600', color: c.foreground }, children: yesNo ? 'Yes/No question' : 'New poll' }), !web && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: yesNo ? "Send question" : "Send poll", disabled: busy || submitted || !ready, onPress: () => void send(), style: { minHeight: 44, justifyContent: 'center', opacity: ready ? 1 : .4 }, children: busy ? _jsx(ActivityIndicator, { color: c.accent }) : _jsx(Text, { style: { color: c.accent, fontSize: 17, fontWeight: '600' }, children: "Send" }) })] }), _jsx(KeyboardAvoidingView, { style: web ? { flexShrink: 1 } : { flex: 1 }, behavior: Platform.OS === 'ios' ? 'padding' : 'height', children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", keyboardDismissMode: "on-drag", contentContainerStyle: { padding: web ? 0 : 16, paddingBottom: web ? 12 : 20, gap: 4 }, children: [_jsx(Text, { style: { color: c.secondary, fontSize: 14, marginTop: 12 }, children: "Question" }), _jsx(TextInput, { accessibilityLabel: "Poll question", placeholder: "Ask a question", placeholderTextColor: c.secondary, maxLength: 300, multiline: true, value: question, onChangeText: setQuestion, editable: !busy, style: inputStyle }), !yesNo && _jsxs(_Fragment, { children: [_jsx(Text, { style: { color: c.secondary, fontSize: 14, marginTop: 16 }, children: "Answers" }), options.map((option, i) => _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsx(TextInput, { accessibilityLabel: `Option ${i + 1}`, placeholder: "Add an option", placeholderTextColor: c.secondary, maxLength: 100, value: option, onChangeText: v => setOptions(items => items.map((x, n) => n === i ? v : x)), editable: !busy, style: { ...inputStyle, flex: 1 } }), options.length > 2 && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: `Remove option ${i + 1}`, disabled: busy, onPress: () => setOptions(items => items.filter((_, n) => n !== i)), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.secondary, fontSize: 26 }, children: "\u00D7" }) })] }, i)), options.length < 12 && _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => setOptions(items => [...items, '']), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 16 }, children: "Add option" }) }), _jsxs(View, { style: { minHeight: 48, flexDirection: 'row', gap: 12, alignItems: 'center', marginTop: 8 }, children: [_jsx(Text, { style: { color: c.foreground, fontSize: 16, flex: 1 }, children: "Allow multiple answers" }), _jsx(Switch, { accessibilityLabel: "Allow multiple answers", disabled: busy, value: multiple, onValueChange: setMultiple, trackColor: { true: c.accent } })] }), _jsx(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19 }, children: "Everyone in the chat can see the votes." })] }), yesNo && _jsx(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19, marginTop: 8 }, children: "Their first Yes or No answer is final." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error })] }) }), web && _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 8 }, children: [_jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Cancel", disabled: busy, onPress: onCancel, style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: c.secondary + '20', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16, fontWeight: '600' }, children: "Cancel" }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: yesNo ? "Send question" : "Send poll", disabled: busy || submitted || !ready, onPress: () => void send(), style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: c.accent, opacity: (busy || submitted || !ready) ? 0.45 : 1, alignItems: 'center', justifyContent: 'center' }, children: busy ? _jsx(ActivityIndicator, { color: "#fff" }) : _jsx(Text, { style: { color: '#fff', fontSize: 16, fontWeight: '600' }, children: "Send" }) })] })] }) });
121
+ }
@@ -0,0 +1,9 @@
1
+ import React from 'react';
2
+ export declare function MessagingPopup({ children, onClose, background, form, visible, onDismiss }: {
3
+ children: React.ReactNode;
4
+ onClose: () => void;
5
+ background: string;
6
+ form?: boolean;
7
+ visible?: boolean;
8
+ onDismiss?: () => void;
9
+ }): React.JSX.Element;
package/dist/popup.js ADDED
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { KeyboardAvoidingView, Modal, Platform, View } from 'react-native';
3
+ // Messenger's centered popup on every platform. Keyboard movement is contained
4
+ // by the scrim; the form itself must never become a separate full-screen route.
5
+ export function MessagingPopup({ children, onClose, background, form = false, visible = true, onDismiss }) {
6
+ return _jsx(Modal, { visible: visible, onDismiss: onDismiss, transparent: true, animationType: "fade", onRequestClose: onClose, children: _jsx(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: { flex: 1, backgroundColor: '#0009', justifyContent: 'center', alignItems: 'center', padding: 24 }, children: _jsx(View, { accessibilityViewIsModal: true, onAccessibilityEscape: onClose, style: { width: '100%', maxWidth: 360, maxHeight: '85%', backgroundColor: background, borderRadius: form ? 20 : 18, padding: form ? 20 : 16, overflow: 'hidden' }, children: children }) }) });
7
+ }
@@ -0,0 +1,21 @@
1
+ import type { MessagingMessage } from '@robono/messaging';
2
+ import type { LocalMessagingMedia, MessagingMediaStorage } from './media-controller.js';
3
+ /** Small, short-lived conversation cache. Every original is obtained through the
4
+ * authenticated storage adapter. Viewers receive their own disposable copy. */
5
+ export declare class RecentMessagingMedia {
6
+ private source;
7
+ private files;
8
+ private now;
9
+ private entries;
10
+ private running;
11
+ constructor(source: MessagingMediaStorage, files: {
12
+ copy: (file: LocalMessagingMedia) => Promise<LocalMessagingMedia>;
13
+ remove: (file: LocalMessagingMedia) => Promise<void>;
14
+ }, now?: () => number);
15
+ readonly storage: MessagingMediaStorage;
16
+ sync(messages: MessagingMessage[]): void;
17
+ clear(): void;
18
+ private drop;
19
+ private pump;
20
+ private start;
21
+ }
@@ -0,0 +1,123 @@
1
+ const key = (m) => JSON.stringify([m.conversationId, m.id, m.media]);
2
+ /** Small, short-lived conversation cache. Every original is obtained through the
3
+ * authenticated storage adapter. Viewers receive their own disposable copy. */
4
+ export class RecentMessagingMedia {
5
+ source;
6
+ files;
7
+ now;
8
+ entries = new Map();
9
+ running = 0;
10
+ constructor(source, files, now = Date.now) {
11
+ this.source = source;
12
+ this.files = files;
13
+ this.now = now;
14
+ }
15
+ storage = {
16
+ thumbnail: (message, context) => this.source.thumbnail?.(message, context) ?? Promise.resolve(null),
17
+ stream: (message, context) => this.source.stream?.(message, context) ?? Promise.resolve(null),
18
+ upload: (file, context) => this.source.upload(file, context), discard: id => this.source.discard(id),
19
+ download: async (message, context) => {
20
+ const id = key(message), entry = this.entries.get(id);
21
+ if (!entry)
22
+ return this.source.download(message, context);
23
+ if (context.signal.aborted)
24
+ throw Error('Cancelled.');
25
+ if (entry.file && entry.expires <= this.now()) {
26
+ this.drop(id, entry);
27
+ return this.source.download(message, context);
28
+ }
29
+ let file;
30
+ try {
31
+ file = await this.start(id, entry);
32
+ }
33
+ catch {
34
+ if (context.signal.aborted || this.entries.get(id) !== entry)
35
+ throw Error('Cancelled.');
36
+ // A failed speculative transfer must not make the user's explicit retry fail.
37
+ this.drop(id, entry);
38
+ return this.source.download(message, context);
39
+ }
40
+ if (context.signal.aborted || this.entries.get(id) !== entry)
41
+ throw Error('Cancelled.');
42
+ const copy = await this.files.copy(file);
43
+ if (context.signal.aborted || this.entries.get(id) !== entry) {
44
+ await this.files.remove(copy);
45
+ throw Error('Cancelled.');
46
+ }
47
+ context.onProgress(1);
48
+ return copy;
49
+ }
50
+ };
51
+ sync(messages) {
52
+ // At most six recent photos/voice clips/small videos, 2 MiB each / 6 MiB total.
53
+ // Large videos and documents remain tap-to-download. Never preload all history.
54
+ const wanted = new Map();
55
+ let bytes = 0;
56
+ for (const m of [...messages].reverse()) {
57
+ if (wanted.size >= 6)
58
+ break;
59
+ const size = m.media?.byteSize;
60
+ if (this.source.stream && ['voice', 'video'].includes(m.kind))
61
+ continue;
62
+ if (m.deleted || !['image', 'voice', 'video'].includes(m.kind) || !size || size > 2 * 1048576 || bytes + size > 6 * 1048576)
63
+ continue;
64
+ wanted.set(key(m), m);
65
+ bytes += size;
66
+ }
67
+ for (const [id, entry] of this.entries)
68
+ if (!wanted.has(id))
69
+ this.drop(id, entry);
70
+ for (const [id, message] of wanted) {
71
+ const entry = this.entries.get(id);
72
+ if (!entry)
73
+ this.entries.set(id, { message, abort: new AbortController(), expires: 0, attempted: false });
74
+ // The client replaces message objects when it receives refreshed server history.
75
+ // A re-render with the same objects must not extend this access window.
76
+ else if (entry.message !== message) {
77
+ entry.message = message;
78
+ if (entry.file)
79
+ entry.expires = this.now() + 60000;
80
+ }
81
+ }
82
+ this.pump();
83
+ }
84
+ clear() { for (const [id, entry] of this.entries)
85
+ this.drop(id, entry); }
86
+ drop(id, entry) {
87
+ if (this.entries.get(id) === entry)
88
+ this.entries.delete(id);
89
+ entry.abort.abort();
90
+ if (entry.file) {
91
+ void this.files.remove(entry.file).catch(() => { });
92
+ entry.file = undefined;
93
+ }
94
+ }
95
+ pump() {
96
+ for (const [id, entry] of this.entries) {
97
+ if (this.running >= 2)
98
+ break;
99
+ if (!entry.attempted) {
100
+ this.running++;
101
+ void this.start(id, entry).catch(() => { }).finally(() => { this.running--; this.pump(); });
102
+ }
103
+ }
104
+ }
105
+ start(id, entry) {
106
+ if (entry.file)
107
+ return Promise.resolve(entry.file);
108
+ if (entry.work)
109
+ return entry.work;
110
+ entry.attempted = true;
111
+ entry.work = (async () => {
112
+ const file = await this.source.download(entry.message, { signal: entry.abort.signal, onProgress: () => { } });
113
+ if (entry.abort.signal.aborted || this.entries.get(id) !== entry) {
114
+ await this.files.remove(file);
115
+ throw Error('Cancelled.');
116
+ }
117
+ entry.file = file;
118
+ entry.expires = this.now() + 60000;
119
+ return file;
120
+ })();
121
+ return entry.work;
122
+ }
123
+ }
@@ -0,0 +1,12 @@
1
+ import React from 'react';
2
+ import type { MessagingClient, MessagingAppearance, MessagingState } from '@robono/messaging';
3
+ /** Uses the authenticated messaging client's durable safety operations, or the host can keep its own UI. */
4
+ export declare function MessagingSafety({ client, state, appearance, name, messageId, onClose, onSearch }: {
5
+ client: MessagingClient;
6
+ state: MessagingState;
7
+ appearance: MessagingAppearance;
8
+ name: string;
9
+ messageId?: string;
10
+ onClose: () => void;
11
+ onSearch?: () => void;
12
+ }): React.JSX.Element;
package/dist/safety.js ADDED
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { KeyboardAvoidingView, Modal, Platform, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
4
+ /** Uses the authenticated messaging client's durable safety operations, or the host can keep its own UI. */
5
+ export function MessagingSafety({ client, state, appearance, name, messageId, onClose, onSearch }) {
6
+ const [mode, setMode] = useState(messageId ? 'report' : 'menu');
7
+ const [category, setCategory] = useState(''), [details, setDetails] = useState(''), [busy, setBusy] = useState(false), [error, setError] = useState('');
8
+ const reportRequest = useRef(null), reportContent = useRef('');
9
+ const [visible, setVisible] = useState(true), afterClose = useRef(null);
10
+ const dismissed = () => { const next = afterClose.current; afterClose.current = null; next?.(); };
11
+ useEffect(() => { if (!visible && Platform.OS !== 'ios')
12
+ dismissed(); }, [visible]);
13
+ useEffect(() => () => { afterClose.current = null; }, [client]);
14
+ const search = () => { afterClose.current = onSearch ?? null; setVisible(false); };
15
+ const c = appearance.colors, safety = state.conversation?.safety, blocked = safety?.blockedByMe;
16
+ const primaryInk = (() => { const hex = c.accent.replace('#', ''); const expanded = hex.length === 3 ? hex.split('').map(x => x + x).join('') : hex; const value = parseInt(expanded, 16); return Number.isFinite(value) && ((value >> 16) * .299 + ((value >> 8) & 255) * .587 + (value & 255) * .114) > 155 ? '#111111' : '#ffffff'; })();
17
+ const button = (label, press, disabled = false, kind = 'plain') => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label, accessibilityState: { disabled: busy || disabled }, disabled: busy || disabled, onPress: press, style: ({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, paddingVertical: 10, justifyContent: 'center', alignItems: kind === 'plain' ? 'flex-start' : 'center', borderRadius: 12, flex: kind === 'plain' ? undefined : 1, backgroundColor: kind === 'primary' ? c.accent : kind === 'danger' ? '#b72d34' : kind === 'cancel' ? c.incoming : 'transparent', opacity: (busy || disabled) ? 0.5 : pressed ? 0.7 : 1 }), children: _jsx(Text, { style: { color: kind === 'primary' ? primaryInk : kind === 'danger' ? '#fff' : c.foreground, fontSize: 16, fontWeight: kind === 'plain' ? '400' : '600' }, children: label }) });
18
+ async function block() { setBusy(true); setError(''); try {
19
+ await client.setBlocked(!blocked);
20
+ onClose();
21
+ }
22
+ catch {
23
+ setError('The change was not saved. Try again.');
24
+ }
25
+ finally {
26
+ setBusy(false);
27
+ } }
28
+ async function report() {
29
+ const reason = [category, details.trim()].filter(Boolean).join(': ');
30
+ if (!reason)
31
+ return;
32
+ setBusy(true);
33
+ setError('');
34
+ if (reportContent.current !== reason) {
35
+ reportContent.current = reason;
36
+ reportRequest.current = null;
37
+ }
38
+ // Stable across retries, including a response lost after the report was saved.
39
+ reportRequest.current ??= globalThis.crypto?.randomUUID?.() ?? `report-${Date.now()}-${Math.random().toString(36).slice(2)}`;
40
+ try {
41
+ await client.report({ reason, messageId, requestId: reportRequest.current });
42
+ setMode('sent');
43
+ }
44
+ catch {
45
+ setError('The report could not be confirmed. Try again.');
46
+ }
47
+ finally {
48
+ setBusy(false);
49
+ }
50
+ }
51
+ return _jsx(Modal, { visible: visible, onDismiss: dismissed, transparent: true, animationType: "fade", onRequestClose: () => { if (!busy)
52
+ onClose(); }, children: _jsx(KeyboardAvoidingView, { behavior: Platform.OS === 'ios' ? 'padding' : undefined, style: { flex: 1, backgroundColor: '#0009', justifyContent: 'center', alignItems: 'center', padding: 24 }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", accessibilityViewIsModal: true, style: { width: '100%', maxWidth: 360, flexGrow: 0, flexShrink: 1, maxHeight: '90%', backgroundColor: c.background, borderRadius: 20, borderWidth: 1, borderColor: c.secondary + '40' }, contentContainerStyle: { padding: 20, gap: 8 }, children: [_jsx(Text, { accessibilityRole: "header", style: { fontSize: 20, fontWeight: '600', color: c.foreground }, children: mode === 'menu' ? name : mode === 'block' ? `${blocked ? 'Unblock' : 'Block'} ${name}?` : mode === 'sent' ? 'Report sent' : messageId ? 'Report message' : 'Report conversation' }), mode === 'menu' && _jsxs(_Fragment, { children: [onSearch && button('Search messages', search), safety?.canBlock && button(blocked ? 'Unblock contact' : 'Block contact', () => setMode('block')), safety?.canReport && button('Report', () => setMode('report'))] }), mode === 'block' && _jsx(_Fragment, { children: _jsx(Text, { style: { fontSize: 14, lineHeight: 21, color: c.secondary }, children: blocked ? 'You will be able to message and call each other again. Messages attempted while blocked will not be delivered.' : 'This stops direct messages and calls between you. Your existing chat stays. They will not receive a notification that you blocked them. Shared group messages are not hidden.' }) }), mode === 'report' && _jsxs(_Fragment, { children: [_jsxs(Text, { style: { fontSize: 14, lineHeight: 21, color: c.secondary }, children: [messageId ? 'This message and your reason will be shared with this app’s moderation team.' : 'Up to five recent messages from other people in this conversation and your reason will be shared with this app’s moderation team.', " Report details are kept for up to 30 days. Reporting does not block this contact."] }), ['Spam', 'Harassment', 'Inappropriate content', 'Other'].map(value => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: value, accessibilityState: { checked: category === value }, disabled: busy, onPress: () => setCategory(value), style: { minHeight: 40, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [_jsx(View, { style: { width: 18, height: 18, borderRadius: 9, borderWidth: 2, borderColor: c.accent, backgroundColor: category === value ? c.accent : 'transparent' } }), _jsx(Text, { style: { color: c.foreground, fontSize: 15 }, children: value })] }, value)), _jsx(TextInput, { accessibilityLabel: "Report details", placeholder: "Tell us what happened (optional)", placeholderTextColor: c.secondary, value: details, onChangeText: setDetails, multiline: true, maxLength: 1800, editable: !busy, style: { color: c.foreground, minHeight: 80, borderWidth: 1, borderColor: c.secondary + '60', borderRadius: 10, padding: 12, fontSize: 15 } })] }), mode === 'sent' && _jsx(Text, { style: { color: c.secondary, fontSize: 15, lineHeight: 21 }, children: "Your report was saved for review. The other person is not notified." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 8 }, children: [mode !== 'sent' && button('Cancel', onClose, false, 'cancel'), mode === 'block' && button(blocked ? 'Unblock' : 'Block', () => void block(), false, blocked ? 'primary' : 'danger'), mode === 'report' && button(busy ? 'Sending…' : 'Send report', () => void report(), !category && !details.trim(), 'primary'), mode === 'sent' && button('Done', onClose, false, 'primary')] })] }) }) });
53
+ }
@@ -0,0 +1,10 @@
1
+ import React from 'react';
2
+ import type { MessagingAppearance, SavedMessage } from '@robono/messaging';
3
+ /** Same compact popup and media viewer as the conversation; no server-history copy. */
4
+ export declare function SavedMessagesPopup({ records, appearance, onClose, onOpen, onRemove }: {
5
+ records: SavedMessage[];
6
+ appearance: MessagingAppearance;
7
+ onClose: () => void;
8
+ onOpen: (record: SavedMessage) => Promise<void>;
9
+ onRemove: (key: string) => Promise<void>;
10
+ }): React.JSX.Element;