@robono/messaging-react-native 0.5.0 → 0.7.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.
@@ -0,0 +1,16 @@
1
+ import React from 'react';
2
+ import { View } from 'react-native';
3
+ import { MessageIcon } from './icons.js';
4
+ export interface AttachmentMenuAction {
5
+ key: string;
6
+ label: string;
7
+ icon: React.ComponentProps<typeof MessageIcon>['name'];
8
+ run: () => void;
9
+ }
10
+ /** Messenger's 206 × 46 attachment chips, staggered above its plus button. */
11
+ export declare function AttachmentMenu({ anchor, actions, accent, onClose }: {
12
+ anchor: React.RefObject<View | null>;
13
+ actions: AttachmentMenuAction[];
14
+ accent: string;
15
+ onClose: () => void;
16
+ }): React.JSX.Element;
@@ -0,0 +1,71 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
3
+ import { Animated, BackHandler, Platform, Pressable, ScrollView, Text, View, useWindowDimensions } from 'react-native';
4
+ import { MessagingPopupLayer, useMessagingPopupBounds } from './popup.js';
5
+ import { MessageIcon } from './icons.js';
6
+ /** Messenger's 206 × 46 attachment chips, staggered above its plus button. */
7
+ export function AttachmentMenu({ anchor, actions, accent, onClose }) {
8
+ const bounds = useMessagingPopupBounds(), window = useWindowDimensions(), fade = useRef(new Animated.Value(0)).current;
9
+ const [position, setPosition] = useState(null);
10
+ const menu = useRef(null), close = useRef(onClose);
11
+ close.current = onClose;
12
+ useLayoutEffect(() => {
13
+ let live = true;
14
+ const frame = requestAnimationFrame(() => {
15
+ const root = bounds?.root.current, button = anchor.current;
16
+ if (!root || !button)
17
+ return;
18
+ root.measureInWindow((rx, ry, width, height) => button.measureInWindow((x, y, w, h) => { if (live)
19
+ setPosition({ x: x - rx + w / 2, y: y - ry + h / 2, width, height }); }));
20
+ });
21
+ return () => { live = false; cancelAnimationFrame(frame); };
22
+ }, [bounds, anchor, window.width, window.height]);
23
+ useEffect(() => { Animated.timing(fade, { toValue: 1, duration: 120, useNativeDriver: true, isInteraction: false }).start(); return () => fade.stopAnimation(); }, [fade]);
24
+ useEffect(() => {
25
+ if (Platform.OS !== 'web') {
26
+ const off = BackHandler.addEventListener('hardwareBackPress', () => { close.current(); return true; });
27
+ return () => off.remove();
28
+ }
29
+ const previous = document.activeElement;
30
+ const frame = requestAnimationFrame(() => { menu.current?.querySelector('[role="button"]')?.focus(); });
31
+ const key = (event) => {
32
+ const element = menu.current;
33
+ if (event.key === 'Escape') {
34
+ event.preventDefault();
35
+ event.stopPropagation();
36
+ close.current();
37
+ }
38
+ if (event.key === 'Tab' && element) {
39
+ const items = [...element.querySelectorAll('[role="button"]')];
40
+ const first = items[0], last = items.at(-1);
41
+ if (event.shiftKey && (document.activeElement === first || !element.contains(document.activeElement))) {
42
+ event.preventDefault();
43
+ last?.focus();
44
+ }
45
+ else if (!event.shiftKey && (document.activeElement === last || !element.contains(document.activeElement))) {
46
+ event.preventDefault();
47
+ first?.focus();
48
+ }
49
+ }
50
+ };
51
+ document.addEventListener('keydown', key, true);
52
+ return () => { cancelAnimationFrame(frame); document.removeEventListener('keydown', key, true); if (previous?.isConnected)
53
+ previous.focus(); };
54
+ }, [!!position]);
55
+ const node = useMemo(() => {
56
+ if (!position)
57
+ return null;
58
+ const compact = position.y - 80 < Math.max(0, actions.length - 1) * 46;
59
+ const listHeight = Math.min(Math.max(46, position.height - 28), actions.length * 54 - 8);
60
+ const listTop = Math.max(14, Math.min(position.height - listHeight - 14, position.y - listHeight - 30));
61
+ const Container = compact ? ScrollView : View;
62
+ return _jsxs(View, { style: { position: 'absolute', inset: 0, zIndex: 1000, elevation: 1000 }, children: [_jsx(Animated.View, { pointerEvents: "none", style: { position: 'absolute', inset: 0, backgroundColor: '#000', opacity: fade.interpolate({ inputRange: [0, 1], outputRange: [0, .38] }) } }), _jsx(Pressable, { accessibilityLabel: "Close attachment menu", onPress: onClose, style: { position: 'absolute', inset: 0 } }), _jsx(View, { ref: menu, accessibilityViewIsModal: true, style: { position: 'absolute', inset: 0 }, pointerEvents: "box-none", children: _jsx(Container, { style: compact ? { position: 'absolute', top: listTop, left: 10, right: 10, maxHeight: listHeight } : { position: 'absolute', inset: 0 }, ...(compact ? { contentContainerStyle: { gap: 8, paddingHorizontal: 4 }, keyboardShouldPersistTaps: 'always' } : { pointerEvents: 'box-none' }), children: (compact ? [...actions].reverse() : actions).map((action, index) => {
63
+ const chipWidth = Math.min(206, position.width - 20), step = Math.min(58, Math.max(46, (position.y - 80) / Math.max(1, actions.length - 1)));
64
+ return _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: action.label, onPress: () => { onClose(); if (Platform.OS === 'web')
65
+ action.run();
66
+ else
67
+ requestAnimationFrame(() => requestAnimationFrame(action.run)); }, style: { position: compact ? 'relative' : 'absolute', width: chipWidth, height: 46, flexShrink: 0, left: compact ? 0 : Math.max(10, Math.min(position.width - chipWidth - 10, position.x + 14 + index * 18)), top: compact ? 0 : Math.max(14, position.y - 66 - index * step), shadowColor: '#000', shadowOpacity: .42, shadowRadius: 15, shadowOffset: { width: 0, height: 7 }, elevation: 10 }, children: ({ pressed }) => _jsxs(Animated.View, { pointerEvents: "none", style: { flex: 1, opacity: fade, borderRadius: 23, borderWidth: 1, borderColor: '#ffffff1c', backgroundColor: pressed ? '#211D1A' : '#171717', flexDirection: 'row', alignItems: 'center', gap: 10, paddingStart: 7, paddingEnd: 12, transform: [{ scale: pressed ? .97 : 1 }] }, children: [_jsx(View, { style: { width: 32, height: 32, borderRadius: 11, borderWidth: .5, borderColor: accent + '38', backgroundColor: accent + '1a', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: action.icon, size: 21, color: accent }) }), _jsx(Text, { numberOfLines: 1, style: { flex: 1, color: '#ffffffeb', fontFamily: Platform.select({ ios: 'Avenir Next', android: 'sans-serif-medium' }), fontSize: 14, fontWeight: '600', letterSpacing: .12 }, children: action.label }), _jsx(MessageIcon, { name: "chevron-forward", size: 14, color: "#ffffff52" })] }) }, action.key);
68
+ }) }) })] });
69
+ }, [position, actions, accent, onClose, fade]);
70
+ return _jsx(MessagingPopupLayer, { children: node });
71
+ }
@@ -0,0 +1,32 @@
1
+ import React from 'react';
2
+ import { type MessengerCalling, type MessengerCallingState } from '@robono/calling-react-native/messenger';
3
+ import { type MessagingInboxClient } from '@robono/messaging';
4
+ export declare function createExpoMessengerCalling(options: {
5
+ endpoint: string;
6
+ getAccessToken: () => Promise<string>;
7
+ fetch?: typeof fetch;
8
+ people?: () => Record<string, {
9
+ name: string;
10
+ avatarUrl?: string;
11
+ }>;
12
+ inbox: MessagingInboxClient;
13
+ }): {
14
+ updateContacts(next: Record<string, {
15
+ name: string;
16
+ avatarUrl?: string;
17
+ }>): void;
18
+ start: () => Promise<void>;
19
+ getSnapshot: () => MessengerCallingState;
20
+ subscribe(fn: () => void): () => void;
21
+ isActive: () => boolean;
22
+ dismissUnanswered(): void;
23
+ notification(data: unknown, opened?: boolean): Promise<boolean>;
24
+ prime(): void;
25
+ call(conversationId: string, kind: "voice" | "video", group?: boolean): Promise<void>;
26
+ dispose(): Promise<void>;
27
+ };
28
+ export declare function useExpoMessengerCalling(calling?: MessengerCalling): MessengerCallingState;
29
+ export declare function ExpoMessengerCalls({ calling, state }: {
30
+ calling?: MessengerCalling;
31
+ state: MessengerCallingState;
32
+ }): React.JSX.Element;
@@ -0,0 +1,41 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { useEffect, useSyncExternalStore } from 'react';
3
+ import { Pressable, Text, View } from 'react-native';
4
+ import { setAudioModeAsync } from 'expo-audio';
5
+ import { createMessengerCalling } from '@robono/calling-react-native/messenger';
6
+ import { createNativeCallAdapters } from '@robono/calling-react-native/native';
7
+ import { createNativeCallRuntime } from '@robono/calling-react-native/runtime';
8
+ import { NativeCallingOverlay, NativeGroupCallingOverlay } from '@robono/calling-react-native/native-video';
9
+ import { createMessagingTransport } from '@robono/messaging';
10
+ export function createExpoMessengerCalling(options) {
11
+ const url = new URL(options.endpoint);
12
+ url.searchParams.set('service', 'calling');
13
+ let people = {};
14
+ const calling = createMessengerCalling({
15
+ request: createMessagingTransport({ ...options, url: url.href }),
16
+ resolveContact: id => {
17
+ const row = options.inbox.getSnapshot().conversations.find(c => c.id === id);
18
+ if (row?.kind === 'group')
19
+ return { name: row.name ?? 'Group' };
20
+ const person = (options.people?.() ?? people)[row?.contactId ?? ''];
21
+ return person ? { name: person.name, avatarUri: person.avatarUrl } : null;
22
+ },
23
+ resolveMember: (_app, id) => { const person = (options.people?.() ?? people)[id]; return person ? { name: person.name, avatarUri: person.avatarUrl } : null; },
24
+ adapters: appearance => {
25
+ const runtime = createNativeCallRuntime({ notificationTitle: 'Call in progress', notificationText: 'Tap to return to your call', channelName: 'Calls', endLabel: 'End call', onError: () => { } });
26
+ const adapter = createNativeCallAdapters({ appearance, runtime, ringtone: 'robono', onError: () => { }, restoreAudio: () => setAudioModeAsync({ allowsRecording: false, playsInSilentMode: true, shouldPlayInBackground: false, shouldRouteThroughEarpiece: false, interruptionMode: 'mixWithOthers' }) });
27
+ return { ...adapter, attach: runtime.attach, dispose: runtime.dispose };
28
+ },
29
+ });
30
+ return { ...calling, updateContacts(next) { people = next; } };
31
+ }
32
+ const absent = { direct: null, group: null, loading: false, error: '', features: { voice: false, video: false } };
33
+ const noSubscribe = () => () => { };
34
+ export function useExpoMessengerCalling(calling) {
35
+ const state = useSyncExternalStore(calling?.subscribe ?? noSubscribe, calling?.getSnapshot ?? (() => absent), calling?.getSnapshot ?? (() => absent));
36
+ useEffect(() => { void calling?.start().catch(() => { }); }, [calling]);
37
+ return state;
38
+ }
39
+ export function ExpoMessengerCalls({ calling, state }) {
40
+ return _jsxs(_Fragment, { children: [state.error && _jsxs(View, { style: { backgroundColor: '#171717', paddingHorizontal: 16, paddingVertical: 8, flexDirection: 'row', gap: 12, alignItems: 'center' }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: '#FAFAFA', flex: 1 }, children: state.error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void calling?.start().catch(() => { }), style: { padding: 8 }, children: _jsx(Text, { style: { color: '#FF8A2C' }, children: "Retry calls" }) })] }), state.direct && _jsx(NativeCallingOverlay, { controller: state.direct, appearance: state.appearance }), state.group && _jsx(NativeGroupCallingOverlay, { controller: state.group, appearance: state.appearance })] });
41
+ }
@@ -0,0 +1,18 @@
1
+ import React from 'react';
2
+ import { MessagingExtrasClient } from '@robono/messaging';
3
+ import type { TrackingMapProps } from './extras.js';
4
+ export declare function createExpoMessagingExtras(options: {
5
+ endpoint: string;
6
+ userId: string;
7
+ getAccessToken: () => Promise<string>;
8
+ fetch?: typeof fetch;
9
+ people?: () => Record<string, {
10
+ name: string;
11
+ avatarUrl?: string;
12
+ }>;
13
+ }): {
14
+ extras: MessagingExtrasClient;
15
+ TrackingMap: (props: TrackingMapProps) => React.JSX.Element;
16
+ signOut(): Promise<void>;
17
+ dispose(): Promise<void>;
18
+ };
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { prepareBackgroundTracking, captureBackgroundTracking, endBackgroundAccount } from './expo-tracking-task.js';
3
+ import * as Location from 'expo-location';
4
+ import Constants from 'expo-constants';
5
+ import * as SecureStore from 'expo-secure-store';
6
+ import * as Crypto from 'expo-crypto';
7
+ import { TrackingMap } from '@robono/location-react-native';
8
+ import { getForegroundPosition, ManagedMap } from '@robono/location-react-native/expo';
9
+ import { MessagingExtrasClient, createMessagingTransport, createRecoverableExtrasTransport } from '@robono/messaging';
10
+ const convert = (value) => ({ latitude: value.coords.latitude, longitude: value.coords.longitude, accuracyMeters: value.coords.accuracy ?? 500, capturedAt: value.timestamp, ...(value.coords.speed !== null && value.coords.speed >= 0 ? { speedMps: value.coords.speed } : {}) });
11
+ export function createExpoMessagingExtras(options) {
12
+ const url = new URL(options.endpoint);
13
+ url.searchParams.set('service', 'extras');
14
+ const cleanup = endBackgroundAccount(url.href, options.userId);
15
+ const storageKey = Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, url.href + '\n' + options.userId).then(hash => 'robono.tracking.recovery.' + hash);
16
+ const request = createRecoverableExtrasTransport(createMessagingTransport({ ...options, url: url.href }), { load: async () => { await cleanup; return JSON.parse(await SecureStore.getItemAsync(await storageKey) ?? '[]'); }, save: async (records) => { const key = await storageKey; if (records.length)
17
+ await SecureStore.setItemAsync(key, JSON.stringify(records));
18
+ else
19
+ await SecureStore.deleteItemAsync(key); } });
20
+ const background = Constants.expoConfig?.extra?.robonoMaps?.background === true;
21
+ const extras = new MessagingExtrasClient({ request, ...(background ? { backgroundCapture: true, prepareTracking: prepareBackgroundTracking, capture: (context, signal) => captureBackgroundTracking({ url: url.href, userId: options.userId, getAccessToken: options.getAccessToken }, context, signal) } : {}), people: options.people, createRequestId: () => Crypto.randomUUID(),
22
+ locate: async (signal) => { const p = await getForegroundPosition(signal); return { ...p, capturedAt: Date.parse(p.capturedAt), speedMps: p.speedMps ?? undefined }; },
23
+ watch: (receive, onError, signal) => { let subscription; const stop = () => { subscription?.remove(); signal.removeEventListener('abort', stop); }; signal.addEventListener('abort', stop, { once: true }); void Location.watchPositionAsync({ accuracy: Location.Accuracy.High, timeInterval: 10000, distanceInterval: 5 }, p => { if (!signal.aborted)
24
+ receive(convert(p)); }, () => { stop(); onError(); }).then(s => { subscription = s; if (signal.aborted)
25
+ stop(); }).catch(() => { stop(); onError(); }); },
26
+ });
27
+ function ConnectedTrackingMap(props) {
28
+ return _jsx(ManagedMap, { client: extras, children: onLoad => _jsx(TrackingMap, { ...props, onLoad: onLoad }) });
29
+ }
30
+ return { extras, TrackingMap: ConnectedTrackingMap, async signOut() { extras.dispose(); const results = await Promise.allSettled([request.cancelUnfinished(), endBackgroundAccount(url.href, options.userId)]); const failed = results.find(r => r.status === 'rejected'); if (failed?.status === 'rejected')
31
+ throw failed.reason; }, async dispose() { extras.dispose(); await endBackgroundAccount(url.href, options.userId); } };
32
+ }
@@ -110,7 +110,7 @@ export function createExpoMessagingDevice(audio) {
110
110
  let recordingDeadline = 0;
111
111
  let starting = null;
112
112
  let stopping = null;
113
- async function chooseFiles(kind, settings, signal, source, maximum) {
113
+ async function chooseFiles(kind, settings, signal, source, maximum, mixedLibrary = false) {
114
114
  if (source === 'camera' && (!['image', 'video'].includes(kind) || !settings.appearance.showCamera))
115
115
  throw Error('Camera capture is unavailable.');
116
116
  if (source === 'call-video' && (kind !== 'video' || !settings.enabled || !settings.features.video || settings.unansweredCalls?.video !== 'video'))
@@ -132,7 +132,7 @@ export function createExpoMessagingDevice(audio) {
132
132
  if (!permission.granted)
133
133
  throw Error('Camera permission is needed to take photos or record videos.');
134
134
  }
135
- const result = await (source ? Picker.launchCameraAsync : Picker.launchImageLibraryAsync)({ mediaTypes: source === 'call-video' ? ['videos'] : source ? [...((settings.features?.image ?? kind === 'image') ? ['images'] : []), ...(settings.features?.video ? ['videos'] : [])] : kind === 'image' ? ['images'] : ['videos'], ...(source ? { videoMaxDuration: settings.attachments?.video?.maxDurationSeconds ?? 60 } : {}), allowsMultipleSelection: !source && maximum > 1, selectionLimit: maximum, orderedSelection: true, allowsEditing: false, quality: 1, preferredAssetRepresentationMode: Picker.UIImagePickerPreferredAssetRepresentationMode.Current });
135
+ const result = await (source ? Picker.launchCameraAsync : Picker.launchImageLibraryAsync)({ mediaTypes: source === 'call-video' ? ['videos'] : source ? [...((settings.features?.image ?? kind === 'image') ? ['images'] : []), ...(settings.features?.video ? ['videos'] : [])] : mixedLibrary ? [...(settings.features.image ? ['images'] : []), ...(settings.features.video ? ['videos'] : [])] : kind === 'image' ? ['images'] : ['videos'], ...(source ? { videoMaxDuration: settings.attachments?.video?.maxDurationSeconds ?? 60 } : {}), allowsMultipleSelection: !source && maximum > 1, selectionLimit: maximum, orderedSelection: true, allowsEditing: false, quality: 1, preferredAssetRepresentationMode: Picker.UIImagePickerPreferredAssetRepresentationMode.Current });
136
136
  assets = result.assets ?? [];
137
137
  if (result.canceled)
138
138
  return [];
@@ -142,7 +142,7 @@ export function createExpoMessagingDevice(audio) {
142
142
  throw Error(`Choose up to ${maximum} attachments.`);
143
143
  for (const asset of assets) {
144
144
  check(signal);
145
- const video = kind === 'video' || !!source && (asset.type === 'video' || asset.mimeType?.startsWith('video/'));
145
+ const video = (mixedLibrary || !!source) ? asset.type === 'video' || !!asset.mimeType?.startsWith('video/') : kind === 'video';
146
146
  const name = asset.name ?? asset.fileName ?? asset.uri.split('/').at(-1) ?? (video ? 'Video' : 'Photo');
147
147
  if (video && (!asset.duration || !Number.isFinite(asset.duration)))
148
148
  throw Error('The video length could not be read. Choose another video.');
@@ -161,7 +161,7 @@ export function createExpoMessagingDevice(audio) {
161
161
  }
162
162
  return {
163
163
  async pick(kind, settings, signal, source) { return (await chooseFiles(kind, settings, signal, source, 1))[0] ?? null; },
164
- pickMany: (kind, settings, signal, maximum) => chooseFiles(kind, settings, signal, undefined, maximum),
164
+ pickMany: (kind, settings, signal, maximum, mixedLibrary) => chooseFiles(kind, settings, signal, undefined, maximum, mixedLibrary),
165
165
  async startRecording(maximum, signal) {
166
166
  if (starting || recorder)
167
167
  throw Error('A recording is already in progress.');
@@ -1,3 +1,5 @@
1
+ import { createExpoMessengerCalling } from './expo-connected-calling.js';
2
+ import type { LocationWorkspace } from '@robono/location';
1
3
  import React from 'react';
2
4
  import { MessagingInboxClient, type ConversationSummary } from '@robono/messaging';
3
5
  import { type ExpoMessagingScreenProps } from './expo-screen.js';
@@ -9,11 +11,24 @@ export declare function createExpoMessenger(options: {
9
11
  getAccessToken: () => Promise<string>;
10
12
  fetch?: typeof fetch;
11
13
  notifications?: false;
14
+ extras?: false;
15
+ maps?: false;
16
+ calling?: false;
17
+ people?: () => Record<string, {
18
+ name: string;
19
+ avatarUrl?: string;
20
+ }>;
12
21
  persistence?: false;
13
22
  namespace?: string;
14
23
  audioSession?: MessagingAudioSession;
15
24
  isCallActive?: () => boolean;
16
25
  }): {
26
+ signOut(): Promise<void>;
27
+ dispose(): void;
28
+ extras?: import("@robono/messaging").MessagingExtrasClient | undefined;
29
+ components?: {
30
+ TrackingMap: (props: import("./extras.js").TrackingMapProps) => React.JSX.Element;
31
+ } | undefined;
17
32
  client: import("@robono/messaging").MessagingClient;
18
33
  inbox: MessagingInboxClient;
19
34
  storage: import("./media-controller.js").MessagingMediaStorage;
@@ -22,10 +37,26 @@ export declare function createExpoMessenger(options: {
22
37
  start(open: (conversationId: string, messageId: string) => Promise<void>): Promise<void>;
23
38
  enable(): Promise<void>;
24
39
  } | undefined;
25
- signOut(): Promise<void>;
26
- dispose(): void;
40
+ calling: {
41
+ updateContacts(next: Record<string, {
42
+ name: string;
43
+ avatarUrl?: string;
44
+ }>): void;
45
+ start: () => Promise<void>;
46
+ getSnapshot: () => import("@robono/calling-react-native/messenger").MessengerCallingState;
47
+ subscribe(fn: () => void): () => void;
48
+ isActive: () => boolean;
49
+ dismissUnanswered(): void;
50
+ notification(data: unknown, opened?: boolean): Promise<boolean>;
51
+ prime(): void;
52
+ call(conversationId: string, kind: "voice" | "video", group?: boolean): Promise<void>;
53
+ dispose(): Promise<void>;
54
+ } | undefined;
55
+ maps: LocationWorkspace | undefined;
27
56
  };
28
57
  export interface ExpoMessengerProps extends Omit<ExpoMessagingScreenProps, 'contact' | 'renderConversation' | 'onBack'> {
58
+ calling?: ReturnType<typeof createExpoMessengerCalling>;
59
+ maps?: LocationWorkspace;
29
60
  notifications?: {
30
61
  start: (open: (conversationId: string, messageId: string) => Promise<void>) => Promise<void>;
31
62
  enable: () => Promise<void>;
@@ -41,4 +72,4 @@ export interface ExpoMessengerProps extends Omit<ExpoMessagingScreenProps, 'cont
41
72
  onSelectConversation?: (conversation: ConversationSummary | null) => void;
42
73
  }
43
74
  /** Supplied inbox, group creation, conversation, settings and media screens. */
44
- export declare function ExpoMessenger({ inbox, contacts, initialConversation, selectedConversation, onSelectConversation, notifications, ...props }: ExpoMessengerProps): React.JSX.Element;
75
+ export declare function ExpoMessenger({ inbox, contacts, initialConversation, selectedConversation, onSelectConversation, notifications, maps, calling, ...props }: ExpoMessengerProps): React.JSX.Element;
@@ -1,11 +1,18 @@
1
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMessengerMaps, MessengerMapsNotice } from './maps-connection.js';
3
+ import { MessagingCallingScreen } from './calling.js';
4
+ import { createExpoMessengerCalling, useExpoMessengerCalling, ExpoMessengerCalls } from './expo-connected-calling.js';
5
+ import { isNativeCallAudioActive } from '@robono/calling-react-native/native';
6
+ import { View } from 'react-native';
7
+ import { createExpoMaps, RobonoMaps } from '@robono/location-react-native/expo';
8
+ import { createExpoMessagingExtras } from './expo-extras.js';
2
9
  import { connectExpoMessagingNotifications } from './expo-notifications.js';
3
10
  import { useEffect, useState, useSyncExternalStore } from 'react';
4
11
  import { createMessagingTransport, defaultMessagingSettings, MessagingInboxClient } from '@robono/messaging';
5
12
  import { ExpoMessagingScreen } from './expo-screen.js';
6
13
  import { createExpoMessagingClient } from './expo-outbox.js';
7
14
  import { createExpoMessagingAudioSession, createExpoMessagingStorage } from './expo-media.js';
8
- import { MessagingProvider, MessagingConversation, MessagingAccountMenu } from './index.js';
15
+ import { MessagingProvider, MessagingAccountMenu } from './index.js';
9
16
  import { MessagingInbox } from './inbox.js';
10
17
  import { MessagingGroupEditor } from './group-editor.js';
11
18
  /** One account, one authenticated route, supplied storage and media-session adapters. */
@@ -16,9 +23,12 @@ export function createExpoMessenger(options) {
16
23
  const media = new URL(endpoint);
17
24
  media.searchParams.set('service', 'media');
18
25
  const client = createExpoMessagingClient({ ...options, url: endpoint.href });
26
+ const mapConnection = options.maps === false ? undefined : createExpoMaps(options);
27
+ const connectedExtras = options.extras === false ? undefined : createExpoMessagingExtras(options);
19
28
  const inbox = new MessagingInboxClient({ request: createMessagingTransport({ ...options, url: endpoint.href }) });
20
29
  const storage = createExpoMessagingStorage({ url: media.href, getAccessToken: options.getAccessToken, ...(options.fetch ? { fetch: options.fetch } : {}) });
21
- const audioSession = options.audioSession ?? createExpoMessagingAudioSession({ isCallActive: options.isCallActive });
30
+ const calling = options.calling === false ? undefined : createExpoMessengerCalling({ ...options, inbox });
31
+ const audioSession = options.audioSession ?? createExpoMessagingAudioSession({ isCallActive: () => isNativeCallAudioActive() || calling?.isActive() === true || options.isCallActive?.() === true });
22
32
  let connection = null, pending = null, closed = false;
23
33
  let openNotification;
24
34
  const notifications = options.notifications === false ? undefined : {
@@ -39,7 +49,7 @@ export function createExpoMessenger(options) {
39
49
  catch {
40
50
  return false;
41
51
  } },
42
- open: e => open(e.conversationId, e.messageId), refresh: () => inbox.refresh().then(() => { }), onError: () => { },
52
+ onCall: calling?.notification, open: e => open(e.conversationId, e.messageId), refresh: () => inbox.refresh().then(() => { }), onError: () => { },
43
53
  });
44
54
  if (closed) {
45
55
  await n.signOut();
@@ -59,25 +69,26 @@ export function createExpoMessenger(options) {
59
69
  throw Error('Notifications are not connected yet. Check your Apple/Google setup in Robono.'); if (!await connection.enable())
60
70
  throw Error('Allow notifications in your phone’s app settings.'); },
61
71
  };
62
- return { client, inbox, storage, audioSession, notifications,
63
- async signOut() { closed = true; try {
64
- await pending?.catch(() => { });
65
- await connection?.signOut();
66
- }
67
- finally {
68
- inbox.dispose();
69
- await client.signOut();
70
- } },
71
- dispose() { closed = true; connection?.dispose(); inbox.dispose(); client.dispose(); },
72
+ return { client, inbox, storage, audioSession, notifications, calling, maps: mapConnection?.workspace, ...(connectedExtras ? { extras: connectedExtras.extras, components: { TrackingMap: connectedExtras.TrackingMap } } : {}),
73
+ async signOut() { closed = true; const extraCleanup = connectedExtras?.signOut(); inbox.dispose(); const results = await Promise.allSettled([extraCleanup, calling?.dispose(), mapConnection?.signOut(), client.signOut(), (async () => { await pending?.catch(() => { }); await connection?.signOut(); })()]); const failed = results.find(r => r.status === 'rejected'); if (failed?.status === 'rejected')
74
+ throw failed.reason; },
75
+ dispose() { closed = true; void calling?.dispose().catch(() => { }); mapConnection?.dispose(); void connectedExtras?.dispose().catch(() => { }); connection?.dispose(); inbox.dispose(); client.dispose(); },
72
76
  };
73
77
  }
74
78
  /** Supplied inbox, group creation, conversation, settings and media screens. */
75
- export function ExpoMessenger({ inbox, contacts = {}, initialConversation, selectedConversation, onSelectConversation, notifications, ...props }) {
79
+ export function ExpoMessenger({ inbox, contacts = {}, initialConversation, selectedConversation, onSelectConversation, notifications, maps, calling, ...props }) {
80
+ const callState = useExpoMessengerCalling(calling);
81
+ useEffect(() => { calling?.updateContacts(contacts); }, [calling, contacts]);
82
+ const chatState = useSyncExternalStore(props.client.subscribe, props.client.getSnapshot, props.client.getSnapshot);
83
+ const groupAllowed = chatState.conversation?.kind !== 'group' || !chatState.conversation.supergroup && (chatState.conversation.memberCount ?? 0) >= 2 && (chatState.conversation.memberCount ?? 0) <= 8;
76
84
  const [internal, setInternal] = useState(initialConversation ?? null), [newGroup, setNewGroup] = useState(false);
77
85
  const selected = selectedConversation === undefined ? internal : selectedConversation;
86
+ const autoCalls = calling && selected && groupAllowed ? { ...(callState.features.voice ? { voice: () => calling.call(selected.id, 'voice', selected.kind === 'group') } : {}), ...(callState.features.video ? { video: () => calling.call(selected.id, 'video', selected.kind === 'group') } : {}), voiceLabel: 'Internet call' } : undefined;
78
87
  const setSelected = (row) => { setInternal(row); onSelectConversation?.(row); };
88
+ const [showMaps, setShowMaps] = useState(false), mapConnection = useMessengerMaps(maps), mapsAvailable = mapConnection.available;
79
89
  const inboxState = useSyncExternalStore(inbox.subscribe, inbox.getSnapshot, inbox.getSnapshot);
80
90
  const open = (row, messageId) => {
91
+ setShowMaps(false);
81
92
  setSelected(row);
82
93
  void props.client.open(row.id).then(async () => { if (messageId && props.client.getSnapshot().conversationId === row.id)
83
94
  await props.client.focusMessage(messageId); }).catch(() => { });
@@ -86,5 +97,5 @@ export function ExpoMessenger({ inbox, contacts = {}, initialConversation, selec
86
97
  open(row, message); }).catch(() => { }); }, [notifications, inbox]);
87
98
  const account = notifications ? { name: props.account?.name ?? 'Your account', ...props.account, onEnableNotifications: () => notifications.enable() } : props.account;
88
99
  const contact = selected?.kind === 'group' ? { name: selected.name ?? 'Group' } : contacts[selected?.contactId ?? ''] ?? { name: selected?.contactId ?? 'Chat' };
89
- return _jsx(ExpoMessagingScreen, { ...props, account: account, contact: contact, participants: props.participants ?? contacts, extras: selected ? props.extras : undefined, callActions: selected ? { ...props.callActions, phoneNumber: contacts[selected.contactId ?? '']?.phoneNumber ?? props.callActions?.phoneNumber } : undefined, onBack: () => { props.client.stop(); setSelected(null); void inbox.refresh().catch(() => { }); }, renderConversation: screen => _jsx(MessagingProvider, { ...screen, children: selected ? _jsx(MessagingConversation, {}) : _jsxs(_Fragment, { children: [_jsx(MessagingInbox, { client: inbox, userId: props.userId, contacts: contacts, companyName: props.companyName, appearance: props.appearance, active: props.active !== false, headerActions: _jsx(MessagingAccountMenu, { onNewGroup: inboxState.groupCreationAvailable ? () => setNewGroup(true) : undefined }), onSelect: open, onDeleted: id => props.client.forgetConversation(id) }), newGroup && _jsx(MessagingGroupEditor, { client: props.client, inbox: inbox, userId: props.userId, contacts: contacts, groups: inboxState.groups, accent: (props.appearance ?? inboxState.appearance ?? defaultMessagingSettings().appearance).colors.accent, onClose: () => setNewGroup(false), onSaved: (id, name) => { setNewGroup(false); const row = inbox.getSnapshot().conversations.find(c => c.id === id) ?? { id, kind: 'group', name, contactId: null, lastMessage: null, unreadCount: 0, canSend: true }; open(row); } })] }) }) });
100
+ return _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(MessengerMapsNotice, { error: mapConnection.error, retry: mapConnection.retry }), showMaps && maps ? _jsx(RobonoMaps, { workspace: maps, contacts: contacts, onClose: () => setShowMaps(false) }) : _jsx(ExpoMessagingScreen, { ...props, account: account, contact: contact, participants: props.participants ?? contacts, extras: selected ? props.extras : undefined, callActions: selected ? { ...autoCalls, ...props.callActions, phoneNumber: contacts[selected.contactId ?? '']?.phoneNumber ?? props.callActions?.phoneNumber } : undefined, onBack: () => { props.client.stop(); setSelected(null); void inbox.refresh().catch(() => { }); }, renderConversation: screen => selected ? _jsx(MessagingCallingScreen, { ...screen, onCall: screen.callActions?.voice, onVideoCall: screen.callActions?.video, callLabel: "Internet call", unansweredCallId: callState.unanswered?.conversationId === selected.id ? callState.unanswered.id : undefined, onDismissCallMessage: calling?.dismissUnanswered }) : _jsxs(MessagingProvider, { ...screen, children: [_jsx(MessagingInbox, { client: inbox, userId: props.userId, contacts: contacts, companyName: props.companyName, appearance: props.appearance, active: props.active !== false, headerActions: _jsx(MessagingAccountMenu, { onMaps: mapsAvailable ? () => setShowMaps(true) : undefined, onNewGroup: inboxState.groupCreationAvailable ? () => setNewGroup(true) : undefined }), onSelect: open, onDeleted: id => props.client.forgetConversation(id) }), newGroup && _jsx(MessagingGroupEditor, { client: props.client, inbox: inbox, userId: props.userId, contacts: contacts, groups: inboxState.groups, accent: (props.appearance ?? inboxState.appearance ?? defaultMessagingSettings().appearance).colors.accent, onClose: () => setNewGroup(false), onSaved: (id, name) => { setNewGroup(false); const row = inbox.getSnapshot().conversations.find(c => c.id === id) ?? { id, kind: 'group', name, contactId: null, lastMessage: null, unreadCount: 0, canSend: true }; open(row); } })] }) }), _jsx(ExpoMessengerCalls, { calling: calling, state: callState })] });
90
101
  }
@@ -10,8 +10,9 @@ export interface ExpoMessagingNotificationOptions {
10
10
  refresh: () => void | Promise<void>;
11
11
  onError: (error: Error) => void;
12
12
  fetch?: typeof fetch;
13
+ onCall?: (data: unknown, opened: boolean) => Promise<boolean>;
13
14
  }
14
- /** One restored account. Never prompts on mount, replaces a global handler or handles calling pushes. */
15
+ /** One restored account. Never prompts on mount, replaces a global handler or opens an unverified call. */
15
16
  export declare function connectExpoMessagingNotifications(options: ExpoMessagingNotificationOptions): Promise<{
16
17
  ready: Promise<void>;
17
18
  /** Call only after the user chooses Enable notifications in your interface. */
@@ -3,7 +3,7 @@ import * as Crypto from 'expo-crypto';
3
3
  import * as SecureStore from 'expo-secure-store';
4
4
  import { Platform } from 'react-native';
5
5
  import { createMessagingNotificationRouter, parseMessagingNotification } from '@robono/messaging';
6
- /** One restored account. Never prompts on mount, replaces a global handler or handles calling pushes. */
6
+ /** One restored account. Never prompts on mount, replaces a global handler or opens an unverified call. */
7
7
  export async function connectExpoMessagingNotifications(options) {
8
8
  if (!['ios', 'android'].includes(Platform.OS))
9
9
  throw Error('Mobile notification registration requires iPhone or Android.');
@@ -36,7 +36,7 @@ export async function connectExpoMessagingNotifications(options) {
36
36
  const work = registerQueue.then(async () => {
37
37
  if (disposed || closing)
38
38
  return;
39
- const data = await request({ operation: 'register-device', deviceId, provider: Platform.OS === 'ios' ? 'apns' : 'fcm', token: String(token.data) });
39
+ const data = await request({ operation: 'register-device', calling: !!options.onCall, deviceId, provider: Platform.OS === 'ios' ? 'apns' : 'fcm', token: String(token.data) });
40
40
  if (data.accountScope !== status.accountScope)
41
41
  throw Error('The signed-in account changed. Reconnect notifications for the new account.');
42
42
  });
@@ -44,11 +44,19 @@ export async function connectExpoMessagingNotifications(options) {
44
44
  return work;
45
45
  };
46
46
  const tokenListener = Notifications.addPushTokenListener(token => { void register(token).catch(options.onError); });
47
- const received = Notifications.addNotificationReceivedListener(notification => { void router.handle(notification.request.content.data).catch(options.onError); });
47
+ const handle = async (data, opened = false) => { if (!data || disposed || closing)
48
+ return false; if (data.accountScope === accountScope && ['calling.incoming', 'calling.dismissed', 'calling.group_incoming', 'calling.group_dismissed'].includes(String(data.event)) && options.onCall) {
49
+ if (String(data.event).endsWith('dismissed')) {
50
+ const shown = await Notifications.getPresentedNotificationsAsync();
51
+ await Promise.all(shown.filter(n => n.request.content.data?.accountScope === accountScope && n.request.content.data?.call_id === data.call_id).map(n => Notifications.dismissNotificationAsync(n.request.identifier)));
52
+ }
53
+ return options.onCall(data, opened);
54
+ } return router.handle(data, opened); };
55
+ const received = Notifications.addNotificationReceivedListener(notification => { void handle(notification.request.content.data).catch(options.onError); });
48
56
  const response = async (value) => {
49
57
  if (disposed || closing || !value || value.actionIdentifier !== Notifications.DEFAULT_ACTION_IDENTIFIER)
50
58
  return;
51
- if (await router.handle(value.notification.request.content.data, true)) {
59
+ if (await handle(value.notification.request.content.data, true)) {
52
60
  const last = await Notifications.getLastNotificationResponseAsync();
53
61
  if (!disposed && last?.notification.request.identifier === value.notification.request.identifier)
54
62
  await Notifications.clearLastNotificationResponseAsync();
@@ -56,8 +64,11 @@ export async function connectExpoMessagingNotifications(options) {
56
64
  };
57
65
  const opened = Notifications.addNotificationResponseReceivedListener(value => { void response(value).catch(options.onError); });
58
66
  const ready = (async () => {
59
- if (Platform.OS === 'android')
67
+ if (Platform.OS === 'android') {
60
68
  await Notifications.setNotificationChannelAsync('robono-messages', { name: 'Messages', importance: Notifications.AndroidImportance.HIGH });
69
+ if (options.onCall)
70
+ await Notifications.setNotificationChannelAsync('robono-calls', { name: 'Calls', importance: Notifications.AndroidImportance.HIGH });
71
+ }
61
72
  if ((await Notifications.getPermissionsAsync()).granted)
62
73
  await register(await Notifications.getDevicePushTokenAsync());
63
74
  await response(await Notifications.getLastNotificationResponseAsync());
@@ -1,4 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import * as Haptics from 'expo-haptics';
3
+ import { Vibration } from 'react-native';
2
4
  import { MessagingPopupHeader, messagingPopupBackdrop } from './popup.js';
3
5
  import { ExpoMessagingPrivacyLayer } from './expo-privacy.js';
4
6
  import { ExpoSocialPlayer } from './expo-social.js';
@@ -489,6 +491,10 @@ export function ExpoMessagingScreen({ storage, audioSession, renderConversation,
489
491
  const attachmentPreview = useCallback(async (m) => { if (m.media && m.kind !== 'text' && m.kind !== 'poll')
490
492
  await restore(m.media, m.kind); return thumbnails.preview(m); }, [restore, thumbnails]);
491
493
  const screenProps = { ...props, requestedMessageAction,
494
+ onQuoteReady: props.onQuoteReady ?? (() => { if (Platform.OS === 'android')
495
+ Vibration.vibrate(8);
496
+ else if (Platform.OS === 'ios')
497
+ void Haptics.selectionAsync().catch(() => { }); }),
492
498
  copyText: props.copyText ?? (async (text) => { await Clipboard.setStringAsync(text); }),
493
499
  components: { PrivacyLayer: ExpoMessagingPrivacyLayer, SocialPlayer, TranslationVoice, VoiceMessage: InlineVoice, VoiceComposer: InlineComposer, VoiceAttachment, ...props.components },
494
500
  attachmentPreview: props.attachmentPreview ?? attachmentPreview,
@@ -503,7 +509,7 @@ export function ExpoMessagingScreen({ storage, audioSession, renderConversation,
503
509
  else {
504
510
  message.current = null;
505
511
  chosen.current = options.attachmentId;
506
- media = !options.source && !options.attachmentId && options.intent !== 'preview' && (options.maxCount ?? 1) > 1 ? await controller.pickMany(kind, options.maxCount) : await controller.pick(kind, options.source, options.source === 'call-video' ? 'preview' : options.intent === 'preview' ? 'preview' : 'attach');
512
+ media = !options.source && !options.attachmentId && options.intent !== 'preview' && ((options.maxCount ?? 1) > 1 || options.mixedLibrary) ? await controller.pickMany(kind, options.maxCount ?? 1, options.mixedLibrary) : await controller.pick(kind, options.source, options.source === 'call-video' ? 'preview' : options.intent === 'preview' ? 'preview' : 'attach');
507
513
  }
508
514
  const selection = media ? (Array.isArray(media) ? media : [media]) : [];
509
515
  try {
@@ -512,7 +518,7 @@ export function ExpoMessagingScreen({ storage, audioSession, renderConversation,
512
518
  const file = recorder.getPrepared(item.assetId) ?? controller.getPrepared(item.assetId);
513
519
  if (!file)
514
520
  throw Error('The selected file is unavailable.');
515
- await outbox.saveFile(conversationId, item, file, kind);
521
+ await outbox.saveFile(conversationId, item, file, (options.mixedLibrary || options.source === 'camera') ? (item.mimeType.startsWith('video/') ? 'video' : 'image') : kind);
516
522
  }
517
523
  }
518
524
  catch (error) {
@@ -530,7 +536,8 @@ export function ExpoMessagingScreen({ storage, audioSession, renderConversation,
530
536
  throw Error('This saved attachment is unavailable.'); const intent = ++openingIntent.current; await stopVoicePlayers(); const file = await copyMessagingCache(await saved.file(record)); if (intent !== openingIntent.current) {
531
537
  await createExpoMessagingDevice(audioSession).remove(file);
532
538
  return;
533
- } opened.current = null; message.current = null; await controller.previewLocal(file, record.message.kind); }, };
539
+ } opened.current = null; message.current = null; await controller.previewLocal(file, record.message.kind); },
540
+ };
534
541
  const mediaReady = (stage, uri) => { const event = opened.current; if (!event || controller.getSnapshot().phase !== 'viewing' || controller.getSnapshot().file?.uri !== uri)
535
542
  return; if (stage !== 'video-frame' && message.current?.conversationId === current.current.client.getSnapshot().conversationId)
536
543
  void current.current.client.consumeMessage(event.messageId).catch(() => { }); if (event.reported)
@@ -0,0 +1 @@
1
+ export { prepareBackgroundTracking, captureBackgroundTracking, endBackgroundAccount } from '@robono/location-react-native/expo';
@@ -0,0 +1 @@
1
+ export { prepareBackgroundTracking, captureBackgroundTracking, endBackgroundAccount } from '@robono/location-react-native/expo';
@@ -7,9 +7,9 @@ export function retainExtrasLifecycle(extras) {
7
7
  if (!entry) {
8
8
  const next = { users: 0, release: () => { } };
9
9
  const subscription = AppState.addEventListener('change', state => {
10
- if (state !== 'active')
11
- extras.pause();
12
- else if (next.users)
10
+ if (state === 'background')
11
+ extras.background();
12
+ else if (state === 'active' && next.users)
13
13
  void extras.refresh();
14
14
  });
15
15
  const cleanup = () => { if (!next.users && !extras.hasActiveTracking()) {