@robono/messaging-react-native 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +65 -0
- package/README.md +160 -8
- package/SBOM.spdx.json +1985 -209
- package/dist/account.d.ts +15 -0
- package/dist/account.js +27 -0
- package/dist/call-message.js +7 -1
- package/dist/calling.js +5 -9
- package/dist/conversation-details.d.ts +24 -0
- package/dist/conversation-details.js +83 -0
- package/dist/emoji-text.d.ts +2 -0
- package/dist/emoji-text.js +8 -0
- package/dist/expo-download.d.ts +3 -0
- package/dist/expo-download.js +43 -0
- package/dist/expo-history.d.ts +3 -0
- package/dist/expo-history.js +59 -0
- package/dist/expo-media.d.ts +1 -0
- package/dist/expo-media.js +69 -43
- package/dist/expo-messenger.d.ts +35 -0
- package/dist/expo-messenger.js +39 -0
- package/dist/expo-notifications.d.ts +31 -0
- package/dist/expo-notifications.js +92 -0
- package/dist/expo-outbox.d.ts +13 -0
- package/dist/expo-outbox.js +82 -0
- package/dist/expo-privacy.d.ts +4 -0
- package/dist/expo-privacy.js +7 -0
- package/dist/expo-saved.d.ts +24 -0
- package/dist/expo-saved.js +76 -0
- package/dist/expo-screen.d.ts +42 -0
- package/dist/expo-screen.js +557 -0
- package/dist/expo-social.d.ts +3 -0
- package/dist/expo-social.js +21 -0
- package/dist/expo-thumbnails.d.ts +9 -0
- package/dist/expo-thumbnails.js +68 -0
- package/dist/expo.d.ts +2 -36
- package/dist/expo.js +2 -429
- package/dist/extras-lifecycle.d.ts +2 -0
- package/dist/extras-lifecycle.js +27 -0
- package/dist/extras.d.ts +49 -0
- package/dist/extras.js +49 -0
- package/dist/forward.d.ts +13 -0
- package/dist/forward.js +62 -0
- package/dist/group-editor.d.ts +18 -0
- package/dist/group-editor.js +98 -0
- package/dist/header-calls.d.ts +18 -0
- package/dist/header-calls.js +45 -0
- package/dist/icons.d.ts +1 -1
- package/dist/inbox-search.d.ts +11 -0
- package/dist/inbox-search.js +42 -0
- package/dist/inbox.d.ts +19 -0
- package/dist/inbox.js +52 -0
- package/dist/index.d.ts +76 -3
- package/dist/index.js +648 -167
- package/dist/media-controller.d.ts +21 -0
- package/dist/media-controller.js +157 -3
- package/dist/media-readiness.d.ts +1 -1
- package/dist/media-ui.d.ts +27 -5
- package/dist/media-ui.js +95 -29
- package/dist/message-links.d.ts +5 -0
- package/dist/message-links.js +30 -0
- package/dist/message-menu.d.ts +28 -0
- package/dist/message-menu.js +24 -0
- package/dist/message-order.d.ts +7 -0
- package/dist/message-order.js +27 -0
- package/dist/messenger-video-view.d.ts +8 -0
- package/dist/messenger-video-view.js +46 -0
- package/dist/native-outbox.d.ts +55 -0
- package/dist/native-outbox.js +53 -0
- package/dist/photo-gesture.d.ts +20 -0
- package/dist/photo-gesture.js +52 -0
- package/dist/pollVoteQueue.d.ts +3 -0
- package/dist/pollVoteQueue.js +29 -0
- package/dist/polls.d.ts +7 -2
- package/dist/polls.js +89 -20
- package/dist/popup.d.ts +51 -0
- package/dist/popup.js +73 -0
- package/dist/quick-send.d.ts +15 -0
- package/dist/quick-send.js +26 -0
- package/dist/recent-media.js +1 -0
- package/dist/safety.d.ts +10 -1
- package/dist/safety.js +29 -8
- package/dist/saved.d.ts +10 -0
- package/dist/saved.js +51 -0
- package/dist/search.d.ts +9 -0
- package/dist/search.js +59 -0
- package/dist/social.d.ts +25 -0
- package/dist/social.js +31 -0
- package/dist/translation-settings.d.ts +12 -0
- package/dist/translation-settings.js +16 -0
- package/dist/use-quote-gesture.d.ts +5 -1
- package/dist/use-quote-gesture.js +35 -17
- package/dist/visual/RobonoIcon.js +2 -1
- package/dist/visual/WeatherArtwork.d.ts +9 -0
- package/dist/visual/WeatherArtwork.js +43 -0
- package/dist/visual/weather-artwork.d.ts +17 -0
- package/dist/visual/weather-artwork.js +86 -0
- package/dist/visual/weather-conditions.d.ts +5 -0
- package/dist/visual/weather-conditions.js +2 -0
- package/dist/voice-composer.js +2 -2
- package/dist/voice-waveform.d.ts +2 -0
- package/dist/voice-waveform.js +21 -0
- package/package.json +41 -5
package/dist/extras.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
|
|
3
|
+
import { ActivityIndicator, Image, Linking, Platform, Pressable, ScrollView, Text, View } from 'react-native';
|
|
4
|
+
import { currentTrackers, trackerFresh, trackerETA, weatherTemperature } from '@robono/messaging';
|
|
5
|
+
import { MessagingPopup, messagingPopupColors } from './popup.js';
|
|
6
|
+
import { MessageIcon } from './icons.js';
|
|
7
|
+
import WeatherArtwork from './visual/WeatherArtwork.js';
|
|
8
|
+
import { weatherConditions } from './visual/weather-conditions.js';
|
|
9
|
+
function ForecastIcon({ period, size = 32 }) {
|
|
10
|
+
const condition = period.condition.trim().toLowerCase();
|
|
11
|
+
const match = weatherConditions.find(c => [c.day, c.night].some(name => name.toLowerCase() === condition));
|
|
12
|
+
return _jsx(View, { accessible: true, accessibilityLabel: period.condition, style: { width: size, height: size, flexShrink: 0 }, children: _jsx(WeatherArtwork, { code: period.code ?? match?.code, isDay: period.isDay ?? condition !== 'clear', cloud: period.cloud, windKph: period.windKph, size: size }) });
|
|
13
|
+
}
|
|
14
|
+
export const privacyReadingStripHeight = 74;
|
|
15
|
+
/** Messenger's fixed 74px reading strip. Scrolling moves messages through it. */
|
|
16
|
+
export function MessagingPrivacyLayer({ children, enabled, background }) { return _jsxs(View, { style: { flex: 1, minHeight: 0, overflow: 'hidden' }, children: [children, enabled && _jsx(View, { pointerEvents: "none", testID: "privacy-lens-mask", style: { position: 'absolute', top: 0, left: 0, right: 0, bottom: privacyReadingStripHeight, backgroundColor: Platform.OS === 'web' ? background + '30' : background, ...(Platform.OS === 'web' ? { backdropFilter: 'blur(9px)', WebkitBackdropFilter: 'blur(9px)' } : {}) } })] }); }
|
|
17
|
+
export async function openChatAddress(address) { const query = encodeURIComponent(address); if (Platform.OS === 'ios') {
|
|
18
|
+
try {
|
|
19
|
+
await Linking.openURL(`geo-navigation:///place?address=${query}`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
catch { }
|
|
23
|
+
await Linking.openURL(`https://maps.apple.com/?q=${query}`);
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
await Linking.openURL(`https://www.google.com/maps/search/?api=1&query=${query}`); }
|
|
27
|
+
export function ExtraPopup({ title, onClose, background, foreground, children, expanded = true }) { return _jsx(MessagingPopup, { expanded: expanded, title: title, foreground: foreground, background: background, onClose: onClose, children: children }); }
|
|
28
|
+
export const MessageExtra = forwardRef(function MessageExtra({ addressBubbleHeight = 44, extra, messageId, userId, extras, ink, accent, background, foreground, footer, renderAddress, Map, onCall, onLongPress }, ref) {
|
|
29
|
+
const [mapHeight, setMapHeight] = useState(230);
|
|
30
|
+
const [open, setOpen] = useState(false), [fahrenheit, setFahrenheit] = useState(extra.weather?.fahrenheit ?? false), [imperial, setImperial] = useState(false), [now, setNow] = useState(Date.now()), [error, setError] = useState(''), [busy, setBusy] = useState(false), [selected, setSelected] = useState();
|
|
31
|
+
useImperativeHandle(ref, () => ({ open: () => setOpen(true) }));
|
|
32
|
+
useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(timer); }, []);
|
|
33
|
+
const weather = extra.weather, address = extra.address, call = extra.call, trackers = currentTrackers(address?.trackers ?? [], now), mine = trackers.find(t => t.userId === userId);
|
|
34
|
+
const act = async (fn) => { if (busy)
|
|
35
|
+
return; setBusy(true); setError(''); try {
|
|
36
|
+
await fn();
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
setError(e instanceof Error ? e.message : 'Please try again.');
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
setBusy(false);
|
|
43
|
+
} };
|
|
44
|
+
const text = { color: messagingPopupColors.foreground, fontSize: 15 }, small = { color: messagingPopupColors.foreground, fontSize: 12, opacity: .7 }, row = { flexDirection: 'row', alignItems: 'center', gap: 8 }, title = { color: ink, fontSize: 16, fontWeight: '600' };
|
|
45
|
+
const avatarSize = Math.max(8, Math.min(32, addressBubbleHeight - 34));
|
|
46
|
+
const addressAction = _jsxs(View, { testID: "address-tracking", style: { flexShrink: 0, maxWidth: 136, height: trackers.length ? addressBubbleHeight : 32, justifyContent: 'center', alignItems: 'center', gap: 2, overflow: 'hidden' }, children: [_jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: mine ? 'End tracking' : 'Allow tracking', accessibilityHint: "Share your live location with this chat for 15 minutes", disabled: busy || !mine && !extras.getSnapshot().available.tracking, hitSlop: { top: 6, bottom: 6, left: 0, right: 0 }, onPress: event => { event.stopPropagation(); void act(() => mine ? extras.stopTracking(mine.sessionId) : extras.startTracking(messageId)); }, accessibilityState: { busy, disabled: busy || !mine && !extras.getSnapshot().available.tracking }, style: ({ pressed }) => ({ minHeight: 32, paddingHorizontal: 10, paddingVertical: 5, borderRadius: 16, borderWidth: 1, borderColor: accent + (mine ? 'b0' : '70'), backgroundColor: accent + (pressed ? '30' : mine ? '20' : '0d'), flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 6, opacity: (busy || !mine && !extras.getSnapshot().available.tracking) ? 0.45 : 1 }), children: [busy ? _jsx(ActivityIndicator, { size: 16, color: accent }) : _jsx(MessageIcon, { name: mine ? 'stop' : 'location-outline', size: 16, color: accent }), _jsx(Text, { numberOfLines: 1, style: { color: foreground, fontSize: 12, lineHeight: 18, fontWeight: '600' }, children: mine ? 'End tracking' : 'Allow tracking' })] }), trackers.length > 0 && _jsx(View, { testID: "tracking-map-avatars", style: { flexDirection: 'row', flexWrap: 'nowrap', justifyContent: 'center', gap: 2 }, children: trackers.slice(0, 4).map(t => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Show ' + (t.userId === userId ? 'your' : t.name + "’s") + ' location on map', onPress: event => { event.stopPropagation(); setSelected(t.userId); setOpen(true); }, style: ({ pressed }) => ({ width: avatarSize, height: avatarSize, borderRadius: avatarSize / 2, alignItems: 'center', justifyContent: 'center', backgroundColor: accent + (pressed ? '30' : '12'), borderWidth: 1, borderColor: accent + '70' }), children: t.avatarUrl ? _jsx(Image, { source: { uri: t.avatarUrl }, style: { width: avatarSize - 4, height: avatarSize - 4, borderRadius: avatarSize / 2 } }) : _jsx(Text, { style: { color: foreground, fontSize: Math.min(18, avatarSize * .55), fontWeight: '600' }, children: t.name.slice(0, 1).toUpperCase() }) }, t.sessionId)) })] });
|
|
47
|
+
const addressParticipants = trackers.length > 0 && _jsx(View, { testID: "address-participants", pointerEvents: "none", style: { alignSelf: 'stretch' }, children: _jsx(Text, { style: { color: ink, fontSize: 13, lineHeight: 18 }, children: trackers.map(t => `${t.userId === userId ? 'You' : t.name} · ${trackerETA(t, now)}`).join(' · ') }) });
|
|
48
|
+
return _jsxs(_Fragment, { children: [weather && _jsxs(Pressable, { testID: "weather-card", accessibilityRole: "button", accessibilityLabel: "Open weather", onLongPress: onLongPress, onPress: () => setOpen(true), style: { width: 268, maxWidth: '100%', paddingHorizontal: 4, paddingVertical: 2 }, children: [_jsxs(View, { style: row, children: [_jsx(ForecastIcon, { period: weather.current, size: 34 }), _jsxs(View, { style: { flex: 1 }, children: [_jsxs(Text, { style: title, children: [weatherTemperature(weather.current.c, weather.fahrenheit ?? false), " \u00B7 ", weather.current.condition] }), _jsx(Text, { style: { color: ink, fontSize: 12, opacity: .75 }, children: weather.place })] })] }), _jsxs(View, { style: { ...row, justifyContent: 'space-between', marginTop: 2 }, children: [_jsx(Text, { style: { color: ink, fontSize: 11 }, children: weather.current.detail }), footer] })] }), address && (renderAddress ? renderAddress(addressAction, addressParticipants) : _jsxs(_Fragment, { children: [addressAction, addressParticipants] })), call && _jsxs(Pressable, { testID: "call-card", accessibilityRole: "button", accessibilityLabel: "Open call details", onLongPress: onLongPress, onPress: () => setOpen(true), style: { ...row, width: 268, maxWidth: '100%', padding: 4 }, children: [_jsx(MessageIcon, { name: call.kind === 'video' ? 'video' : 'call', color: call.outcome === 'missed' ? '#c94040' : accent, size: 26 }), _jsxs(View, { style: { flex: 1 }, children: [_jsxs(Text, { style: title, children: [call.outcome === 'missed' ? 'Missed ' : call.direction === 'incoming' ? 'Incoming ' : 'Outgoing ', call.kind === 'video' ? 'video' : 'voice', " call"] }), _jsx(Text, { style: { color: ink, fontSize: 12 }, children: call.outcome === 'completed' ? `${Math.floor(call.durationSeconds / 60)}:${String(Math.floor(call.durationSeconds % 60)).padStart(2, '0')}` : call.outcome })] }), footer] }), open && _jsxs(ExtraPopup, { expanded: !!address || !!weather, title: weather ? 'Weather' : address ? 'Live tracking' : 'Call details', onClose: () => setOpen(false), background: background, foreground: foreground, children: [address && _jsxs(View, { style: { flex: 1, minHeight: 0 }, children: [_jsx(Pressable, { accessibilityRole: "link", accessibilityLabel: "Open destination in maps", onPress: () => void act(() => openChatAddress(address.text)), children: _jsx(Text, { style: { ...text, color: accent, marginBottom: 10 }, numberOfLines: 2, children: address.text }) }), _jsx(View, { testID: "tracking-map-area", onLayout: event => setMapHeight(Math.max(1, Math.round(event.nativeEvent.layout.height))), style: { flex: 1, minHeight: 120, overflow: 'hidden' }, children: address.destination && Map ? _jsx(Map, { imperial: imperial, now: now, height: mapHeight, destination: address.destination, trackers: trackers.filter(t => trackerFresh(t, now)), accent: accent, selectedUserId: selected, onSelect: setSelected }) : _jsx(Text, { style: small, children: "The map is unavailable." }) }), _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 14 }, children: "Sharing ends after 15 minutes." }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Change speed and distance units", onPress: () => setImperial(v => !v), style: { minHeight: 44, paddingHorizontal: 4, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 17, fontWeight: '600' }, children: imperial ? 'km/h · km' : 'mph · mi' }) })] }), _jsxs(ScrollView, { style: { flexGrow: 0, maxHeight: 156 }, children: [!trackers.length && _jsx(Text, { style: text, children: "No one is sharing their location." }), trackers.map(t => _jsxs(Pressable, { onPress: () => setSelected(t.userId), accessibilityRole: "button", accessibilityLabel: 'Locate ' + t.name, style: { minHeight: 74, paddingVertical: 8, borderBottomWidth: 1, borderColor: messagingPopupColors.foreground + '18' }, children: [_jsxs(View, { style: { ...row, justifyContent: 'space-between' }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 21, lineHeight: 27, fontWeight: '600', flexShrink: 1 }, children: t.userId === userId ? 'You' : t.name }), _jsx(Text, { style: { color: accent, fontSize: 25, lineHeight: 30, fontWeight: '700' }, children: trackerETA(t, now) })] }), _jsxs(View, { style: { ...row, justifyContent: 'space-between', alignItems: 'flex-start' }, children: [_jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 19, lineHeight: 26, flex: 1 }, children: trackerFresh(t, now) ? t.speedMps === null ? 'Speed unavailable' : Math.round(t.speedMps * (imperial ? 2.23694 : 3.6)) + (imperial ? ' mph' : ' km/h') : 'Waiting for a fresh location' }), trackerFresh(t, now) && _jsx(Text, { testID: "tracking-distance", style: { color: messagingPopupColors.foreground, fontSize: 19, lineHeight: 26, textAlign: 'right', flexShrink: 1 }, children: t.distanceMeters === null ? 'Distance unavailable' : (t.distanceMeters / (imperial ? 1609.344 : 1000)).toFixed(1) + (imperial ? ' mi' : ' km') + ' away' })] })] }, t.sessionId))] })] }), !address && _jsxs(ScrollView, { style: weather ? { flex: 1, minHeight: 0 } : { flexShrink: 1 }, children: [weather && (weather.expiresAt <= now ? _jsx(Text, { style: text, children: "Weather details expired." }) : _jsxs(_Fragment, { children: [_jsx(Text, { style: small, children: weather.place }), _jsxs(View, { style: { ...row, marginVertical: 8 }, children: [_jsx(ForecastIcon, { period: weather.current, size: 36 }), _jsxs(Text, { style: { ...text, flex: 1, fontWeight: '600' }, children: [weatherTemperature(weather.current.c, fahrenheit), " \u00B7 ", weather.current.condition] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Change temperature unit", onPress: () => setFahrenheit(v => !v), style: { minWidth: 44, minHeight: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 16, fontWeight: '600' }, children: fahrenheit ? '°C' : '°F' }) })] }), weather.alert && _jsx(Text, { style: { ...text, color: accent }, children: weather.alert }), _jsxs(View, { style: { marginTop: 20, paddingTop: 16, borderTopWidth: 1, borderTopColor: messagingPopupColors.foreground + '24' }, children: [_jsx(Text, { style: { ...text, fontWeight: '600' }, children: "Next three hours" }), _jsx(View, { style: { ...row, marginTop: 12, alignItems: 'flex-start' }, children: weather.hours.slice(0, 3).map(p => _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: small, children: p.label }), _jsxs(View, { style: row, children: [_jsx(ForecastIcon, { period: p, size: 32 }), _jsx(Text, { style: text, children: weatherTemperature(p.c, fahrenheit) })] }), _jsx(Text, { style: small, children: p.detail })] }, p.at)) })] }), _jsxs(View, { style: { marginTop: 20, paddingTop: 16, borderTopWidth: 1, borderTopColor: messagingPopupColors.foreground + '24' }, children: [_jsx(Text, { style: { ...text, fontWeight: '600', marginBottom: 8 }, children: "Three-day forecast" }), weather.days.slice(0, 3).map(p => _jsxs(View, { style: { ...row, paddingVertical: 8 }, children: [_jsx(Text, { style: { ...text, width: 45 }, children: p.label }), _jsx(ForecastIcon, { period: p, size: 32 }), _jsxs(View, { style: { flex: 1 }, children: [_jsx(Text, { style: text, children: p.condition }), _jsx(Text, { style: small, children: p.detail })] }), _jsxs(Text, { style: text, children: [weatherTemperature(p.c, fahrenheit), p.lowC !== undefined ? ' / ' + weatherTemperature(p.lowC, fahrenheit) : ''] })] }, p.at))] }), !!weather.attribution && _jsx(Text, { style: { ...small, marginTop: 8 }, children: weather.attribution })] })), call && _jsxs(_Fragment, { children: [_jsxs(Text, { style: { ...text, fontWeight: '600', marginVertical: 8 }, children: [call.kind === 'video' ? 'Video' : 'Voice', " call \u00B7 ", call.outcome] }), _jsx(Text, { style: text, children: new Date(call.startedAt).toLocaleString() }), call.durationSeconds > 0 && _jsxs(Text, { style: { ...text, marginTop: 8 }, children: [Math.floor(call.durationSeconds / 60), " min ", Math.floor(call.durationSeconds % 60), " sec"] }), call.endedByWarning && _jsx(Text, { style: { ...small, marginTop: 10 }, children: "Ended after the network\u2019s time warning." }), onCall && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Call again", onPress: () => { setOpen(false); onCall(call.kind); }, style: { minHeight: 44, justifyContent: 'center', marginTop: 10 }, children: _jsx(Text, { style: { color: accent, fontWeight: '600' }, children: "Call again" }) })] })] })] }), !!error && _jsx(ExtraPopup, { title: "Message", onClose: () => setError(''), background: background, foreground: foreground, children: _jsx(Text, { accessibilityRole: "alert", style: text, children: error }) })] });
|
|
49
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { MessagingAppearance, MessagingClient, MessagingMessage } from '@robono/messaging';
|
|
3
|
+
/** Messenger's compact destination picker, retaining per-message retry identities. */
|
|
4
|
+
export declare function MessagingForward({ messages, client, appearance, participants, userId, onClose }: {
|
|
5
|
+
messages: MessagingMessage[];
|
|
6
|
+
client: MessagingClient;
|
|
7
|
+
appearance: MessagingAppearance;
|
|
8
|
+
participants?: Record<string, {
|
|
9
|
+
name: string;
|
|
10
|
+
}>;
|
|
11
|
+
userId: string;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}): React.JSX.Element;
|
package/dist/forward.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { messagingPopupColors } from './popup.js';
|
|
3
|
+
import { useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
|
5
|
+
import { MessagingPopup } from './popup.js';
|
|
6
|
+
/** Messenger's compact destination picker, retaining per-message retry identities. */
|
|
7
|
+
export function MessagingForward({ messages, client, appearance, participants = {}, userId, onClose }) {
|
|
8
|
+
const [page, setPage] = useState(null), [loading, setLoading] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState('');
|
|
9
|
+
const live = useRef(true), inFlight = useRef(false), attempts = useRef(new Map()), done = useRef(new Set());
|
|
10
|
+
const c = { ...appearance.colors, ...messagingPopupColors };
|
|
11
|
+
useEffect(() => { live.current = true; void load(false); return () => { live.current = false; }; }, []);
|
|
12
|
+
async function load(more) { if (inFlight.current)
|
|
13
|
+
return; inFlight.current = true; setLoading(true); setError(''); try {
|
|
14
|
+
const next = await client.forwardTargets(more ? page?.nextCursor ?? undefined : undefined);
|
|
15
|
+
if (live.current)
|
|
16
|
+
setPage(previous => ({ ...next, conversations: more ? [...new Map([...(previous?.conversations ?? []), ...next.conversations].map(row => [row.id, row])).values()] : next.conversations }));
|
|
17
|
+
}
|
|
18
|
+
catch (e) {
|
|
19
|
+
if (live.current)
|
|
20
|
+
setError(e instanceof Error ? e.message : 'Chats could not be loaded.');
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
inFlight.current = false;
|
|
24
|
+
if (live.current)
|
|
25
|
+
setLoading(false);
|
|
26
|
+
} }
|
|
27
|
+
async function send(row) {
|
|
28
|
+
if (inFlight.current)
|
|
29
|
+
return;
|
|
30
|
+
inFlight.current = true;
|
|
31
|
+
setBusy(true);
|
|
32
|
+
setError('');
|
|
33
|
+
try {
|
|
34
|
+
for (const message of messages) {
|
|
35
|
+
const key = row.id + ':' + message.id;
|
|
36
|
+
if (done.current.has(key))
|
|
37
|
+
continue;
|
|
38
|
+
let id = attempts.current.get(key);
|
|
39
|
+
if (!id) {
|
|
40
|
+
id = client.createForwardId();
|
|
41
|
+
attempts.current.set(key, id);
|
|
42
|
+
}
|
|
43
|
+
await client.forwardMessage(message.id, row.id, id);
|
|
44
|
+
done.current.add(key);
|
|
45
|
+
if (!live.current)
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
onClose();
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
if (live.current)
|
|
52
|
+
setError(e instanceof Error ? e.message : 'The message could not be forwarded. Try again.');
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
inFlight.current = false;
|
|
56
|
+
if (live.current)
|
|
57
|
+
setBusy(false);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return _jsxs(MessagingPopup, { contentWidth: 640, title: "Forward to", foreground: c.foreground, closeLabel: "Close forwarding", closeDisabled: busy, background: c.background, onClose: () => { if (!busy)
|
|
61
|
+
onClose(); }, children: [_jsx(ScrollView, { style: { flex: 1, minHeight: 0, marginTop: 8 }, children: page?.conversations.filter(row => row.canSend).map(row => { const name = row.kind === 'group' ? row.name ?? 'Group' : row.contactId === userId ? 'You' : participants[row.contactId ?? '']?.name ?? row.contactId ?? 'Chat'; return _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: 'Forward to ' + name, disabled: busy || loading, onPress: () => void send(row), style: { minHeight: 52, justifyContent: 'center', paddingVertical: 12, paddingHorizontal: 8, borderBottomWidth: 1, borderBottomColor: '#ffffff14' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: name }) }, row.id); }) }), loading || busy ? _jsx(ActivityIndicator, { color: c.accent, style: { margin: 12 } }) : page && !page.conversations.some(row => row.canSend) && _jsx(Text, { style: { color: c.secondary, marginVertical: 12 }, children: "No chats available." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, marginTop: 8 }, children: error }), _jsxs(View, { style: { flexDirection: 'row', justifyContent: 'flex-end', gap: 20, marginTop: 8 }, children: [!page && error && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void load(false), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Try again" }) }), page?.hasMore && _jsx(Pressable, { accessibilityRole: "button", disabled: loading || busy, onPress: () => void load(true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "More chats" }) })] })] });
|
|
62
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { MessagingClient, MessagingInboxClient, MessagingSettings, MessagingState } from '@robono/messaging';
|
|
3
|
+
export interface MessagingGroupEditorProps {
|
|
4
|
+
client: MessagingClient;
|
|
5
|
+
inbox?: MessagingInboxClient;
|
|
6
|
+
userId: string;
|
|
7
|
+
contacts: Record<string, {
|
|
8
|
+
name: string;
|
|
9
|
+
avatarUrl?: string;
|
|
10
|
+
}>;
|
|
11
|
+
conversation?: MessagingState['conversation'];
|
|
12
|
+
groups: MessagingSettings['groups'];
|
|
13
|
+
accent: string;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
onSaved: (conversationId: string, name: string) => void;
|
|
16
|
+
}
|
|
17
|
+
/** Messenger's name, group type and contact checklist; changes use member deltas. */
|
|
18
|
+
export declare function MessagingGroupEditor({ client, inbox, userId, contacts, conversation, groups, accent, onClose, onSaved }: MessagingGroupEditorProps): React.JSX.Element;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
3
|
+
import { ActivityIndicator, FlatList, Image, Pressable, Text, TextInput, View } from 'react-native';
|
|
4
|
+
import { MessageIcon } from './icons.js';
|
|
5
|
+
import { MessagingPopup, messagingPopupColors as c } from './popup.js';
|
|
6
|
+
/** Messenger's name, group type and contact checklist; changes use member deltas. */
|
|
7
|
+
export function MessagingGroupEditor({ client, inbox, userId, contacts, conversation, groups, accent, onClose, onSaved }) {
|
|
8
|
+
const editing = !!conversation, conversationId = client.getSnapshot().conversationId;
|
|
9
|
+
const [name, setName] = useState(conversation?.name ?? ''), [supergroup, setSupergroup] = useState(conversation?.supergroup ?? false);
|
|
10
|
+
const [revision, setRevision] = useState(conversation?.revision ?? 0), [count, setCount] = useState(conversation?.memberCount ?? 0);
|
|
11
|
+
const [original, setOriginal] = useState(new Set()), [added, setAdded] = useState(new Set()), [removed, setRemoved] = useState(new Set());
|
|
12
|
+
const [next, setNext] = useState(null), [ready, setReady] = useState(!editing), [loading, setLoading] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState(''), [query, setQuery] = useState('');
|
|
13
|
+
const generation = useRef(0), attempt = useRef(null);
|
|
14
|
+
const max = supergroup ? groups?.maxSupergroupParticipants ?? 2000 : groups?.maxParticipants ?? 50;
|
|
15
|
+
const membersCount = (editing ? count : 1) + added.size - removed.size;
|
|
16
|
+
async function load(more = false) {
|
|
17
|
+
const version = ++generation.current;
|
|
18
|
+
setLoading(true);
|
|
19
|
+
setError('');
|
|
20
|
+
try {
|
|
21
|
+
const page = await client.groupMembers(more && next ? next : undefined);
|
|
22
|
+
if (version !== generation.current)
|
|
23
|
+
return;
|
|
24
|
+
if (!page.managementAvailable || page.ownerId !== userId)
|
|
25
|
+
throw Error('Only the current group owner can change this group.');
|
|
26
|
+
if (more && page.revision !== revision)
|
|
27
|
+
throw Error('This group changed. Close and reopen it before editing.');
|
|
28
|
+
setOriginal(current => new Set([...(more ? current : []), ...(page.memberIds ?? page.members.map(m => m.userId))]));
|
|
29
|
+
setRevision(page.revision);
|
|
30
|
+
setCount(page.memberCount);
|
|
31
|
+
setNext(page.hasMore ? page.nextCursor : null);
|
|
32
|
+
setReady(true);
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
if (version === generation.current) {
|
|
36
|
+
setReady(false);
|
|
37
|
+
setError(e instanceof Error ? e.message : 'Group members could not be loaded.');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
if (version === generation.current)
|
|
42
|
+
setLoading(false);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
useEffect(() => { if (editing)
|
|
46
|
+
void load(); return () => { ++generation.current; }; }, [client, conversationId]);
|
|
47
|
+
const people = useMemo(() => [...new Set([...Object.keys(contacts), ...original])].filter(id => id !== userId &&
|
|
48
|
+
(contacts[id]?.name ?? 'Member').toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())).sort((a, b) => (contacts[a]?.name ?? a).localeCompare(contacts[b]?.name ?? b)), [contacts, original, query, userId]);
|
|
49
|
+
const chosen = (id) => !removed.has(id) && (original.has(id) || added.has(id));
|
|
50
|
+
const toggle = (id) => {
|
|
51
|
+
if (!chosen(id) && membersCount >= max) {
|
|
52
|
+
setError(`This group can have up to ${max.toLocaleString()} members.`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (original.has(id))
|
|
56
|
+
setRemoved(current => { const value = new Set(current); if (value.has(id))
|
|
57
|
+
value.delete(id);
|
|
58
|
+
else
|
|
59
|
+
value.add(id); return value; });
|
|
60
|
+
else
|
|
61
|
+
setAdded(current => { const value = new Set(current); if (value.has(id))
|
|
62
|
+
value.delete(id);
|
|
63
|
+
else
|
|
64
|
+
value.add(id); return value; });
|
|
65
|
+
setError('');
|
|
66
|
+
};
|
|
67
|
+
async function save() {
|
|
68
|
+
if (busy || !ready || loading)
|
|
69
|
+
return;
|
|
70
|
+
setBusy(true);
|
|
71
|
+
setError('');
|
|
72
|
+
const value = { name: name.trim(), supergroup, ...(editing ? { revision, addMembers: [...added].sort(), removeMembers: [...removed].sort() } : { members: [...added].sort() }) };
|
|
73
|
+
const body = JSON.stringify(value);
|
|
74
|
+
if (attempt.current?.body !== body)
|
|
75
|
+
attempt.current = { body, id: client.createForwardId() };
|
|
76
|
+
try {
|
|
77
|
+
if (editing) {
|
|
78
|
+
await client.updateGroup({ ...value, revision, addMembers: [...added].sort(), removeMembers: [...removed].sort(), requestId: attempt.current.id });
|
|
79
|
+
onSaved(conversationId, value.name);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
if (!inbox)
|
|
83
|
+
throw Error('The chat list is unavailable.');
|
|
84
|
+
const result = await inbox.createGroup({ name: value.name, supergroup, members: [...added].sort(), requestId: attempt.current.id });
|
|
85
|
+
onSaved(result.conversationId, value.name);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
setError(e instanceof Error ? e.message : 'The group could not be saved.');
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
setBusy(false);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const locked = busy || loading || !ready, canSave = !locked && !!name.trim() && membersCount >= 2 && membersCount <= max;
|
|
96
|
+
return _jsxs(MessagingPopup, { title: editing ? 'Edit group' : 'New group', background: c.background, closeDisabled: busy, onClose: () => { if (!busy)
|
|
97
|
+
onClose(); }, form: true, children: [_jsx(FlatList, { data: people, keyExtractor: id => id, keyboardShouldPersistTaps: "handled", style: { flex: 1 }, contentContainerStyle: { paddingHorizontal: 8, paddingBottom: 16 }, initialNumToRender: 15, ListHeaderComponent: _jsxs(View, { style: { gap: 12, paddingTop: 12 }, children: [_jsx(TextInput, { accessibilityLabel: "Group name", placeholder: "Group name", placeholderTextColor: c.secondary, maxLength: 100, value: name, onChangeText: setName, editable: !locked, style: { color: c.foreground, fontSize: 21, minHeight: 48, paddingHorizontal: 4, borderBottomWidth: 1, borderBottomColor: '#ffffff30' } }), groups?.supergroupsEnabled && _jsx(View, { style: { flexDirection: 'row', gap: 20 }, children: [false, true].map(value => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: value ? 'Supergroup' : 'Standard group', accessibilityState: { checked: supergroup === value, disabled: locked }, disabled: locked, onPress: () => setSupergroup(value), style: { minHeight: 44, flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsx(MessageIcon, { name: supergroup === value ? 'radio-button-on' : 'radio-button-off', color: accent, size: 22 }), _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: value ? 'Supergroup' : 'Group' })] }, String(value))) }), _jsxs(Text, { style: { color: c.secondary, fontSize: 14 }, children: [membersCount.toLocaleString(), " / ", max.toLocaleString(), " members"] }), _jsx(Text, { style: { color: c.foreground, fontSize: 17 }, children: "You \u00B7 Group owner" }), _jsx(TextInput, { accessibilityLabel: "Search group contacts", placeholder: "Search contacts", placeholderTextColor: c.secondary, value: query, onChangeText: setQuery, style: { color: c.foreground, fontSize: 16, minHeight: 44, borderBottomWidth: 1, borderBottomColor: '#ffffff30' } })] }), renderItem: ({ item: id }) => _jsxs(Pressable, { accessibilityRole: "checkbox", accessibilityLabel: contacts[id]?.name ?? 'Member', accessibilityState: { checked: chosen(id), disabled: locked }, disabled: locked, onPress: () => toggle(id), style: { minHeight: 56, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [contacts[id]?.avatarUrl ? _jsx(Image, { source: { uri: contacts[id].avatarUrl }, style: { width: 36, height: 36, borderRadius: 18 } }) : _jsx(View, { style: { width: 36, height: 36, borderRadius: 18, backgroundColor: c.incoming, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent, fontSize: 18 }, children: (contacts[id]?.name ?? 'Member').slice(0, 1) }) }), _jsx(Text, { style: { flex: 1, color: c.foreground, fontSize: 16 }, children: contacts[id]?.name ?? 'Member' }), _jsx(MessageIcon, { name: chosen(id) ? 'checkmark-circle' : 'ellipse-outline', color: chosen(id) ? accent : c.secondary, size: 24 })] }), ListFooterComponent: _jsxs(View, { style: { gap: 12, paddingTop: 12 }, children: [loading && _jsx(ActivityIndicator, { accessibilityLabel: "Loading group members", color: accent }), next && _jsx(Pressable, { accessibilityRole: "button", disabled: loading || busy, onPress: () => void load(true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: accent }, children: "Load more members" }) }), !people.length && !loading && _jsx(Text, { style: { color: c.secondary }, children: query ? 'No matching contacts.' : 'Your app has not supplied any contacts to invite.' }), _jsxs(Text, { style: { color: c.secondary, fontSize: 13, lineHeight: 19 }, children: ["Only the group owner can change the name or members. ", groups?.historyForNewMembers === 'all' ? 'New members can see retained group history.' : 'New members see messages sent after they join.'] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground, fontSize: 15 }, children: error })] }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: editing ? 'Save group' : 'Create group', disabled: !canSave, onPress: () => void save(), style: { minHeight: 48, margin: 8, borderRadius: 12, backgroundColor: accent, alignItems: 'center', justifyContent: 'center', opacity: canSave ? 1 : .4 }, children: busy ? _jsx(ActivityIndicator, { color: "#000" }) : _jsx(Text, { style: { color: '#000', fontSize: 17, fontWeight: '600' }, children: editing ? 'Save' : 'Create group' }) })] });
|
|
98
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/** Supply callbacks only while the host's authenticated calling service allows them. */
|
|
3
|
+
export interface MessagingCallActions {
|
|
4
|
+
voice?: () => void | Promise<void>;
|
|
5
|
+
video?: () => void | Promise<void>;
|
|
6
|
+
/** Contact's callable number. Never used for group calls. */
|
|
7
|
+
phoneNumber?: string;
|
|
8
|
+
phone?: (number: string) => void | Promise<void>;
|
|
9
|
+
voiceLabel?: string;
|
|
10
|
+
videoLabel?: string;
|
|
11
|
+
}
|
|
12
|
+
/** Matches Messenger: video is separate; the phone button chooses internet/carrier. */
|
|
13
|
+
export declare function MessagingHeaderCalls({ actions, name, group, color }: {
|
|
14
|
+
actions?: MessagingCallActions;
|
|
15
|
+
name: string;
|
|
16
|
+
group: boolean;
|
|
17
|
+
color: string;
|
|
18
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { Keyboard, Linking, Modal, Platform, Pressable, SafeAreaView, Text, View } from 'react-native';
|
|
4
|
+
import { MessageIcon } from './icons.js';
|
|
5
|
+
/** Matches Messenger: video is separate; the phone button chooses internet/carrier. */
|
|
6
|
+
export function MessagingHeaderCalls({ actions, name, group, color }) {
|
|
7
|
+
const [visible, setVisible] = useState(false), [error, setError] = useState('');
|
|
8
|
+
const pending = useRef(null), latest = useRef(actions);
|
|
9
|
+
latest.current = actions;
|
|
10
|
+
const phone = !group ? actions?.phoneNumber?.trim() : undefined;
|
|
11
|
+
const validPhone = phone && /^\+?[\d ().-]{3,30}$/.test(phone) ? phone : undefined;
|
|
12
|
+
const invoke = async (kind) => { const current = latest.current; try {
|
|
13
|
+
setError('');
|
|
14
|
+
if (kind === 'phone') {
|
|
15
|
+
if (group || !validPhone)
|
|
16
|
+
return;
|
|
17
|
+
if (current?.phone)
|
|
18
|
+
await current.phone(validPhone);
|
|
19
|
+
else
|
|
20
|
+
await Linking.openURL('tel:' + validPhone.replace(/[^+\d]/g, ''));
|
|
21
|
+
}
|
|
22
|
+
else
|
|
23
|
+
await current?.[kind]?.();
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
setError('The call could not start. Please try again.');
|
|
27
|
+
} };
|
|
28
|
+
const dismissed = () => { const kind = pending.current; pending.current = null; if (kind)
|
|
29
|
+
requestAnimationFrame(() => void invoke(kind)); };
|
|
30
|
+
useEffect(() => { if (!visible && Platform.OS !== 'ios')
|
|
31
|
+
dismissed(); }, [visible]);
|
|
32
|
+
useEffect(() => { if (!visible || Platform.OS !== 'web')
|
|
33
|
+
return; const close = (e) => { if (e.key === 'Escape') {
|
|
34
|
+
e.stopPropagation();
|
|
35
|
+
setVisible(false);
|
|
36
|
+
} }; document.addEventListener('keydown', close, true); return () => document.removeEventListener('keydown', close, true); }, [visible]);
|
|
37
|
+
useEffect(() => { pending.current = null; setVisible(false); setError(''); }, [name, group]);
|
|
38
|
+
const choose = (kind) => { pending.current = kind; setVisible(false); };
|
|
39
|
+
const button = (label, icon, press) => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: label, onPress: press, style: { width: icon === 'video' ? 42 : 44, height: icon === 'video' ? 42 : 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: icon, size: 23, color: color }) });
|
|
40
|
+
const row = (kind, label, detail) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: label, onPress: () => choose(kind), style: ({ pressed }) => ({ flexDirection: 'row', alignItems: 'center', gap: 16, minHeight: 80, padding: 12, borderRadius: 18, backgroundColor: pressed ? '#1D1D1D' : 'transparent' }), children: [_jsx(View, { style: { width: 48, height: 48, borderRadius: 24, backgroundColor: '#1D1D1D', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: kind === 'phone' ? 'phone-portrait-outline' : 'phone', color: "#F7F7F7", size: 24 }) }), _jsxs(View, { style: { flex: 1, gap: 4 }, children: [_jsx(Text, { style: { color: '#F7F7F7', fontSize: 18, fontWeight: '600' }, children: label }), _jsx(Text, { style: { color: '#A3A3A3', fontSize: 15 }, children: detail })] })] });
|
|
41
|
+
return _jsxs(_Fragment, { children: [actions?.video && button(actions.videoLabel ?? 'Video call', 'video', () => void invoke('video')), (actions?.voice || validPhone) && button('Call ' + name, 'phone', () => { if (!validPhone) {
|
|
42
|
+
void invoke('voice');
|
|
43
|
+
return;
|
|
44
|
+
} Keyboard.dismiss(); setVisible(true); }), _jsx(Modal, { visible: visible || !!error, transparent: true, animationType: "fade", onDismiss: dismissed, onRequestClose: () => { setVisible(false); setError(''); }, children: _jsxs(SafeAreaView, { style: { flex: 1, justifyContent: 'flex-end' }, children: [_jsx(Pressable, { accessibilityLabel: "Close call options", onPress: () => { setVisible(false); setError(''); }, style: { position: 'absolute', inset: 0, backgroundColor: '#0009' } }), _jsxs(View, { accessibilityViewIsModal: true, style: { width: '100%', maxWidth: 560, alignSelf: 'center', backgroundColor: '#111111', borderRadius: 28, padding: 20, gap: 8 }, children: [_jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', gap: 8 }, children: [_jsxs(Text, { accessibilityRole: "header", style: { flex: 1, color: '#F7F7F7', fontSize: 22, fontWeight: '600' }, children: ["Call ", name] }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close call choices", onPress: () => { setVisible(false); setError(''); }, style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", color: "#A3A3A3" }) })] }), error ? _jsx(Text, { accessibilityRole: "alert", style: { color: '#F7F7F7', fontSize: 16 }, children: error }) : _jsxs(_Fragment, { children: [actions?.voice && row('voice', actions.voiceLabel ?? 'Robono call', 'Wi-Fi or mobile data'), validPhone && row('phone', 'Phone call', validPhone)] })] })] }) })] });
|
|
45
|
+
}
|
package/dist/icons.d.ts
CHANGED
|
@@ -4,4 +4,4 @@ export declare function MessageIcon(props: RobonoIconProps): React.JSX.Element;
|
|
|
4
4
|
export declare function duration(ms?: number): string;
|
|
5
5
|
export declare function fileSize(bytes?: number): string;
|
|
6
6
|
/** Neutral app chrome stays legible for light and dark customer palettes. */
|
|
7
|
-
export declare function chatSurface(background: string): "#
|
|
7
|
+
export declare function chatSurface(background: string): "#1e262b" | "#ffffff";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { ConversationSummary, MessagingInboxClient, MessagingAppearance } from '@robono/messaging';
|
|
3
|
+
/** Main-screen search covers retained messages beyond the current inbox page. */
|
|
4
|
+
export declare function InboxMessageSearch({ client, query, appearance, name, chats, onSelect }: {
|
|
5
|
+
client: MessagingInboxClient;
|
|
6
|
+
query: string;
|
|
7
|
+
appearance: MessagingAppearance;
|
|
8
|
+
name: (chat: ConversationSummary) => string;
|
|
9
|
+
chats: ConversationSummary[];
|
|
10
|
+
onSelect: (chat: ConversationSummary, messageId?: string) => void;
|
|
11
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { ActivityIndicator, FlatList, Pressable, Text, View } from 'react-native';
|
|
4
|
+
/** Main-screen search covers retained messages beyond the current inbox page. */
|
|
5
|
+
export function InboxMessageSearch({ client, query, appearance, name, chats, onSelect }) {
|
|
6
|
+
const c = appearance.colors, [rows, setRows] = useState([]), [next, setNext] = useState(null), [busy, setBusy] = useState(false), [error, setError] = useState('');
|
|
7
|
+
const controller = useRef(null), generation = useRef(0);
|
|
8
|
+
async function search(more = false) {
|
|
9
|
+
controller.current?.abort();
|
|
10
|
+
const control = new AbortController();
|
|
11
|
+
controller.current = control;
|
|
12
|
+
const version = ++generation.current;
|
|
13
|
+
setBusy(true);
|
|
14
|
+
setError('');
|
|
15
|
+
try {
|
|
16
|
+
const page = await client.search(query, { ...(more && next ? { before: next } : {}), signal: control.signal });
|
|
17
|
+
if (version !== generation.current)
|
|
18
|
+
return;
|
|
19
|
+
setRows(current => more ? [...new Map([...current, ...page.results].map(row => [row.message.conversationId + ':' + row.message.id, row])).values()] : page.results);
|
|
20
|
+
setNext(page.hasMore ? page.nextCursor : null);
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
if (version === generation.current && !control.signal.aborted) {
|
|
24
|
+
setRows([]);
|
|
25
|
+
setError(e instanceof Error ? e.message : 'Messages could not be searched.');
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
finally {
|
|
29
|
+
if (version === generation.current)
|
|
30
|
+
setBusy(false);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
setRows([]);
|
|
35
|
+
setNext(null);
|
|
36
|
+
setError('');
|
|
37
|
+
setBusy(true);
|
|
38
|
+
const timer = setTimeout(() => void search(), 300);
|
|
39
|
+
return () => { clearTimeout(timer); ++generation.current; controller.current?.abort(); };
|
|
40
|
+
}, [client, query]);
|
|
41
|
+
return _jsx(FlatList, { style: { flex: 1 }, data: rows, keyExtractor: row => row.message.conversationId + ':' + row.message.id, keyboardShouldPersistTaps: "handled", ListHeaderComponent: _jsxs(View, { children: [chats.length > 0 && _jsxs(_Fragment, { children: [_jsx(Text, { style: { paddingHorizontal: 16, paddingVertical: 8, color: c.secondary, fontSize: 14, fontWeight: '600' }, children: "Chats" }), chats.map(chat => _jsx(Pressable, { accessibilityRole: "button", onPress: () => onSelect(chat), style: { minHeight: 48, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.foreground, fontSize: 16 }, children: name(chat) }) }, chat.id))] }), _jsx(Text, { style: { paddingHorizontal: 16, paddingVertical: 12, color: c.secondary, fontSize: 14, fontWeight: '600' }, children: "Messages" })] }), ListEmptyComponent: !busy && !error ? _jsx(Text, { style: { padding: 16, color: c.secondary }, children: "No matching messages." }) : null, renderItem: ({ item }) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `Message in ${name(item.conversation)}: ${item.message.text}`, onPress: () => onSelect(item.conversation, item.message.id), style: { paddingHorizontal: 16, paddingVertical: 12, gap: 5, borderBottomWidth: 1, borderBottomColor: c.foreground + '16' }, children: [_jsxs(View, { style: { flexDirection: 'row', gap: 8 }, children: [_jsx(Text, { numberOfLines: 1, style: { flex: 1, fontSize: 16, fontWeight: '600', color: c.foreground }, children: name(item.conversation) }), _jsx(Text, { style: { fontSize: 12, color: c.secondary }, children: new Date(item.message.createdAt).toLocaleDateString() })] }), _jsx(Text, { numberOfLines: 3, style: { fontSize: 15, lineHeight: 21, color: c.secondary }, children: item.message.text })] }), ListFooterComponent: _jsxs(View, { children: [busy && _jsx(ActivityIndicator, { accessibilityLabel: "Searching all messages", color: c.accent, style: { padding: 16 } }), !!error && _jsxs(_Fragment, { children: [_jsx(Text, { accessibilityRole: "alert", style: { padding: 16, color: c.foreground }, children: error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void search(), style: { minHeight: 44, paddingHorizontal: 16, justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "Try again" }) })] }), next && !busy && !error && _jsx(Pressable, { accessibilityRole: "button", onPress: () => void search(true), style: { minHeight: 48, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent }, children: "More results" }) })] }) });
|
|
42
|
+
}
|
package/dist/inbox.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type MessagingAppearance, type MessagingInboxClient, type ConversationSummary } from '@robono/messaging';
|
|
3
|
+
export interface MessagingInboxProps {
|
|
4
|
+
client: MessagingInboxClient;
|
|
5
|
+
userId: string;
|
|
6
|
+
contacts?: Record<string, {
|
|
7
|
+
name: string;
|
|
8
|
+
avatarUrl?: string;
|
|
9
|
+
}>;
|
|
10
|
+
companyName?: string;
|
|
11
|
+
headerActions?: React.ReactNode;
|
|
12
|
+
searchText?: (conversation: ConversationSummary) => string;
|
|
13
|
+
appearance?: MessagingAppearance;
|
|
14
|
+
active?: boolean;
|
|
15
|
+
onSelect: (conversation: ConversationSummary, messageId?: string) => void;
|
|
16
|
+
onDeleted?: (conversationId: string) => Promise<void> | void;
|
|
17
|
+
}
|
|
18
|
+
/** Compact Messenger chat rows: avatar, name, last message, time and unread badge. */
|
|
19
|
+
export declare function MessagingInbox({ client, userId, contacts, appearance, active, onSelect, onDeleted, companyName, headerActions, searchText }: MessagingInboxProps): React.JSX.Element;
|
package/dist/inbox.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { InboxMessageSearch } from './inbox-search.js';
|
|
3
|
+
import { MessageIcon } from './icons.js';
|
|
4
|
+
import { useEffect, useState, useSyncExternalStore } from 'react';
|
|
5
|
+
import { AppState, FlatList, Image, Platform, Pressable, Text, TextInput, View } from 'react-native';
|
|
6
|
+
import { defaultMessagingSettings } from '@robono/messaging';
|
|
7
|
+
import { MessagingPopup, MessagingPopupHeaderBoundary, messagingPopupColors } from './popup.js';
|
|
8
|
+
/** Compact Messenger chat rows: avatar, name, last message, time and unread badge. */
|
|
9
|
+
export function MessagingInbox({ client, userId, contacts = {}, appearance, active = true, onSelect, onDeleted, companyName, headerActions, searchText }) {
|
|
10
|
+
const state = useSyncExternalStore(client.subscribe, client.getSnapshot, client.getSnapshot), design = appearance ?? state.appearance ?? defaultMessagingSettings().appearance, c = design.colors;
|
|
11
|
+
useEffect(() => { const update = (value) => { if (active && (value === null || value === 'active'))
|
|
12
|
+
client.start();
|
|
13
|
+
else
|
|
14
|
+
client.stop(); }; update(AppState.currentState); const sub = AppState.addEventListener('change', update); return () => { sub.remove(); client.stop(); }; }, [client, active]);
|
|
15
|
+
const [query, setQuery] = useState('');
|
|
16
|
+
const [selected, setSelected] = useState(null), [confirm, setConfirm] = useState(false), [busy, setBusy] = useState(false), [error, setError] = useState('');
|
|
17
|
+
const title = (row) => row.kind === 'group' ? row.name ?? 'Group' : contacts[row.contactId ?? '']?.name ?? row.contactId ?? 'Chat';
|
|
18
|
+
const openOptions = (row) => { if (!state.inboxActionsAvailable)
|
|
19
|
+
return; setSelected(row); setConfirm(false); setError(''); };
|
|
20
|
+
useEffect(() => { if (selected && !state.conversations.some(c => c.id === selected.id) && !busy && !error)
|
|
21
|
+
setSelected(null); }, [state.conversations, busy, selected, error]);
|
|
22
|
+
async function act(remove) { if (!selected || busy)
|
|
23
|
+
return; setBusy(true); setError(''); try {
|
|
24
|
+
if (remove) {
|
|
25
|
+
await client.deleteConversation(selected);
|
|
26
|
+
await onDeleted?.(selected.id);
|
|
27
|
+
}
|
|
28
|
+
else
|
|
29
|
+
await client.setMuted(selected.id, !selected.muted);
|
|
30
|
+
setSelected(null);
|
|
31
|
+
setConfirm(false);
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
setError(e instanceof Error ? e.message : 'Chat options could not be saved.');
|
|
35
|
+
}
|
|
36
|
+
finally {
|
|
37
|
+
setBusy(false);
|
|
38
|
+
} }
|
|
39
|
+
const preview = (row) => { const m = row.lastMessage; if (!m)
|
|
40
|
+
return 'No messages yet'; const text = m.kind === 'text' ? m.text ?? '' : ({ voice: 'Voice message', image: 'Photo', video: 'Video', document: 'Document', poll: 'Poll' }[m.kind] ?? 'Message'); return (m.senderId === userId ? 'You: ' : '') + text; };
|
|
41
|
+
return _jsxs(View, { style: { flex: 1, minHeight: 0, backgroundColor: c.background }, children: [_jsx(MessagingPopupHeaderBoundary, { children: _jsxs(View, { testID: "messaging-inbox-header", style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', minHeight: 64, paddingHorizontal: 16, paddingVertical: 8, gap: 8 }, children: [_jsx(Text, { accessibilityRole: "header", numberOfLines: 1, style: { flex: 1, color: c.foreground, fontSize: 20, fontWeight: '700' }, children: companyName ?? design.name }), headerActions] }) }), _jsxs(View, { style: { margin: 12, marginTop: 8, flexDirection: 'row', alignItems: 'center', gap: 8, borderRadius: 10, paddingHorizontal: 10, backgroundColor: c.foreground + '0b' }, children: [_jsx(MessageIcon, { name: "search", size: 20, color: c.secondary }), _jsx(TextInput, { accessibilityLabel: "Search chats", placeholder: state.globalSearchAvailable ? "Search chats and messages" : "Search chats", placeholderTextColor: c.secondary, value: query, onChangeText: setQuery, maxLength: 200, autoCapitalize: "none", autoCorrect: false, style: { flex: 1, minHeight: 44, color: c.foreground, fontSize: 16 } }), !!query && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Clear chat search", onPress: () => setQuery(''), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 20, color: c.secondary }) })] }), state.error && _jsxs(View, { style: { padding: 12 }, children: [_jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: state.error }), _jsx(Pressable, { accessibilityRole: "button", onPress: () => void client.refresh().catch(() => { }), children: _jsx(Text, { style: { color: c.accent, paddingVertical: 8 }, children: "Try again" }) })] }), query.trim() && state.globalSearchAvailable ? _jsx(InboxMessageSearch, { client: client, query: query.trim(), appearance: design, name: title, chats: state.conversations.filter(row => title(row).toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), onSelect: onSelect }, userId) : _jsx(FlatList, { data: state.conversations.filter(row => !query.trim() || [title(row), row.lastMessage?.text ?? '', searchText?.(row) ?? ''].join(' ').toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), keyExtractor: row => row.id, contentContainerStyle: { flexGrow: 1 }, ItemSeparatorComponent: () => _jsx(View, { style: { height: 1, backgroundColor: c.foreground + '12', marginLeft: 64 } }), ListEmptyComponent: _jsx(Text, { style: { padding: 20, textAlign: 'center', color: c.secondary }, children: state.loading ? 'Loading chats…' : state.error ? '' : query ? 'No matching chats.' : 'No chats yet' }), ListFooterComponent: state.hasMore ? _jsx(Pressable, { accessibilityRole: "button", disabled: state.loading, onPress: () => void client.loadMore().catch(() => { }), children: _jsx(Text, { style: { padding: 12, textAlign: 'center', color: c.accent }, children: state.loading ? 'Loading…' : 'More chats' }) }) : null, renderItem: ({ item }) => {
|
|
42
|
+
const person = item.contactId ? contacts[item.contactId] : undefined, title = item.kind === 'group' ? item.name ?? 'Group' : person?.name ?? item.contactId ?? 'Chat';
|
|
43
|
+
const date = item.lastMessage ? new Date(item.lastMessage.createdAt) : null;
|
|
44
|
+
return _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `${title}${item.unreadCount ? `, ${item.unreadCount} unread` : ''}`, onPress: () => onSelect(item), onLongPress: () => openOptions(item), accessibilityActions: state.inboxActionsAvailable ? [{ name: 'showOptions', label: 'Chat options' }] : [], onAccessibilityAction: e => { if (e.nativeEvent.actionName === 'showOptions')
|
|
45
|
+
openOptions(item); }, ...(Platform.OS === 'web' ? { onContextMenu: (e) => { e.preventDefault(); openOptions(item); } } : {}), style: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 12, paddingVertical: 10, minHeight: 68 }, children: [person?.avatarUrl ? _jsx(Image, { source: { uri: person.avatarUrl }, style: { width: 40, height: 40, borderRadius: 20 } }) : _jsx(View, { style: { width: 40, height: 40, borderRadius: 20, backgroundColor: c.accent + '20', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: c.accent, fontSize: 18, fontWeight: '700' }, children: title.slice(0, 1).toUpperCase() }) }), _jsxs(View, { style: { flex: 1, minWidth: 0 }, children: [_jsxs(Text, { numberOfLines: 1, style: { color: c.foreground, fontSize: 16, fontWeight: '700' }, children: [title, item.muted ? ' · Muted' : ''] }), _jsx(Text, { numberOfLines: 1, style: { color: c.secondary, marginTop: 3, fontSize: 14 }, children: preview(item) })] }), _jsxs(View, { style: { alignItems: 'flex-end', gap: 4 }, children: [_jsx(Text, { style: { color: item.unreadCount ? c.accent : c.secondary, fontSize: 12 }, children: date ? date.toDateString() === new Date().toDateString() ? date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }) : date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : '' }), item.unreadCount > 0 && _jsx(View, { style: { minWidth: 20, height: 20, borderRadius: 10, borderWidth: 1.5, borderColor: c.accent, alignItems: 'center', justifyContent: 'center', paddingHorizontal: 5 }, children: _jsx(Text, { style: { color: c.accent, fontSize: 10, fontWeight: '900' }, children: item.unreadCount > 99 ? '99+' : item.unreadCount }) })] })] });
|
|
46
|
+
} }), selected && _jsxs(MessagingPopup, { compact: true, contentWidth: 640, title: confirm ? "Delete chat with " + title(selected) + "?" : title(selected), foreground: c.foreground, closeLabel: confirm ? 'Close delete chat' : 'Close chat options', closeDisabled: busy, background: c.background, onClose: () => { if (!busy) {
|
|
47
|
+
if (confirm)
|
|
48
|
+
setConfirm(false);
|
|
49
|
+
else
|
|
50
|
+
setSelected(null);
|
|
51
|
+
} }, children: [!confirm ? _jsxs(_Fragment, { children: [_jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => void act(false), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 16 }, children: selected.muted ? 'Unmute' : 'Mute' }) }), _jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => setConfirm(true), style: { minHeight: 44, justifyContent: 'center' }, children: _jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 16 }, children: "Delete chat" }) })] }) : _jsx(Text, { style: { color: messagingPopupColors.secondary, marginTop: 8 }, children: "This removes the chat and its history from your view. Other people keep their copy. New messages can bring the chat back." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: messagingPopupColors.foreground, paddingVertical: 8 }, children: error }), confirm && _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 12 }, children: [_jsx(Pressable, { accessibilityRole: "button", disabled: busy, onPress: () => setConfirm(false), style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: messagingPopupColors.incoming, alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: messagingPopupColors.foreground, fontSize: 16, fontWeight: '600' }, children: "Cancel" }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Confirm delete chat", disabled: busy, onPress: () => void act(true), style: { flex: 1, minHeight: 44, borderRadius: 12, backgroundColor: '#b72d34', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { color: '#ffffff', fontSize: 16, fontWeight: '600' }, children: "Delete" }) })] })] })] });
|
|
52
|
+
}
|