@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.
@@ -168,7 +168,7 @@ export class MessagingMediaController {
168
168
  })();
169
169
  return result;
170
170
  }
171
- async pickMany(kind, maximum) {
171
+ async pickMany(kind, maximum, mixedLibrary = false) {
172
172
  if (!this.options.stageLocal || !this.options.device.pickMany) {
173
173
  const one = await this.pick(kind, undefined, 'attach');
174
174
  return one ? [one] : [];
@@ -187,14 +187,14 @@ export class MessagingMediaController {
187
187
  let files = [];
188
188
  this.update({ phase: 'choosing', kind, file: null, error: null, progress: 0, retryUpload: false });
189
189
  try {
190
- files = await this.options.device.pickMany(kind, settings, abort.signal, limit);
190
+ files = await this.options.device.pickMany(kind, settings, abort.signal, limit, mixedLibrary);
191
191
  if (!this.isCurrent(g) || conversationId !== this.options.getConversationId())
192
192
  return [];
193
193
  if (files.length > limit)
194
194
  throw Error(`Choose up to ${limit} attachments.`);
195
195
  const media = files.map(file => ({ assetId: this.options.createId(), mimeType: file.mimeType, fileName: file.fileName, byteSize: file.byteSize, ...(file.durationMs === undefined ? {} : { durationMs: file.durationMs }) }));
196
196
  // Validate the whole selection before accepting any part of it.
197
- media.forEach(item => validateMessageMedia(kind, item, this.options.getSettings()));
197
+ media.forEach(item => validateMessageMedia(mixedLibrary && kind !== 'document' ? (item.mimeType.startsWith('video/') ? 'video' : 'image') : kind, item, this.options.getSettings()));
198
198
  media.forEach((item, index) => this.prepared.set(item.assetId, files[index]));
199
199
  files = [];
200
200
  return media;
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { messagingPopupBackdrop } from './popup.js';
2
+ import { messagingPopupBackdrop, MessagingMenuSurface } from './popup.js';
3
3
  import { useEffect } from 'react';
4
- import { Modal, Platform, Pressable, ScrollView, Text, View } from 'react-native';
4
+ import { Platform, Pressable, ScrollView, Text, View } from 'react-native';
5
5
  import { MessageIcon } from './icons.js';
6
6
  /** Anchor outside the selected bubble, never at its text's center. */
7
7
  export function messageMenuPosition(x, y, width, height, screenWidth, screenHeight, mine) {
@@ -19,6 +19,10 @@ export function MessageMenu({ visible, position, screenHeight, mine, reactions,
19
19
  e.stopPropagation();
20
20
  onClose();
21
21
  } }; document.addEventListener('keydown', key, true); return () => document.removeEventListener('keydown', key, true); }, [visible, onClose]);
22
- const card = _jsx(View, { testID: "message-action-card", style: { width: 218, maxWidth: '100%', maxHeight: Math.min(310, Math.max(43, position.available - (reactions ? 58 : 0))), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006', alignSelf: mine ? 'flex-end' : 'flex-start' }, children: _jsx(ScrollView, { showsVerticalScrollIndicator: false, bounces: false, keyboardShouldPersistTaps: "handled", children: children }) });
23
- return _jsx(Modal, { visible: visible, transparent: true, animationType: "none", onRequestClose: onClose, onDismiss: onDismiss, children: _jsxs(View, { style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close message options", onPress: onClose, style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsxs(View, { testID: "message-context-menu", style: { position: 'absolute', left: position.x, ...(position.above ? { bottom: screenHeight - position.y + 12 } : { top: position.y + 12 }), width: 300, maxWidth: '94%', gap: 8 }, children: [position.above ? card : reactions, position.above ? reactions : card] })] }) });
22
+ return _jsx(MessagingMenuSurface, { visible: visible, onClose: onClose, onDismiss: onDismiss, children: rect => {
23
+ const menuWidth = Math.min(300, rect.width - 24), y = Math.max(12, Math.min(position.y - rect.top, rect.height - 12));
24
+ const local = { ...position, x: Math.max(12, Math.min(position.x - rect.left, rect.width - menuWidth - 12)), y, available: Math.max(44, (position.above ? y : rect.height - y) - 24) };
25
+ const card = _jsx(View, { testID: "message-action-card", style: { width: 218, maxWidth: '100%', maxHeight: Math.min(310, Math.max(43, local.available - (reactions ? 58 : 0))), borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006', alignSelf: mine ? 'flex-end' : 'flex-start' }, children: _jsx(ScrollView, { showsVerticalScrollIndicator: false, bounces: false, keyboardShouldPersistTaps: "handled", children: children }) });
26
+ return _jsxs(View, { style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close message options", onPress: onClose, style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsxs(View, { testID: "message-context-menu", style: { position: 'absolute', left: local.x, ...(position.above ? { bottom: rect.height - local.y + 12 } : { top: local.y + 12 }), width: menuWidth, maxWidth: '94%', gap: 8 }, children: [position.above ? card : reactions, position.above ? reactions : card] })] });
27
+ } });
24
28
  }
package/dist/popup.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import React from 'react';
2
+ import { View } from 'react-native';
2
3
  export interface MessagingPopupSurfaceProps {
3
4
  children: React.ReactNode;
4
5
  visible: boolean;
@@ -24,12 +25,35 @@ export declare function MessagingPopupHeader({ title, foreground, onClose, close
24
25
  disabled?: boolean;
25
26
  back?: boolean;
26
27
  }): React.JSX.Element;
28
+ export declare function useMessagingPopupBounds(): {
29
+ root: React.RefObject<View | null>;
30
+ header: React.RefObject<View | null>;
31
+ revision: number;
32
+ changed: () => void;
33
+ } | null;
34
+ /** App-local overlay for anchored menus, without a browser-wide modal. */
35
+ export declare function MessagingPopupLayer({ children }: {
36
+ children: React.ReactNode;
37
+ }): React.JSX.Element | null;
27
38
  export declare function MessagingPopupHost({ children }: {
28
39
  children: React.ReactNode;
29
40
  }): React.JSX.Element;
30
41
  export declare function MessagingPopupHeaderBoundary({ children }: {
31
42
  children: React.ReactNode;
32
43
  }): React.JSX.Element;
44
+ type MenuRect = {
45
+ left: number;
46
+ top: number;
47
+ width: number;
48
+ height: number;
49
+ };
50
+ /** Anchored dropdowns use window coordinates on phones, app coordinates on web. */
51
+ export declare function MessagingMenuSurface({ visible, onClose, onDismiss, children }: {
52
+ visible: boolean;
53
+ onClose: () => void;
54
+ onDismiss?: () => void;
55
+ children: (rect: MenuRect) => React.ReactNode;
56
+ }): React.JSX.Element;
33
57
  /** Content panels occupy the area below the persistent header; confirmations stay compact. */
34
58
  export declare function MessagingPopup({ children, onClose, background, form, visible, onDismiss, expanded, bottomPadding, title, foreground, closeLabel, closeDisabled, contentWidth, headerBack, withinChat, compact }: {
35
59
  compact?: boolean;
@@ -49,3 +73,4 @@ export declare function MessagingPopup({ children, onClose, background, form, vi
49
73
  contentWidth?: number;
50
74
  headerBack?: boolean;
51
75
  }): React.JSX.Element;
76
+ export {};
package/dist/popup.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, useCallback, useMemo } from 'react';
3
3
  import { Keyboard, KeyboardAvoidingView, Modal, Platform, Pressable, SafeAreaView, Text, View, useWindowDimensions } from 'react-native';
4
4
  import { MessageIcon } from './icons.js';
@@ -12,16 +12,85 @@ export function MessagingPopupHeader({ title, foreground, onClose, closeLabel =
12
12
  }
13
13
  // Measure the host and its persistent header, including embedded native layouts.
14
14
  const PopupBounds = createContext(null);
15
+ const PopupLayer = createContext(null);
16
+ export function useMessagingPopupBounds() { return useContext(PopupBounds); }
17
+ /** App-local overlay for anchored menus, without a browser-wide modal. */
18
+ export function MessagingPopupLayer({ children }) {
19
+ const update = useContext(PopupLayer), owner = useRef(Symbol('menu')).current;
20
+ useLayoutEffect(() => { update?.(owner, children); return () => update?.(owner, null); }, [update, owner, children]);
21
+ return update ? null : _jsx(_Fragment, { children: children });
22
+ }
15
23
  export function MessagingPopupHost({ children }) {
16
24
  const root = useRef(null), header = useRef(null), [revision, setRevision] = useState(0);
25
+ const [layer, setLayer] = useState(null);
26
+ const update = useCallback((owner, node) => setLayer(current => node ? { owner, node } : current?.owner === owner ? null : current), []);
17
27
  const changed = useCallback(() => setRevision(value => value + 1), []);
18
28
  const value = useMemo(() => ({ root, header, revision, changed }), [revision, changed]);
19
- return _jsx(PopupBounds.Provider, { value: value, children: _jsx(View, { ref: root, collapsable: false, onLayout: changed, style: { flex: 1, minHeight: 0 }, children: children }) });
29
+ return _jsx(PopupBounds.Provider, { value: value, children: _jsx(PopupLayer.Provider, { value: update, children: _jsxs(View, { ref: root, collapsable: false, onLayout: changed, style: { flex: 1, minHeight: 0 }, children: [children, layer?.node] }) }) });
20
30
  }
21
31
  export function MessagingPopupHeaderBoundary({ children }) {
22
32
  const bounds = useContext(PopupBounds);
23
33
  return _jsx(View, { ref: bounds?.header, collapsable: false, onLayout: bounds?.changed, children: children });
24
34
  }
35
+ /** Anchored dropdowns use window coordinates on phones, app coordinates on web. */
36
+ export function MessagingMenuSurface({ visible, onClose, onDismiss, children }) {
37
+ const bounds = useContext(PopupBounds), viewport = useWindowDimensions(), panel = useRef(null), wasVisible = useRef(false), dismiss = useRef(onDismiss), close = useRef(onClose);
38
+ dismiss.current = onDismiss;
39
+ close.current = onClose;
40
+ const [rect, setRect] = useState(null);
41
+ useLayoutEffect(() => {
42
+ if (Platform.OS !== 'web' || !visible)
43
+ return;
44
+ let active = true;
45
+ const measure = () => bounds?.root.current?.measureInWindow((left, top, width, height) => { if (active)
46
+ setRect({ left, top, width, height }); });
47
+ measure();
48
+ const root = bounds?.root.current, observer = root ? new ResizeObserver(measure) : undefined;
49
+ if (root)
50
+ observer?.observe(root);
51
+ return () => { active = false; observer?.disconnect(); };
52
+ }, [visible, bounds, viewport.width, viewport.height]);
53
+ useEffect(() => { if (Platform.OS !== 'web')
54
+ return; if (wasVisible.current && !visible)
55
+ dismiss.current?.(); wasVisible.current = visible; }, [visible]);
56
+ useEffect(() => {
57
+ if (Platform.OS !== 'web' || !visible || !rect)
58
+ return;
59
+ const previous = document.activeElement, frame = requestAnimationFrame(() => { panel.current?.querySelector('[role="button"],button')?.focus(); });
60
+ const key = (event) => {
61
+ if (event.key === 'Escape') {
62
+ event.preventDefault();
63
+ event.stopPropagation();
64
+ close.current();
65
+ }
66
+ if (event.key !== 'Tab')
67
+ return;
68
+ const root = panel.current;
69
+ if (!root)
70
+ return;
71
+ const controls = [...root.querySelectorAll('[role="button"],button,[tabindex="0"]')].filter(el => el.getClientRects().length && el.getAttribute('aria-disabled') !== 'true');
72
+ const first = controls[0], last = controls.at(-1);
73
+ if (!first) {
74
+ event.preventDefault();
75
+ return;
76
+ }
77
+ if (event.shiftKey && (document.activeElement === first || !root.contains(document.activeElement))) {
78
+ event.preventDefault();
79
+ last?.focus();
80
+ }
81
+ else if (!event.shiftKey && (document.activeElement === last || !root.contains(document.activeElement))) {
82
+ event.preventDefault();
83
+ first.focus();
84
+ }
85
+ };
86
+ document.addEventListener('keydown', key, true);
87
+ return () => { cancelAnimationFrame(frame); document.removeEventListener('keydown', key, true); if (previous?.isConnected)
88
+ previous.focus({ preventScroll: true }); };
89
+ }, [visible, !!rect]);
90
+ if (Platform.OS !== 'web')
91
+ return _jsx(Modal, { visible: visible, transparent: true, animationType: "none", onRequestClose: onClose, onDismiss: onDismiss, children: children({ left: 0, top: 0, width: viewport.width, height: viewport.height }) });
92
+ return _jsx(MessagingPopupLayer, { children: visible && rect ? _jsx(View, { ref: panel, testID: "messaging-menu-surface", style: { position: 'absolute', inset: 0, zIndex: 1000, overflow: 'hidden' }, children: children(rect) }) : null });
93
+ }
25
94
  /** Content panels occupy the area below the persistent header; confirmations stay compact. */
26
95
  export function MessagingPopup({ children, onClose, background, form = false, visible = true, onDismiss, expanded = true, bottomPadding, title, foreground, closeLabel, closeDisabled = false, contentWidth, headerBack = false, withinChat = false, compact = false }) {
27
96
  const ChatSurface = useContext(MessagingPopupSurface), bounds = useContext(PopupBounds), viewport = useWindowDimensions();
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { MessagingPopupHeader, messagingPopupBackdrop } from './popup.js';
2
+ import { MessagingPopupHeader, messagingPopupBackdrop, MessagingMenuSurface } from './popup.js';
3
3
  import { useEffect, useRef, useState } from 'react';
4
- import { Modal, Platform, Pressable, ScrollView, Text, View, useWindowDimensions } from 'react-native';
4
+ import { Platform, Pressable, ScrollView, Text, View, useWindowDimensions } from 'react-native';
5
5
  import { quickMessageOptions, validQuickMessage } from '@robono/messaging';
6
6
  import { MessageIcon, chatSurface } from './icons.js';
7
7
  /** Messenger's empty-composer shortcut and anchored picker. Selecting never sends. */
@@ -34,5 +34,5 @@ export function QuickSendButton({ choice, empty, enabled, disabled, editing, sen
34
34
  held.current = true;
35
35
  onSchedule?.();
36
36
  }
37
- } } } : {}), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', opacity: unavailable ? .4 : 1 }, children: quick ? _jsx(Text, { style: { fontSize: 26, lineHeight: 32 }, children: emoji }) : _jsx(MessageIcon, { name: editing ? 'check' : 'send', size: 24, color: c.accent }) }), _jsx(Modal, { visible: anchor !== null, transparent: true, animationType: "fade", onRequestClose: () => setAnchor(null), children: _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close quick messages", onPress: () => setAnchor(null), style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsxs(View, { style: { position: 'absolute', width: Math.min(304, viewport.width - 24), left: Math.max(12, Math.min((anchor?.x ?? viewport.width) - 304, viewport.width - 316)), top: Math.max(48, Math.min((anchor?.y ?? viewport.height) - 282, viewport.height - 312)), backgroundColor: chatSurface(c.background), borderRadius: 18, borderWidth: 1, borderColor: c.secondary + '40', padding: 8, boxShadow: '0 4px 18px #0005' }, children: [_jsx(MessagingPopupHeader, { title: "Quick message", foreground: c.foreground, closeLabel: "Close quick message picker", onClose: () => setAnchor(null) }), _jsx(ScrollView, { style: { maxHeight: 220 }, keyboardShouldPersistTaps: "handled", contentContainerStyle: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' }, children: quickMessageOptions.map(item => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: item.label, accessibilityState: { selected: emoji === item.emoji }, onPress: () => { held.current = false; setAnchor(null); onChoose(item.emoji); }, style: { width: 46, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: 10, backgroundColor: emoji === item.emoji ? c.accent + '20' : 'transparent' }, children: _jsx(Text, { style: { fontSize: 26, lineHeight: 32 }, children: item.emoji }) }, item.emoji)) })] })] }) })] });
37
+ } } } : {}), style: { width: 44, height: 44, alignItems: 'center', justifyContent: 'center', opacity: unavailable ? .4 : 1 }, children: quick ? _jsx(Text, { style: { fontSize: 26, lineHeight: 32 }, children: emoji }) : _jsx(MessageIcon, { name: editing ? 'check' : 'send', size: 24, color: c.accent }) }), _jsx(MessagingMenuSurface, { visible: anchor !== null, onClose: () => setAnchor(null), children: rect => _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close quick messages", onPress: () => setAnchor(null), style: { position: 'absolute', inset: 0, backgroundColor: messagingPopupBackdrop } }), _jsxs(View, { style: { position: 'absolute', width: Math.min(304, rect.width - 24), maxHeight: Math.max(100, rect.height - 24), left: Math.max(12, Math.min((anchor?.x ?? rect.width) - rect.left - 304, rect.width - Math.min(304, rect.width - 24) - 12)), top: Math.max(12, Math.min((anchor?.y ?? rect.height) - rect.top - 282, rect.height - 312)), backgroundColor: chatSurface(c.background), borderRadius: 18, borderWidth: 1, borderColor: c.secondary + '40', padding: 8, boxShadow: '0 4px 18px #0005' }, children: [_jsx(MessagingPopupHeader, { title: "Quick message", foreground: c.foreground, closeLabel: "Close quick message picker", onClose: () => setAnchor(null) }), _jsx(ScrollView, { style: { maxHeight: Math.min(220, Math.max(44, rect.height - 90)) }, keyboardShouldPersistTaps: "handled", contentContainerStyle: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center' }, children: quickMessageOptions.map(item => _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: item.label, accessibilityState: { selected: emoji === item.emoji }, onPress: () => { held.current = false; setAnchor(null); onChoose(item.emoji); }, style: { width: 46, height: 44, alignItems: 'center', justifyContent: 'center', borderRadius: 10, backgroundColor: emoji === item.emoji ? c.accent + '20' : 'transparent' }, children: _jsx(Text, { style: { fontSize: 26, lineHeight: 32 }, children: item.emoji }) }, item.emoji)) })] })] }) })] });
38
38
  }
package/dist/safety.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { MessagingMenuSurface } from './popup.js';
2
3
  import { messagingPopupColors } from './popup.js';
3
4
  import { useEffect, useRef, useState } from 'react';
4
5
  import { MessagingPopup } from './popup.js';
5
6
  import { MessageIcon } from './icons.js';
6
- import { Modal, Platform, Pressable, ScrollView, Text, TextInput, View, useWindowDimensions } from 'react-native';
7
+ import { Platform, Pressable, ScrollView, Text, TextInput, View, useWindowDimensions } from 'react-native';
7
8
  /** Uses the authenticated messaging client's durable safety operations, or the host can keep its own UI. */
8
9
  export function MessagingSafety({ client, state, appearance, name, messageId, onClose, onSearch, onDetails, onPins, onScheduled, onSaved, children, menuAnchor }) {
9
10
  const [mode, setMode] = useState(messageId ? 'report' : 'menu');
@@ -61,7 +62,7 @@ export function MessagingSafety({ client, state, appearance, name, messageId, on
61
62
  const choose = (next) => { afterClose.current = () => { setMode(next); setVisible(true); }; setVisible(false); };
62
63
  if (mode === 'menu' && menuAnchor) {
63
64
  const count = Number(!!onSearch) + Number(!!onDetails) + Number(!!onPins) + Number(!!onScheduled) + Number(!!children) + Number(!!safety?.canBlock) + Number(!!safety?.canReport), height = count * 44 + 8;
64
- return _jsx(Modal, { visible: visible, transparent: true, animationType: "none", onDismiss: dismissed, onRequestClose: onClose, children: _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close contact options", onPress: onClose, style: { position: 'absolute', inset: 0 } }), _jsx(View, { testID: "messaging-contact-dropdown", style: { position: 'absolute', left: menuAnchor.x, top: Math.max(12, Math.min(menuAnchor.y, viewport.height - height - 12)), width: Math.min(240, viewport.width - 24), maxHeight: viewport.height - 24, borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006' }, children: _jsxs(ScrollView, { bounces: false, keyboardShouldPersistTaps: "handled", children: [onDetails && menuRow(state.conversation?.kind === 'group' ? 'Group details' : 'Contact details', 'person', () => { afterClose.current = onDetails; setVisible(false); }), onPins && menuRow('Pinned messages', 'bookmark', () => { afterClose.current = onPins; setVisible(false); }), onScheduled && menuRow('Scheduled messages', 'send', () => { afterClose.current = onScheduled; setVisible(false); }), onSearch && menuRow('Search this chat', 'search', search), children && menuRow('Translation', 'translate', () => choose('translation')), safety?.canBlock && menuRow(blocked ? 'Unblock contact' : 'Block contact', 'shield', () => choose('block')), safety?.canReport && menuRow('Report', 'flag-outline', () => choose('report'))] }) })] }) });
65
+ return _jsx(MessagingMenuSurface, { visible: visible, onDismiss: dismissed, onClose: onClose, children: rect => _jsxs(View, { accessibilityViewIsModal: true, style: { flex: 1 }, children: [_jsx(Pressable, { accessibilityLabel: "Close contact options", onPress: onClose, style: { position: 'absolute', inset: 0 } }), _jsx(View, { testID: "messaging-contact-dropdown", style: { position: 'absolute', left: Math.max(12, Math.min(menuAnchor.x - rect.left, rect.width - Math.min(240, rect.width - 24) - 12)), top: Math.max(12, Math.min(menuAnchor.y - rect.top, rect.height - height - 12)), width: Math.min(240, rect.width - 24), maxHeight: rect.height - 24, borderRadius: 16, borderWidth: .5, borderColor: '#ffffff22', backgroundColor: '#151515', paddingVertical: 4, overflow: 'hidden', boxShadow: '0 8px 18px #0006' }, children: _jsxs(ScrollView, { bounces: false, keyboardShouldPersistTaps: "handled", children: [onDetails && menuRow(state.conversation?.kind === 'group' ? 'Group details' : 'Contact details', 'person', () => { afterClose.current = onDetails; setVisible(false); }), onPins && menuRow('Pinned messages', 'bookmark', () => { afterClose.current = onPins; setVisible(false); }), onScheduled && menuRow('Scheduled messages', 'send', () => { afterClose.current = onScheduled; setVisible(false); }), onSearch && menuRow('Search this chat', 'search', search), children && menuRow('Translation', 'translate', () => choose('translation')), safety?.canBlock && menuRow(blocked ? 'Unblock contact' : 'Block contact', 'shield', () => choose('block')), safety?.canReport && menuRow('Report', 'flag-outline', () => choose('report'))] }) })] }) });
65
66
  }
66
67
  return _jsx(MessagingPopup, { compact: mode === 'block' || mode === 'sent', withinChat: mode === 'translation', contentWidth: 640, visible: visible, onDismiss: dismissed, background: c.background, foreground: c.foreground, closeLabel: "Close contact options", closeDisabled: busy, title: mode === 'menu' ? name : mode === 'translation' ? 'Translation' : mode === 'block' ? `${blocked ? 'Unblock' : 'Block'} ${name}?` : mode === 'sent' ? 'Report sent' : messageId ? 'Report message' : 'Report conversation', onClose: () => { if (!busy)
67
68
  onClose(); }, children: _jsxs(ScrollView, { keyboardShouldPersistTaps: "handled", style: { ...(mode === 'block' || mode === 'sent' ? { flexGrow: 0, flexShrink: 1 } : { flex: 1 }), minHeight: 0 }, contentContainerStyle: { gap: 20, paddingBottom: 12 }, children: [mode === 'menu' && _jsxs(_Fragment, { children: [onSearch && _jsx(View, { style: { backgroundColor: '#111111', borderRadius: 12, borderWidth: 1, borderColor: '#ffffff14', overflow: 'hidden' }, children: menuRow('Search this chat', 'search', search) }), children, (safety?.canBlock || safety?.canReport) && _jsxs(View, { style: { backgroundColor: '#111111', borderRadius: 12, borderWidth: 1, borderColor: '#ffffff14', overflow: 'hidden' }, children: [safety?.canBlock && menuRow(blocked ? 'Unblock contact' : 'Block contact', 'shield', () => setMode('block')), safety?.canReport && menuRow('Report', 'flag-outline', () => setMode('report'))] })] }), mode === 'translation' && children, mode === 'block' && _jsx(_Fragment, { children: _jsx(Text, { style: { fontSize: 14, lineHeight: 21, color: c.secondary }, children: blocked ? 'You will be able to message and call each other again. Messages attempted while blocked will not be delivered.' : 'This stops direct messages and calls between you. Your existing chat stays. They will not receive a notification that you blocked them. Shared group messages are not hidden.' }) }), mode === 'report' && _jsxs(_Fragment, { children: [_jsxs(Text, { style: { fontSize: 14, lineHeight: 21, color: c.secondary }, children: [messageId ? 'This message and your reason will be shared with this app’s moderation team.' : 'Up to five recent messages from other people in this conversation and your reason will be shared with this app’s moderation team.', " Report details are kept for up to 30 days. Reporting does not block this contact."] }), ['Spam', 'Harassment', 'Inappropriate content', 'Other'].map(value => _jsxs(Pressable, { accessibilityRole: "radio", accessibilityLabel: value, accessibilityState: { checked: category === value }, disabled: busy, onPress: () => setCategory(value), style: { minHeight: 40, flexDirection: 'row', alignItems: 'center', gap: 10 }, children: [_jsx(View, { style: { width: 18, height: 18, borderRadius: 9, borderWidth: 2, borderColor: c.accent, backgroundColor: category === value ? c.accent : 'transparent' } }), _jsx(Text, { style: { color: c.foreground, fontSize: 15 }, children: value })] }, value)), _jsx(TextInput, { accessibilityLabel: "Report details", placeholder: "Tell us what happened (optional)", placeholderTextColor: c.secondary, value: details, onChangeText: setDetails, multiline: true, maxLength: 1800, editable: !busy, style: { color: c.foreground, minHeight: 80, borderWidth: 1, borderColor: c.secondary + '60', borderRadius: 10, padding: 12, fontSize: 15 } })] }), mode === 'sent' && _jsx(Text, { style: { color: c.secondary, fontSize: 15, lineHeight: 21 }, children: "Your report was saved for review. The other person is not notified." }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: c.foreground }, children: error }), mode !== 'menu' && mode !== 'translation' && _jsxs(View, { style: { flexDirection: 'row', gap: 8, marginTop: 8 }, children: [(mode === 'block' || mode === 'report') && button('Cancel', onClose, false, 'cancel'), mode === 'block' && button(blocked ? 'Unblock' : 'Block', () => void block(), false, blocked ? 'primary' : 'danger'), mode === 'report' && button(busy ? 'Sending…' : 'Send report', () => void report(), !category && !details.trim(), 'primary'), mode === 'sent' && button('Done', onClose, false, 'primary')] })] }) });
@@ -1,7 +1,7 @@
1
1
  import { Animated } from 'react-native';
2
2
  export declare const messageInfoRevealWidth = 176;
3
3
  export declare const messageReplyRevealWidth = 104;
4
- export declare function useQuoteGesture(enabled: boolean, outgoing: boolean, quote: (() => void) | undefined, details?: () => void): {
4
+ export declare function useQuoteGesture(enabled: boolean, outgoing: boolean, quote: (() => void) | undefined, details?: () => void, onQuoteReady?: () => void): {
5
5
  offset: number | Animated.Value;
6
6
  webRef: import("react").RefObject<HTMLElement | null> | undefined;
7
7
  infoLoaded: boolean;
@@ -4,14 +4,15 @@ import { Animated, Easing, I18nManager, PanResponder, Platform } from 'react-nat
4
4
  export const messageInfoRevealWidth = 176;
5
5
  export const messageReplyRevealWidth = 104;
6
6
  const threshold = 82;
7
- export function useQuoteGesture(enabled, outgoing, quote, details) {
7
+ export function useQuoteGesture(enabled, outgoing, quote, details, onQuoteReady) {
8
8
  const offset = useRef(new Animated.Value(0)).current;
9
9
  const [webOffset, setWebOffset] = useState(0);
10
10
  const [webDragging, setWebDragging] = useState(false);
11
11
  const direction = (outgoing !== !!I18nManager.isRTL) ? -1 : 1;
12
12
  const [infoLoaded, setInfoLoaded] = useState(false);
13
- const current = useRef({ enabled, direction, quote, details });
14
- current.current = { enabled, direction, quote, details };
13
+ const quoteReady = useRef(false);
14
+ const current = useRef({ enabled, direction, quote, details, onQuoteReady });
15
+ current.current = { enabled, direction, quote, details, onQuoteReady };
15
16
  const suppressUntil = useRef(0);
16
17
  const reset = () => {
17
18
  if (Platform.OS === 'web') {
@@ -29,6 +30,10 @@ export function useQuoteGesture(enabled, outgoing, quote, details) {
29
30
  };
30
31
  const move = (dx) => {
31
32
  suppressUntil.current = Date.now() + 400;
33
+ const ready = !!current.current.quote && dx * current.current.direction >= threshold;
34
+ if (ready && !quoteReady.current)
35
+ current.current.onQuoteReady?.();
36
+ quoteReady.current = ready;
32
37
  const info = dx * current.current.direction < 0;
33
38
  if (info)
34
39
  setInfoLoaded(true);
@@ -47,6 +52,7 @@ export function useQuoteGesture(enabled, outgoing, quote, details) {
47
52
  current.current.quote?.();
48
53
  else if (!cancelled && current.current.enabled && dx * current.current.direction <= -threshold)
49
54
  current.current.details?.();
55
+ quoteReady.current = false;
50
56
  reset();
51
57
  };
52
58
  const responder = useRef(PanResponder.create({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robono/messaging-react-native",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Optional, composable direct and group messaging screens for Robono.",
@@ -53,7 +53,7 @@
53
53
  "prepack": "npm run build && node ../../scripts/generate-sdk-file-sbom.mjs"
54
54
  },
55
55
  "peerDependencies": {
56
- "@robono/messaging": "^0.4.0",
56
+ "@robono/messaging": "^0.5.0",
57
57
  "react": ">=18.2 <20",
58
58
  "react-native": ">=0.76 <1",
59
59
  "expo-audio": ">=55 <57",
@@ -65,14 +65,20 @@
65
65
  "expo-crypto": ">=55 <57",
66
66
  "react-native-safe-area-context": ">=5 <6",
67
67
  "react-native-svg": ">=15 <16",
68
- "@robono/calling-react-native": "^0.3.0",
68
+ "@robono/calling-react-native": "^0.5.0",
69
69
  "expo-clipboard": ">=8 <57",
70
70
  "expo-image-manipulator": ">=14 <57",
71
71
  "expo-video-thumbnails": ">=14 <57",
72
72
  "react-native-webview": ">=13 <14",
73
73
  "expo-blur": ">=55 <57",
74
74
  "expo-notifications": ">=55 <57",
75
- "expo-secure-store": ">=55 <57"
75
+ "expo-secure-store": ">=55 <57",
76
+ "@robono/location-react-native": "^0.2.0",
77
+ "expo-location": ">=55 <57",
78
+ "expo-constants": ">=55 <57",
79
+ "@robono/location": "^0.2.0",
80
+ "expo-haptics": ">=55 <57",
81
+ "@robono/calling": "^0.4.0"
76
82
  },
77
83
  "license": "SEE LICENSE IN LICENSE",
78
84
  "peerDependenciesMeta": {
@@ -123,6 +129,9 @@
123
129
  },
124
130
  "expo-secure-store": {
125
131
  "optional": true
132
+ },
133
+ "expo-haptics": {
134
+ "optional": true
126
135
  }
127
136
  },
128
137
  "author": {