@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
package/app.plugin.cjs ADDED
@@ -0,0 +1,5 @@
1
+ const {withDangerousMod,createRunOncePlugin} = require('expo/config-plugins');
2
+ module.exports=createRunOncePlugin(config=>withDangerousMod(config,['ios',async value=>{
3
+ require('./plugin/preserve-call-audio.cjs').install(value.modRequest.projectRoot);
4
+ return value;
5
+ }]),'@robono/messaging-react-native',require('./package.json').version);
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ /** A new, reviewed message after no answer. Never records or sends a call automatically. */
3
+ export declare function UnansweredCallMessage({ callId, onDismiss }: {
4
+ callId: string;
5
+ onDismiss: () => void;
6
+ }): React.JSX.Element | null;
@@ -0,0 +1,109 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { Pressable, Text, View } from 'react-native';
4
+ import { useMessagingUI } from './index.js';
5
+ /** A new, reviewed message after no answer. Never records or sends a call automatically. */
6
+ export function UnansweredCallMessage({ callId, onDismiss }) {
7
+ const { client, design, state, pickAttachment, releaseAttachment, components } = useMessagingUI();
8
+ const [option, setOption] = useState(null);
9
+ const [draft, setDraft] = useState(null);
10
+ const [busy, setBusy] = useState(false), [error, setError] = useState(''), [attempt, setAttempt] = useState(0);
11
+ const generation = useRef(0), inFlight = useRef(false), prepared = useRef(null);
12
+ const release = useRef(releaseAttachment);
13
+ release.current = releaseAttachment;
14
+ function clearDraft() {
15
+ const media = prepared.current;
16
+ prepared.current = null;
17
+ setDraft(null);
18
+ if (media)
19
+ void release.current?.(media);
20
+ }
21
+ useEffect(() => {
22
+ const id = ++generation.current;
23
+ clearDraft();
24
+ setOption(null);
25
+ setError('');
26
+ setBusy(false);
27
+ void client.unansweredCall(callId).then(o => { if (id === generation.current)
28
+ setOption(o); })
29
+ .catch(() => { if (id === generation.current)
30
+ setError('Could not check message availability.'); });
31
+ return () => { generation.current++; const media = prepared.current; prepared.current = null; if (media)
32
+ void release.current?.(media); };
33
+ }, [client, callId, state.conversationId, attempt]);
34
+ async function record() {
35
+ if (inFlight.current || !option || !pickAttachment)
36
+ return;
37
+ const id = generation.current, conversation = state.conversationId;
38
+ inFlight.current = true;
39
+ setBusy(true);
40
+ setError('');
41
+ try {
42
+ const current = await client.unansweredCall(callId);
43
+ if (id !== generation.current)
44
+ return;
45
+ if (!current || current.kind !== option.kind) {
46
+ clearDraft();
47
+ setOption(current);
48
+ return;
49
+ }
50
+ const media = await pickAttachment(current.kind, { maxDurationMs: current.maxDurationMs, ...(current.kind === 'video' ? { source: 'call-video' } : {}), intent: 'attach' });
51
+ if (!media)
52
+ return;
53
+ if (id !== generation.current || client.getSnapshot().conversationId !== conversation) {
54
+ void release.current?.(media);
55
+ return;
56
+ }
57
+ clearDraft();
58
+ prepared.current = media;
59
+ setDraft({ kind: current.kind, media });
60
+ }
61
+ catch {
62
+ if (id === generation.current)
63
+ setError('Could not prepare your message. Try again.');
64
+ }
65
+ finally {
66
+ inFlight.current = false;
67
+ if (id === generation.current)
68
+ setBusy(false);
69
+ }
70
+ }
71
+ async function send() {
72
+ if (inFlight.current || !draft)
73
+ return;
74
+ inFlight.current = true;
75
+ setBusy(true);
76
+ setError('');
77
+ const id = generation.current;
78
+ try {
79
+ const current = await client.unansweredCall(callId);
80
+ if (id !== generation.current)
81
+ return;
82
+ if (!current || current.kind !== draft.kind) {
83
+ clearDraft();
84
+ setOption(current);
85
+ return;
86
+ }
87
+ // The client now owns the stable retry identity. Unmounting only releases
88
+ // local preview resources, never the uploaded asset needed by a retry.
89
+ const work = client.send({ kind: draft.kind, media: draft.media, unansweredCallId: callId });
90
+ clearDraft();
91
+ onDismiss();
92
+ await work;
93
+ }
94
+ catch {
95
+ if (id === generation.current)
96
+ setError('The message could not be sent. Check the conversation for a retry.');
97
+ }
98
+ finally {
99
+ inFlight.current = false;
100
+ if (id === generation.current)
101
+ setBusy(false);
102
+ }
103
+ }
104
+ if (!pickAttachment || !state.settings?.enabled || state.conversation?.canSend === false || (!option && !error))
105
+ return null;
106
+ const c = design.colors, VoiceAttachment = components?.VoiceAttachment;
107
+ const action = (label, onPress) => _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: onPress, style: { paddingVertical: 12, paddingHorizontal: 4, minHeight: 44 }, children: _jsx(Text, { style: { color: c.incomingText, fontWeight: '600' }, children: label }) });
108
+ return _jsxs(View, { style: { backgroundColor: c.incoming, padding: 16, borderTopWidth: 1, borderTopColor: c.secondary + '30', gap: 6 }, children: [_jsx(Text, { style: { color: c.incomingText, fontSize: 16, fontWeight: '600' }, children: "No answer" }), option && _jsx(Text, { style: { color: c.incomingText, fontSize: 14 }, children: draft ? 'Review your message, then send it.' : `Leave a ${option.kind === 'voice' ? 'voice' : 'video'} message in this chat.` }), draft && _jsx(View, { children: draft.kind === 'voice' && VoiceAttachment ? _jsx(VoiceAttachment, { media: draft.media, appearance: design, disabled: busy, onRemove: clearDraft }) : _jsxs(Text, { style: { color: c.incomingText }, children: [draft.kind === 'voice' ? 'Voice' : 'Video', " message ready"] }) }), _jsxs(View, { style: { flexDirection: 'row', gap: 16, flexWrap: 'wrap' }, children: [option && (draft ? action('Send message', () => void send()) : action(`Record ${option.kind === 'voice' ? 'voice' : 'video'} message`, () => void record())), !option && error && action('Try again', () => setAttempt(value => value + 1)), action('Not now', () => { clearDraft(); onDismiss(); })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.incomingText }, children: error })] });
109
+ }
@@ -0,0 +1,15 @@
1
+ import React from 'react';
2
+ import { type MessagingScreenProps } from './index.js';
3
+ export { UnansweredCallMessage } from './call-message.js';
4
+ /** Optional call entry in the standard message header. The host controls eligibility and admission. */
5
+ export declare function MessagingCallHeader(): React.JSX.Element;
6
+ export interface MessagingCallingScreenProps extends MessagingScreenProps {
7
+ /** Supply only when this contact is eligible. Never bypass server-side permissions. */
8
+ onCall?: () => void;
9
+ callLabel?: string;
10
+ onVideoCall?: () => void;
11
+ videoCallLabel?: string;
12
+ unansweredCallId?: string;
13
+ onDismissCallMessage?: () => void;
14
+ }
15
+ export declare function MessagingCallingScreen({ onCall, onVideoCall, videoCallLabel, callLabel, components, unansweredCallId, onDismissCallMessage, ...props }: MessagingCallingScreenProps): React.JSX.Element;
@@ -0,0 +1,22 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { createContext, useContext, useSyncExternalStore } from 'react';
3
+ import { Pressable, View, Text } from 'react-native';
4
+ import { RobonoIcon } from './visual/RobonoIcon.js';
5
+ import { messagingHeaderColors, MessagingScreen, MessagingProvider, MessagingHeader, MessagingComposer, useMessagingUI } from './index.js';
6
+ import { UnansweredCallMessage } from './call-message.js';
7
+ export { UnansweredCallMessage } from './call-message.js';
8
+ const Action = createContext(null);
9
+ const FollowUp = createContext(null);
10
+ function CallingMessageComposer() { const follow = useContext(FollowUp); return _jsxs(_Fragment, { children: [follow && _jsx(UnansweredCallMessage, { callId: follow.callId, onDismiss: follow.dismiss }), _jsx(MessagingComposer, {})] }); }
11
+ /** Optional call entry in the standard message header. The host controls eligibility and admission. */
12
+ export function MessagingCallHeader() {
13
+ const action = useContext(Action), { design, state } = useMessagingUI();
14
+ const allowed = state.conversation?.canSend !== false;
15
+ return _jsxs(MessagingHeader, { children: [allowed && action?.onCall && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: action.label, onPress: action.onCall, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(RobonoIcon, { name: "phone", size: 24, color: messagingHeaderColors(design, state.conversation?.kind === 'group').foreground }) }), allowed && action?.onVideoCall && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: action.videoLabel, onPress: action.onVideoCall, style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(RobonoIcon, { name: "video", size: 24, color: messagingHeaderColors(design, state.conversation?.kind === 'group').foreground }) })] });
16
+ }
17
+ export function MessagingCallingScreen({ onCall, onVideoCall, videoCallLabel = 'Video call', callLabel = 'Voice call', components, unansweredCallId, onDismissCallMessage, ...props }) {
18
+ const state = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
19
+ const design = props.appearance ?? state.settings?.appearance;
20
+ const callingOnly = (onCall || onVideoCall) && design && state.settings && !state.settings.enabled;
21
+ return _jsx(Action.Provider, { value: onCall || onVideoCall ? { onCall, onVideoCall, label: callLabel, videoLabel: videoCallLabel } : null, children: _jsx(FollowUp.Provider, { value: unansweredCallId && onDismissCallMessage ? { callId: unansweredCallId, dismiss: onDismissCallMessage } : null, children: callingOnly ? _jsx(MessagingProvider, { ...props, children: _jsxs(View, { style: [{ flex: 1, backgroundColor: design.colors.background }, props.style], children: [_jsx(MessagingCallHeader, {}), _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 24, padding: 24 }, children: [_jsx(Text, { style: { fontSize: 28, fontWeight: '600', color: design.colors.foreground }, children: props.contact.name }), onCall && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Start ${callLabel.toLowerCase()}`, onPress: onCall, style: { minHeight: 56, minWidth: 160, borderRadius: 28, backgroundColor: design.colors.incoming, alignItems: 'center', justifyContent: 'center', padding: 16, flexDirection: 'row', gap: 12 }, children: [_jsx(RobonoIcon, { name: "phone", size: 24, color: design.colors.incomingText }), _jsx(Text, { style: { fontSize: 18, color: design.colors.incomingText }, children: callLabel })] }), onVideoCall && _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Start ${videoCallLabel.toLowerCase()}`, onPress: onVideoCall, style: { minHeight: 56, padding: 16, borderRadius: 28, backgroundColor: design.colors.incoming, flexDirection: 'row', alignItems: 'center', gap: 12 }, children: [_jsx(RobonoIcon, { name: "video", size: 24, color: design.colors.incomingText }), _jsx(Text, { style: { color: design.colors.incomingText, fontSize: 18 }, children: videoCallLabel })] })] })] }) }) : _jsx(MessagingScreen, { ...props, components: { Header: MessagingCallHeader, Composer: CallingMessageComposer, ...components } }) }) });
22
+ }
@@ -0,0 +1,6 @@
1
+ import React from 'react';
2
+ import { type ExpoMessagingScreenProps } from './expo.js';
3
+ import { type MessagingCallingScreenProps } from './calling.js';
4
+ export type ExpoMessagingCallingScreenProps = Omit<ExpoMessagingScreenProps, 'renderConversation'> & Pick<MessagingCallingScreenProps, 'onCall' | 'callLabel' | 'onVideoCall' | 'videoCallLabel' | 'unansweredCallId' | 'onDismissCallMessage'>;
5
+ /** Optional composition of the shared conversation, calling entry and native recording/review tools. */
6
+ export declare function ExpoMessagingCallingScreen({ onCall, callLabel, onVideoCall, videoCallLabel, unansweredCallId, onDismissCallMessage, ...props }: ExpoMessagingCallingScreenProps): React.JSX.Element;
@@ -0,0 +1,7 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ExpoMessagingScreen } from './expo.js';
3
+ import { MessagingCallingScreen } from './calling.js';
4
+ /** Optional composition of the shared conversation, calling entry and native recording/review tools. */
5
+ export function ExpoMessagingCallingScreen({ onCall, callLabel, onVideoCall, videoCallLabel, unansweredCallId, onDismissCallMessage, ...props }) {
6
+ return _jsx(ExpoMessagingScreen, { ...props, renderConversation: screen => _jsx(MessagingCallingScreen, { ...screen, onCall: onCall, callLabel: callLabel, onVideoCall: onVideoCall, videoCallLabel: videoCallLabel, unansweredCallId: unansweredCallId, onDismissCallMessage: onDismissCallMessage }) });
7
+ }
@@ -0,0 +1,3 @@
1
+ import type { LocalMessagingMedia, MediaKind } from './media-controller.js';
2
+ /** Export an already-authorized viewer copy; never expose its private source URL. */
3
+ export declare function saveMessagingAttachment(file: LocalMessagingMedia, kind: MediaKind): Promise<void>;
@@ -0,0 +1,43 @@
1
+ import { Platform } from 'react-native';
2
+ import * as FS from 'expo-file-system/legacy';
3
+ import * as Sharing from 'expo-sharing';
4
+ import * as MediaLibrary from 'expo-media-library';
5
+ /** Export an already-authorized viewer copy; never expose its private source URL. */
6
+ export async function saveMessagingAttachment(file, kind) {
7
+ if (kind === 'image' || kind === 'video') {
8
+ const permission = await MediaLibrary.requestPermissionsAsync(true, [kind === 'image' ? 'photo' : 'video']);
9
+ if (!permission.granted)
10
+ throw Error('Allow photo access to save this attachment.');
11
+ await MediaLibrary.saveToLibraryAsync(file.uri);
12
+ return;
13
+ }
14
+ if (kind !== 'document')
15
+ throw Error('This attachment cannot be downloaded.');
16
+ if (Platform.OS !== 'android') {
17
+ if (!await Sharing.isAvailableAsync())
18
+ throw Error('File saving is unavailable.');
19
+ await Sharing.shareAsync(file.uri, { mimeType: file.mimeType, dialogTitle: 'Save ' + file.fileName });
20
+ return;
21
+ }
22
+ const permission = await FS.StorageAccessFramework.requestDirectoryPermissionsAsync();
23
+ if (!permission.granted)
24
+ return;
25
+ const name = file.fileName.split(/[\\/]/).at(-1)?.replace(/[\u0000-\u001f\u007f]/g, '').trim() || 'Attachment';
26
+ const destination = await FS.StorageAccessFramework.createFileAsync(permission.directoryUri, name, file.mimeType);
27
+ try {
28
+ // Bounded chunks avoid keeping an entire large document and its base64 copy in memory.
29
+ const chunk = 3 * 256 * 1024;
30
+ for (let position = 0; position < file.byteSize; position += chunk) {
31
+ const length = Math.min(chunk, file.byteSize - position);
32
+ const data = await FS.readAsStringAsync(file.uri, { encoding: FS.EncodingType.Base64, position, length });
33
+ const bytes = data.length * 3 / 4 - (data.endsWith('==') ? 2 : data.endsWith('=') ? 1 : 0);
34
+ if (bytes !== length)
35
+ throw Error('The attachment could not be read completely.');
36
+ await FS.writeAsStringAsync(destination, data, { encoding: FS.EncodingType.Base64, append: position > 0 });
37
+ }
38
+ }
39
+ catch (error) {
40
+ await FS.deleteAsync(destination, { idempotent: true }).catch(() => { });
41
+ throw error;
42
+ }
43
+ }
@@ -0,0 +1,19 @@
1
+ import { type AudioMode } from 'expo-audio';
2
+ import type { LocalMessagingMedia, MessagingMediaDevice, MessagingMediaStorage } from './media-controller.js';
3
+ export interface MessagingAudioSession {
4
+ isAvailable?: (purpose?: 'recording' | 'playback') => boolean;
5
+ acquire(purpose: 'recording' | 'playback'): Promise<() => Promise<void>>;
6
+ }
7
+ /** Pass your app's call-state guard and restore mode, or supply your existing audio-session coordinator. */
8
+ export declare function createExpoMessagingAudioSession(options?: {
9
+ isCallActive?: () => boolean;
10
+ restoreMode?: Partial<AudioMode>;
11
+ }): MessagingAudioSession;
12
+ export declare function copyMessagingCache(file: LocalMessagingMedia): Promise<LocalMessagingMedia>;
13
+ export declare function createExpoMessagingDevice(audio: MessagingAudioSession): MessagingMediaDevice;
14
+ /** Private-storage transport. The endpoint is YOUR authenticated backend, never a Robono API key on the phone. */
15
+ export declare function createExpoMessagingStorage(options: {
16
+ url: string;
17
+ getAccessToken: () => Promise<string>;
18
+ streaming?: boolean;
19
+ }): MessagingMediaStorage;