@robono/messaging-react-native 0.2.0 → 0.3.1
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 +55 -0
- package/README.md +84 -7
- package/SBOM.spdx.json +1290 -234
- 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-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 +46 -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.js +2 -1
- package/dist/expo-privacy.d.ts +4 -0
- package/dist/expo-privacy.js +7 -0
- package/dist/expo-screen.d.ts +42 -0
- package/dist/expo-screen.js +557 -0
- package/dist/expo.d.ts +2 -42
- package/dist/expo.js +2 -549
- 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.js +4 -3
- 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 +2 -0
- package/dist/icons.js +2 -0
- package/dist/inbox-search.d.ts +11 -0
- package/dist/inbox-search.js +42 -0
- package/dist/inbox.d.ts +5 -2
- package/dist/inbox.js +19 -15
- package/dist/index.d.ts +33 -3
- package/dist/index.js +359 -141
- package/dist/media-controller.d.ts +3 -0
- package/dist/media-controller.js +39 -0
- package/dist/media-ui.d.ts +14 -3
- package/dist/media-ui.js +90 -28
- package/dist/message-menu.d.ts +28 -0
- package/dist/message-menu.js +24 -0
- package/dist/message-order.d.ts +3 -0
- package/dist/message-order.js +10 -0
- package/dist/messenger-video-view.d.ts +8 -0
- package/dist/messenger-video-view.js +46 -0
- package/dist/native-outbox.d.ts +8 -2
- package/dist/native-outbox.js +3 -1
- package/dist/photo-gesture.d.ts +20 -0
- package/dist/photo-gesture.js +52 -0
- package/dist/polls.d.ts +2 -1
- package/dist/polls.js +19 -17
- package/dist/popup.d.ts +43 -1
- package/dist/popup.js +72 -6
- package/dist/quick-send.d.ts +15 -0
- package/dist/quick-send.js +26 -0
- package/dist/safety.d.ts +9 -1
- package/dist/safety.js +21 -6
- package/dist/saved.d.ts +1 -1
- package/dist/saved.js +9 -4
- package/dist/search.d.ts +2 -2
- package/dist/search.js +32 -14
- package/dist/social.d.ts +9 -3
- package/dist/social.js +11 -15
- 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/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 +24 -4
|
@@ -29,6 +29,8 @@ export interface MessagingMediaStorage {
|
|
|
29
29
|
}
|
|
30
30
|
export interface MessagingMediaDevice {
|
|
31
31
|
pick(kind: Exclude<MediaKind, 'voice'>, settings: MessagingSettings, signal: AbortSignal, source?: "camera" | "call-video"): Promise<LocalMessagingMedia | null>;
|
|
32
|
+
/** Ordered local selections. No upload or send occurs in the picker. */
|
|
33
|
+
pickMany?(kind: Exclude<MediaKind, 'voice'>, settings: MessagingSettings, signal: AbortSignal, maximum: number): Promise<LocalMessagingMedia[]>;
|
|
32
34
|
startRecording(maxDurationMs: number, signal: AbortSignal): Promise<void>;
|
|
33
35
|
recordingStatus(): {
|
|
34
36
|
durationMs: number;
|
|
@@ -97,6 +99,7 @@ export declare class MessagingMediaController {
|
|
|
97
99
|
private update;
|
|
98
100
|
private isCurrent;
|
|
99
101
|
pick(kind: MediaKind, source?: "camera" | "call-video", selectionIntent?: "preview" | "send" | "attach"): Promise<MessageMedia | null>;
|
|
102
|
+
pickMany(kind: Exclude<MediaKind, 'voice'>, maximum: number): Promise<MessageMedia[]>;
|
|
100
103
|
private check;
|
|
101
104
|
finishRecording(interrupted?: boolean): Promise<void>;
|
|
102
105
|
send(): Promise<void>;
|
package/dist/media-controller.js
CHANGED
|
@@ -168,6 +168,45 @@ export class MessagingMediaController {
|
|
|
168
168
|
})();
|
|
169
169
|
return result;
|
|
170
170
|
}
|
|
171
|
+
async pickMany(kind, maximum) {
|
|
172
|
+
if (!this.options.stageLocal || !this.options.device.pickMany) {
|
|
173
|
+
const one = await this.pick(kind, undefined, 'attach');
|
|
174
|
+
return one ? [one] : [];
|
|
175
|
+
}
|
|
176
|
+
const limit = Math.max(1, Math.min(10, Math.floor(maximum) || 1));
|
|
177
|
+
const intent = ++this.intent;
|
|
178
|
+
await this.closeCurrent();
|
|
179
|
+
if (intent !== this.intent)
|
|
180
|
+
return [];
|
|
181
|
+
if (this.disposed)
|
|
182
|
+
throw Error('Messaging media is closed.');
|
|
183
|
+
const settings = this.options.getSettings(), conversationId = this.options.getConversationId();
|
|
184
|
+
if (!settings.enabled || !settings.features[kind])
|
|
185
|
+
throw Error('This attachment type is unavailable.');
|
|
186
|
+
const g = ++this.generation, abort = this.abort = new AbortController();
|
|
187
|
+
let files = [];
|
|
188
|
+
this.update({ phase: 'choosing', kind, file: null, error: null, progress: 0, retryUpload: false });
|
|
189
|
+
try {
|
|
190
|
+
files = await this.options.device.pickMany(kind, settings, abort.signal, limit);
|
|
191
|
+
if (!this.isCurrent(g) || conversationId !== this.options.getConversationId())
|
|
192
|
+
return [];
|
|
193
|
+
if (files.length > limit)
|
|
194
|
+
throw Error(`Choose up to ${limit} attachments.`);
|
|
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
|
+
// Validate the whole selection before accepting any part of it.
|
|
197
|
+
media.forEach(item => validateMessageMedia(kind, item, this.options.getSettings()));
|
|
198
|
+
media.forEach((item, index) => this.prepared.set(item.assetId, files[index]));
|
|
199
|
+
files = [];
|
|
200
|
+
return media;
|
|
201
|
+
}
|
|
202
|
+
finally {
|
|
203
|
+
await Promise.all(files.map(file => this.options.device.remove(file).catch(() => { })));
|
|
204
|
+
if (this.generation === g) {
|
|
205
|
+
this.abort = null;
|
|
206
|
+
this.update({ phase: 'idle', file: null, error: null });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
171
210
|
check(file) {
|
|
172
211
|
validateMessageMedia(this.state.kind, { ...file, assetId: 'pending-upload' }, this.options.getSettings());
|
|
173
212
|
if (this.options.getConversationId() !== this.conversationId)
|
package/dist/media-ui.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import React, { type ReactNode } from 'react';
|
|
2
2
|
import type { MessagingMessage } from '@robono/messaging';
|
|
3
3
|
export interface VoiceBubbleProps {
|
|
4
|
+
mine?: boolean;
|
|
5
|
+
heard?: boolean;
|
|
6
|
+
onPlayed?: () => void;
|
|
7
|
+
background?: string;
|
|
4
8
|
message: MessagingMessage;
|
|
5
9
|
ink: string;
|
|
6
10
|
accent: string;
|
|
@@ -22,25 +26,32 @@ export interface PlaybackProps {
|
|
|
22
26
|
onRate?: () => void;
|
|
23
27
|
}
|
|
24
28
|
/** Same chat controls on web, iOS and Android. Audio engines supply state, not a different UI. */
|
|
25
|
-
export declare function VoicePlayback({ ink, accent, waveform, showWaveform, footer, onLongPress, playing, loading, positionMs, durationMs, error, onToggle, onSeek }: PlaybackProps & {
|
|
29
|
+
export declare function VoicePlayback({ ink, accent, waveform, showWaveform, footer, onLongPress, mine, heard, onPlayed, background, playing, loading, positionMs, durationMs, error, onToggle, onSeek }: PlaybackProps & {
|
|
26
30
|
ink: string;
|
|
27
31
|
accent: string;
|
|
28
32
|
waveform?: number[];
|
|
29
33
|
showWaveform?: boolean;
|
|
30
34
|
footer?: ReactNode;
|
|
31
35
|
onLongPress?: () => void;
|
|
36
|
+
mine?: boolean;
|
|
37
|
+
heard?: boolean;
|
|
38
|
+
onPlayed?: () => void;
|
|
39
|
+
background?: string;
|
|
32
40
|
}): React.JSX.Element;
|
|
33
41
|
export declare function MediaPlaybackControls({ playing, loading, positionMs, durationMs, error, onToggle, onSeek, onFullscreen, fullscreen }: PlaybackProps & {
|
|
34
42
|
onFullscreen?: () => void;
|
|
35
43
|
fullscreen?: boolean;
|
|
36
44
|
}): React.JSX.Element;
|
|
37
45
|
/** A full-size photo surface. The same tap-to-hide-controls, pinch and pan handling runs in the preview and native app. */
|
|
38
|
-
export declare function ZoomablePhoto({ uri, label, onTap }: {
|
|
46
|
+
export declare function ZoomablePhoto({ uri, label, onTap, expanded }: {
|
|
39
47
|
uri: string;
|
|
40
48
|
label?: string;
|
|
41
49
|
onTap?: () => void;
|
|
50
|
+
expanded?: boolean;
|
|
42
51
|
}): React.JSX.Element;
|
|
43
|
-
export declare function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview, onDownload, onShare, onForward, onDelete, downloading }: {
|
|
52
|
+
export declare function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview, onDownload, onShare, onForward, onDelete, downloading, expanded, onExpandedChange }: {
|
|
53
|
+
expanded?: boolean;
|
|
54
|
+
onExpandedChange?: (expanded: boolean) => void;
|
|
44
55
|
onForward?: () => void;
|
|
45
56
|
onDelete?: () => void;
|
|
46
57
|
downloading?: boolean;
|
package/dist/media-ui.js
CHANGED
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { messengerVoiceWaveform } from './voice-waveform.js';
|
|
3
|
+
import { createPhotoGesture } from './photo-gesture.js';
|
|
2
4
|
import { useRef, useContext, useState, useEffect } from 'react';
|
|
3
5
|
import { ActivityIndicator, Image, PanResponder, Platform, Pressable, Text, View } from 'react-native';
|
|
4
6
|
import { MediaReadiness } from './media-readiness.js';
|
|
5
7
|
import { MessageIcon, duration, fileSize } from './icons.js';
|
|
6
8
|
/** Same chat controls on web, iOS and Android. Audio engines supply state, not a different UI. */
|
|
7
|
-
export function VoicePlayback({ ink, accent, waveform, showWaveform = true, footer, onLongPress, playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek }) {
|
|
8
|
-
const width = useRef(1), progress = durationMs ? Math.max(0, Math.min(1, positionMs / durationMs)) : 0;
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
export function VoicePlayback({ ink, accent, waveform, showWaveform = true, footer, onLongPress, mine = false, heard = false, onPlayed, background, playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek }) {
|
|
10
|
+
const width = useRef(1), held = useRef(false), [played, setPlayed] = useState(false), progress = durationMs ? Math.max(0, Math.min(1, positionMs / durationMs)) : 0;
|
|
11
|
+
useEffect(() => { if (playing && positionMs > 0) {
|
|
12
|
+
setPlayed(true);
|
|
13
|
+
onPlayed?.();
|
|
14
|
+
} }, [playing, positionMs, onPlayed]);
|
|
15
|
+
const bars = showWaveform ? messengerVoiceWaveform(waveform, durationMs) : null, fill = mine ? ink : accent;
|
|
16
|
+
const hold = () => { held.current = true; onLongPress?.(); };
|
|
17
|
+
return _jsxs(View, { testID: "voice-message-player", style: { width: 220, maxWidth: '100%', minHeight: 44, borderRadius: 24, borderWidth: 1, borderColor: mine ? '#ffffff24' : '#2A2A2A', backgroundColor: background ?? (mine ? '#1F5F7A' : '#1D1D1D'), paddingLeft: 5, paddingRight: 8, paddingVertical: 3 }, children: [_jsxs(View, { style: { flexDirection: 'row', direction: 'ltr', alignItems: 'center', gap: 8 }, children: [_jsx(Pressable, { testID: "voice-play-control", accessibilityRole: "button", accessibilityLabel: playing ? 'Pause voice message' : 'Play voice message', accessibilityState: { busy: loading }, disabled: loading, hitSlop: 6, delayLongPress: 360, onPressIn: () => { held.current = false; }, onPress: () => { if (!held.current)
|
|
18
|
+
onToggle(); }, onLongPress: hold, style: { width: 36, height: 36, borderRadius: 18, paddingLeft: playing ? 0 : 2, backgroundColor: mine ? '#ffffff29' : heard || played ? accent : '#2e9d63', alignItems: 'center', justifyContent: 'center' }, children: loading ? _jsx(ActivityIndicator, { size: "small", color: ink }) : _jsx(MessageIcon, { name: playing ? 'pause' : 'play', color: ink, size: 20 }) }), _jsxs(View, { style: { flex: 1, minWidth: 0, gap: 7 }, children: [_jsx(Pressable, { onLongPress: hold, accessibilityRole: "adjustable", accessibilityLabel: showWaveform ? 'Voice waveform' : 'Voice position', accessibilityValue: { min: 0, max: Math.ceil(durationMs / 1000), now: Math.floor(positionMs / 1000) }, accessibilityActions: [{ name: 'increment', label: 'Forward five seconds' }, { name: 'decrement', label: 'Back five seconds' }], onAccessibilityAction: e => onSeek?.(Math.max(0, Math.min(durationMs, positionMs + (e.nativeEvent.actionName === 'increment' ? 5000 : -5000)))), onLayout: e => { width.current = e.nativeEvent.layout.width; }, onPressIn: () => { held.current = false; }, onPress: e => { if (held.current)
|
|
19
|
+
return; const bounds = Platform.OS === 'web' ? e.currentTarget.getBoundingClientRect() : null; const x = bounds ? e.nativeEvent.clientX - bounds.left : e.nativeEvent.locationX; const total = bounds?.width ?? width.current; if (total > 0 && Number.isFinite(x))
|
|
20
|
+
onSeek?.(Math.max(0, Math.min(1, x / total)) * durationMs); }, style: { height: bars ? 24 : 9, transform: [{ translateY: 4 }], flexDirection: 'row', alignItems: 'center', gap: 1 }, children: bars ? bars.map((sample, i) => _jsx(View, { pointerEvents: "none", style: { flex: 1, flexBasis: 0, borderRadius: 2, overflow: 'hidden', backgroundColor: ink + (mine ? '33' : '38'), height: 2 + sample * 18 }, children: _jsx(View, { style: { height: '100%', backgroundColor: fill, width: `${Math.max(0, Math.min(1, progress * bars.length - i)) * 100}%` } }) }, i)) : _jsx(View, { pointerEvents: "none", style: { flex: 1, height: 9, borderRadius: 5, overflow: 'hidden', backgroundColor: ink + '33' }, children: _jsx(View, { style: { height: 9, borderRadius: 5, backgroundColor: fill, width: `${progress * 100}%` } }) }) }), _jsxs(View, { style: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 4, paddingRight: footer ? 6 : 0 }, children: [_jsx(Text, { testID: "voice-duration", style: { color: mine ? ink : '#A3A3A3', fontSize: 11, fontWeight: '800', fontVariant: ['tabular-nums'] }, children: duration(playing || positionMs > 0 ? Math.max(0, durationMs - positionMs) : durationMs) }), footer && _jsx(View, { testID: "voice-message-actions", style: { flexShrink: 1, maxWidth: 112, alignItems: 'flex-end' }, children: footer })] })] })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: ink, fontSize: 12, marginTop: 6 }, children: error })] });
|
|
11
21
|
}
|
|
12
22
|
export function MediaPlaybackControls({ playing = false, loading = false, positionMs = 0, durationMs = 0, error, onToggle, onSeek, onFullscreen, fullscreen = false }) {
|
|
13
23
|
const width = useRef(1);
|
|
@@ -15,32 +25,84 @@ export function MediaPlaybackControls({ playing = false, loading = false, positi
|
|
|
15
25
|
onSeek?.(Math.max(0, Math.min(1, x / total)) * durationMs); }, style: { flex: 1, height: 44, justifyContent: 'center' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#ffffff44' }, children: _jsx(View, { style: { height: 3, borderRadius: 2, backgroundColor: '#fff', width: `${durationMs ? Math.min(100, positionMs / durationMs * 100) : 0}%` } }) }) }), _jsx(Text, { style: { color: '#bbb', fontSize: 12, fontVariant: ['tabular-nums'] }, children: duration(durationMs) }), onFullscreen && _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: fullscreen ? "Exit full screen" : "Full screen video", disabled: loading, onPress: onFullscreen, style: { width: 44, height: 44, alignItems: "center", justifyContent: "center" }, children: _jsx(MessageIcon, { name: fullscreen ? "close" : "open-in-new", color: "#fff", size: 22 }) })] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', fontSize: 13 }, children: error })] });
|
|
16
26
|
}
|
|
17
27
|
/** A full-size photo surface. The same tap-to-hide-controls, pinch and pan handling runs in the preview and native app. */
|
|
18
|
-
export function ZoomablePhoto({ uri, label = 'Photo', onTap }) {
|
|
28
|
+
export function ZoomablePhoto({ uri, label = 'Photo', onTap, expanded = false }) {
|
|
19
29
|
const mediaReady = useContext(MediaReadiness);
|
|
20
|
-
const [
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
const
|
|
30
|
+
const [transform, setTransform] = useState({ zoom: 1, x: 0, y: 0 }), [error, setError] = useState(false);
|
|
31
|
+
const [viewport, setViewport] = useState({ width: 1, height: 1 }), [imageSize, setImageSize] = useState({ width: 1, height: 1 });
|
|
32
|
+
const rotated = expanded && imageSize.width > imageSize.height && viewport.height > viewport.width;
|
|
33
|
+
const rotation = useRef(rotated);
|
|
34
|
+
rotation.current = rotated;
|
|
35
|
+
const photoPoints = (points) => rotation.current ? points.map(p => ({ x: p.y, y: -p.x })) : points;
|
|
36
|
+
const stageWidth = rotated ? viewport.height : viewport.width, stageHeight = rotated ? viewport.width : viewport.height;
|
|
37
|
+
const gesture = useRef(null);
|
|
38
|
+
if (!gesture.current)
|
|
39
|
+
gesture.current = createPhotoGesture(setTransform);
|
|
40
|
+
const controller = gesture.current, tap = useRef(onTap);
|
|
41
|
+
tap.current = onTap;
|
|
42
|
+
const points = (e) => photoPoints(Array.from(e.nativeEvent.touches ?? [], (p) => ({ x: p.pageX, y: p.pageY })));
|
|
24
43
|
const pan = useRef(PanResponder.create({
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
onPanResponderRelease:
|
|
36
|
-
|
|
44
|
+
// Own the photo surface from the first finger. A Pressable must not steal the pinch.
|
|
45
|
+
onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true,
|
|
46
|
+
onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponderCapture: () => true,
|
|
47
|
+
onPanResponderTerminationRequest: () => false, onShouldBlockNativeResponder: () => true,
|
|
48
|
+
onPanResponderGrant: e => controller.begin(points(e)),
|
|
49
|
+
onPanResponderStart: e => { const current = points(e); if (current.length > 1)
|
|
50
|
+
controller.begin(current); },
|
|
51
|
+
onPanResponderMove: e => controller.move(points(e)),
|
|
52
|
+
onPanResponderEnd: e => { const remaining = points(e); if (remaining.length)
|
|
53
|
+
controller.end(remaining); },
|
|
54
|
+
onPanResponderRelease: e => { if (controller.end(points(e)))
|
|
55
|
+
tap.current?.(); },
|
|
56
|
+
onPanResponderTerminate: () => { controller.end([], true); },
|
|
37
57
|
})).current;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
58
|
+
const webRef = useRef(null);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const element = webRef.current;
|
|
61
|
+
if (Platform.OS !== 'web' || !element)
|
|
62
|
+
return;
|
|
63
|
+
const active = new Map();
|
|
64
|
+
const down = (e) => { if (e.button !== 0)
|
|
65
|
+
return; e.preventDefault(); active.set(e.pointerId, { x: e.clientX, y: e.clientY }); element.setPointerCapture(e.pointerId); controller.begin(photoPoints([...active.values()])); };
|
|
66
|
+
const move = (e) => { if (!active.has(e.pointerId))
|
|
67
|
+
return; e.preventDefault(); active.set(e.pointerId, { x: e.clientX, y: e.clientY }); controller.move(photoPoints([...active.values()])); };
|
|
68
|
+
const end = (e) => { if (!active.delete(e.pointerId))
|
|
69
|
+
return; e.preventDefault(); if (controller.end(photoPoints([...active.values()]), e.type !== 'pointerup'))
|
|
70
|
+
tap.current?.(); if (element.hasPointerCapture(e.pointerId))
|
|
71
|
+
element.releasePointerCapture(e.pointerId); };
|
|
72
|
+
const wheel = (e) => { if (!e.ctrlKey)
|
|
73
|
+
return; e.preventDefault(); controller.zoom(controller.value.zoom * Math.exp(-e.deltaY * .01)); };
|
|
74
|
+
const key = (e) => { if (e.key === 'Enter' || e.key === ' ') {
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
tap.current?.();
|
|
77
|
+
}
|
|
78
|
+
else if (e.key === '+' || e.key === '=' || e.key === '-') {
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
controller.zoom(controller.value.zoom * (e.key === '-' ? .8 : 1.25));
|
|
81
|
+
} };
|
|
82
|
+
const prevent = (e) => e.preventDefault();
|
|
83
|
+
element.addEventListener('pointerdown', down);
|
|
84
|
+
element.addEventListener('pointermove', move);
|
|
85
|
+
element.addEventListener('pointerup', end);
|
|
86
|
+
element.addEventListener('pointercancel', end);
|
|
87
|
+
element.addEventListener('lostpointercapture', end);
|
|
88
|
+
element.addEventListener('wheel', wheel, { passive: false });
|
|
89
|
+
element.addEventListener('keydown', key);
|
|
90
|
+
element.addEventListener('dragstart', prevent);
|
|
91
|
+
return () => { active.clear(); controller.end([], true); element.removeEventListener('pointerdown', down); element.removeEventListener('pointermove', move); element.removeEventListener('pointerup', end); element.removeEventListener('pointercancel', end); element.removeEventListener('lostpointercapture', end); element.removeEventListener('wheel', wheel); element.removeEventListener('keydown', key); element.removeEventListener('dragstart', prevent); };
|
|
92
|
+
}, [controller, uri]);
|
|
93
|
+
useEffect(() => { let active = true; controller.reset(); setError(false); Image.getSize(uri, (width, height) => { if (active) {
|
|
94
|
+
controller.image(width, height);
|
|
95
|
+
setImageSize({ width, height });
|
|
96
|
+
} }, () => { }); return () => { active = false; }; }, [uri, controller]);
|
|
97
|
+
useEffect(() => { controller.reset(); }, [expanded, controller]);
|
|
98
|
+
useEffect(() => { controller.layout(stageWidth, stageHeight); }, [controller, stageWidth, stageHeight]);
|
|
99
|
+
return _jsxs(View, { style: { flex: 1, minHeight: 0, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }, onLayout: e => setViewport(e.nativeEvent.layout), children: [_jsx(View, { testID: "photo-image-stage", style: { width: stageWidth, height: stageHeight, transform: rotated ? [{ rotate: '90deg' }] : [] }, children: _jsx(View, { ref: webRef, ...(Platform.OS === 'web' ? {} : pan.panHandlers), testID: "photo-gesture-surface", accessible: true, accessibilityRole: "button", accessibilityLabel: "Toggle photo controls", onAccessibilityTap: () => tap.current?.(), ...(Platform.OS === 'web' ? { tabIndex: 0 } : {}), style: { flex: 1, overflow: 'hidden', ...(Platform.OS === 'web' ? { touchAction: 'none', userSelect: 'none' } : {}) }, children: _jsx(Image, { testID: "photo-zoom-image", accessibilityLabel: label, source: { uri }, resizeMode: "contain", onLoad: () => mediaReady('image-loaded', uri), onError: () => setError(true), style: { width: '100%', height: '100%', transform: [{ translateX: transform.x }, { translateY: transform.y }, { scale: transform.zoom }] } }) }) }), error && _jsx(Text, { accessibilityRole: "alert", style: { position: 'absolute', top: '50%', alignSelf: 'center', color: '#fff' }, children: "This photo could not be opened." })] });
|
|
41
100
|
}
|
|
42
|
-
export function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview, onDownload, onShare, onForward, onDelete, downloading = false }) {
|
|
43
|
-
const [
|
|
44
|
-
|
|
45
|
-
|
|
101
|
+
export function MediaViewer({ title, subtitle, kind, uri, fileName, byteSize, children, loading, error, onClose, onFileAction, reviewAction, onReview, onDownload, onShare, onForward, onDelete, downloading = false, expanded, onExpandedChange }) {
|
|
102
|
+
const [localExpanded, setLocalExpanded] = useState(false);
|
|
103
|
+
const photoExpanded = kind === 'image' && (expanded ?? localExpanded), controls = !photoExpanded;
|
|
104
|
+
const setExpanded = (value) => { setLocalExpanded(value); onExpandedChange?.(value); };
|
|
105
|
+
useEffect(() => { setLocalExpanded(false); }, [uri]);
|
|
106
|
+
const visual = kind === 'image' || kind === 'video';
|
|
107
|
+
return _jsxs(View, { testID: "messenger-media-viewer", accessibilityViewIsModal: true, style: { flex: 1, minHeight: 0, backgroundColor: controls ? 'rgba(0,0,0,0.94)' : '#000000' }, children: [_jsxs(View, { style: { flex: 1, minHeight: 0, justifyContent: 'center', paddingHorizontal: visual && controls ? 12 : 0 }, children: [controls && _jsxs(View, { testID: "media-action-toolbar", style: { position: 'absolute', top: 12, left: 12, right: 12, zIndex: 3, minHeight: 52, borderRadius: 26, borderWidth: 1, borderColor: 'rgba(255,255,255,0.12)', backgroundColor: 'rgba(18,18,18,0.92)', paddingLeft: 8, paddingRight: 4, flexDirection: 'row', alignItems: 'center', gap: 2 }, children: [[[onDownload, 'Download', 'download-outline', downloading, false], [onDelete, 'Delete', 'trash-outline', false, true], [onForward, 'Forward', 'arrow-redo-outline', false, false], [onShare, 'Share', 'share-social-outline', false, false]].filter(([action, label]) => !!action || (visual && !reviewAction && label !== 'Download')).map(([action, label, icon, busy, destructive]) => _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: `${label} media`, accessibilityState: { busy }, disabled: !action || busy || downloading, onPress: action, style: { opacity: !action ? .5 : 1, flex: 1, minWidth: 44, minHeight: 44, borderRadius: 22, alignItems: 'center', justifyContent: 'center', gap: 1, paddingHorizontal: 2 }, children: [busy ? _jsx(ActivityIndicator, { size: "small", color: "#e8843a" }) : _jsx(MessageIcon, { name: icon, size: 20, color: !action ? '#A3A3A3' : destructive ? '#EF4444' : '#F7F7F7' }), _jsx(Text, { numberOfLines: 1, style: { color: !action ? '#A3A3A3' : destructive ? '#EF4444' : '#F7F7F7', fontSize: 10, fontWeight: '800' }, children: label })] }, label)), _jsx(View, { style: { flex: (!visual || !!reviewAction) && !onDownload && !onDelete && !onForward && !onShare ? 1 : 0, minWidth: 0 }, children: (!visual || !!reviewAction) && !onDownload && !onDelete && !onForward && !onShare && _jsx(Text, { numberOfLines: 1, style: { color: '#fff', fontSize: 14, paddingLeft: 8 }, children: title }) }), _jsx(Pressable, { accessibilityRole: "button", accessibilityLabel: "Close media", onPress: onClose, style: { width: 44, height: 44, borderRadius: 22, backgroundColor: 'rgba(255,255,255,0.08)', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "close", size: 24, color: "#fff" }) })] }), loading ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 16 }, children: [_jsx(ActivityIndicator, { color: "#fff" }), _jsx(Text, { style: { color: '#c1c7ce' }, children: "Opening\u2026" })] }) : kind === 'image' && uri ? _jsx(View, { style: { height: controls ? '82%' : '100%', width: '100%' }, children: _jsx(ZoomablePhoto, { uri: uri, label: fileName, expanded: photoExpanded, onTap: () => setExpanded(!photoExpanded) }) }) : kind === 'document' ? _jsxs(View, { style: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32, gap: 20 }, children: [_jsx(View, { style: { width: 88, height: 106, borderRadius: 16, backgroundColor: '#ffffff12', alignItems: 'center', justifyContent: 'center' }, children: _jsx(MessageIcon, { name: "document", color: "#fff", size: 40 }) }), _jsx(Text, { style: { color: '#fff', fontSize: 19, fontWeight: '600', textAlign: 'center' }, children: fileName }), _jsxs(Text, { style: { color: '#9ba3ad', fontSize: 13 }, children: [fileName?.split('.').pop()?.toUpperCase(), " \u00B7 ", fileSize(byteSize)] }), onFileAction && _jsx(Pressable, { accessibilityRole: "button", onPress: onFileAction, style: { borderRadius: 24, paddingHorizontal: 28, paddingVertical: 14, backgroundColor: '#fff' }, children: _jsx(Text, { style: { color: '#15191e', fontWeight: '600' }, children: "Open file" }) })] }) : kind === 'video' ? _jsx(View, { testID: "media-video-stage", style: { height: '72%', width: '100%' }, children: children }) : children] }), !!error && _jsx(Text, { accessibilityRole: "alert", style: { color: '#fff', padding: 16 }, children: error }), controls && reviewAction && onReview && _jsx(View, { style: { padding: 16, borderTopWidth: 1, borderTopColor: '#ffffff15' }, children: _jsx(Pressable, { accessibilityRole: "button", onPress: onReview, style: { minHeight: 48, borderRadius: 24, backgroundColor: '#fff', alignItems: 'center', justifyContent: 'center' }, children: _jsx(Text, { style: { fontWeight: '600', color: '#121619' }, children: reviewAction }) }) })] });
|
|
46
108
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React, { type ReactNode } from 'react';
|
|
2
|
+
import { MessageIcon } from './icons.js';
|
|
3
|
+
export type MessageMenuPosition = {
|
|
4
|
+
x: number;
|
|
5
|
+
y: number;
|
|
6
|
+
above: boolean;
|
|
7
|
+
available: number;
|
|
8
|
+
};
|
|
9
|
+
/** Anchor outside the selected bubble, never at its text's center. */
|
|
10
|
+
export declare function messageMenuPosition(x: number, y: number, width: number, height: number, screenWidth: number, screenHeight: number, mine: boolean): MessageMenuPosition;
|
|
11
|
+
export declare function MessageMenuRow({ icon, label, onPress, disabled, danger, divider }: {
|
|
12
|
+
icon: React.ComponentProps<typeof MessageIcon>['name'];
|
|
13
|
+
label: string;
|
|
14
|
+
onPress: () => void;
|
|
15
|
+
disabled?: boolean;
|
|
16
|
+
danger?: boolean;
|
|
17
|
+
divider?: boolean;
|
|
18
|
+
}): React.JSX.Element;
|
|
19
|
+
export declare function MessageMenu({ visible, position, screenHeight, mine, reactions, children, onClose, onDismiss }: {
|
|
20
|
+
visible: boolean;
|
|
21
|
+
position: MessageMenuPosition;
|
|
22
|
+
screenHeight: number;
|
|
23
|
+
mine: boolean;
|
|
24
|
+
reactions: ReactNode;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
onClose: () => void;
|
|
27
|
+
onDismiss: () => void;
|
|
28
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { messagingPopupBackdrop } from './popup.js';
|
|
3
|
+
import { useEffect } from 'react';
|
|
4
|
+
import { Modal, Platform, Pressable, ScrollView, Text, View } from 'react-native';
|
|
5
|
+
import { MessageIcon } from './icons.js';
|
|
6
|
+
/** Anchor outside the selected bubble, never at its text's center. */
|
|
7
|
+
export function messageMenuPosition(x, y, width, height, screenWidth, screenHeight, mine) {
|
|
8
|
+
const top = Math.max(12, Math.min(y, screenHeight - 12)), bottom = Math.max(12, Math.min(y + height, screenHeight - 12));
|
|
9
|
+
const above = top - 24 > screenHeight - bottom - 24;
|
|
10
|
+
const menuWidth = Math.min(300, screenWidth - 24);
|
|
11
|
+
return { x: Math.max(12, Math.min(mine ? x + width - menuWidth : x, screenWidth - menuWidth - 12)), y: above ? top : bottom, above, available: Math.max(44, (above ? top : screenHeight - bottom) - 24) };
|
|
12
|
+
}
|
|
13
|
+
export function MessageMenuRow({ icon, label, onPress, disabled, danger, divider }) {
|
|
14
|
+
return _jsxs(_Fragment, { children: [divider && _jsx(View, { style: { height: 1, marginHorizontal: 12, marginVertical: 4, backgroundColor: '#ffffff22' } }), _jsxs(Pressable, { accessibilityRole: "button", accessibilityLabel: label, disabled: disabled, onPress: onPress, style: ({ pressed }) => ({ minHeight: 43, paddingHorizontal: 12, flexDirection: 'row', alignItems: 'center', gap: 8, opacity: disabled ? .5 : 1, backgroundColor: pressed ? '#ffffff12' : 'transparent' }), children: [_jsx(MessageIcon, { name: icon, color: danger ? '#F87171' : '#f5f5f5', size: 22 }), _jsx(Text, { style: { flex: 1, color: danger ? '#F87171' : '#f5f5f5', fontSize: 15, fontWeight: '700' }, children: label })] })] });
|
|
15
|
+
}
|
|
16
|
+
export function MessageMenu({ visible, position, screenHeight, mine, reactions, children, onClose, onDismiss }) {
|
|
17
|
+
useEffect(() => { if (!visible || Platform.OS !== 'web')
|
|
18
|
+
return; const key = (e) => { if (e.key === 'Escape') {
|
|
19
|
+
e.stopPropagation();
|
|
20
|
+
onClose();
|
|
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] })] }) });
|
|
24
|
+
}
|
package/dist/message-order.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import type { MessagingMessage } from '@robono/messaging';
|
|
2
|
+
/** Only adjacent parts of the same send share a rail. Never join across a
|
|
3
|
+
* missing part, another sender/conversation, or a date separator. */
|
|
4
|
+
export declare function continuesAttachmentSend(before: MessagingMessage | undefined, after: MessagingMessage | undefined): boolean;
|
|
2
5
|
/** Upload completion order must not reorder the attachments selected together.
|
|
3
6
|
* Transport state remains in server sequence order for reconnect cursors. */
|
|
4
7
|
export declare function orderChatMessages<T extends MessagingMessage>(messages: readonly T[]): T[];
|
package/dist/message-order.js
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/** Only adjacent parts of the same send share a rail. Never join across a
|
|
2
|
+
* missing part, another sender/conversation, or a date separator. */
|
|
3
|
+
export function continuesAttachmentSend(before, after) {
|
|
4
|
+
if (!before || !after || before.deleted || after.deleted)
|
|
5
|
+
return false;
|
|
6
|
+
const a = before.attachmentBatch, b = after.attachmentBatch;
|
|
7
|
+
return !!a && !!b && a.id === b.id && a.count === b.count && a.index + 1 === b.index
|
|
8
|
+
&& before.senderId === after.senderId && before.conversationId === after.conversationId
|
|
9
|
+
&& new Date(before.createdAt).toDateString() === new Date(after.createdAt).toDateString();
|
|
10
|
+
}
|
|
1
11
|
/** Upload completion order must not reorder the attachments selected together.
|
|
2
12
|
* Transport state remains in server sequence order for reconnect cursors. */
|
|
3
13
|
export function orderChatMessages(messages) {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type VideoViewProps, type VideoPlayer } from 'expo-video';
|
|
3
|
+
/** Shared by Robono Messenger and the SDK. A decoded frame includes the camera's
|
|
4
|
+
* rotation; the encoded track's natural width/height does not necessarily do so. */
|
|
5
|
+
export declare function MessengerVideoView({ player, posterUri, ...props }: Omit<VideoViewProps, 'player' | 'contentFit' | 'fullscreenOptions'> & {
|
|
6
|
+
player: VideoPlayer;
|
|
7
|
+
posterUri?: string | null;
|
|
8
|
+
}): React.JSX.Element;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Image } from 'react-native';
|
|
4
|
+
import { VideoView } from 'expo-video';
|
|
5
|
+
/** Shared by Robono Messenger and the SDK. A decoded frame includes the camera's
|
|
6
|
+
* rotation; the encoded track's natural width/height does not necessarily do so. */
|
|
7
|
+
export function MessengerVideoView({ player, posterUri, ...props }) {
|
|
8
|
+
const [orientation, setOrientation] = useState('default');
|
|
9
|
+
useEffect(() => {
|
|
10
|
+
let alive = true, pending = false, resolved = false;
|
|
11
|
+
setOrientation('default');
|
|
12
|
+
const accept = (width, height) => { if (!alive || width <= 0 || height <= 0)
|
|
13
|
+
return; resolved = true; setOrientation(width > height ? 'landscape' : 'portrait'); };
|
|
14
|
+
const decode = async () => {
|
|
15
|
+
if (!alive || pending || resolved || player.status !== 'readyToPlay' || typeof player.generateThumbnailsAsync !== 'function')
|
|
16
|
+
return;
|
|
17
|
+
pending = true;
|
|
18
|
+
try {
|
|
19
|
+
const frames = await player.generateThumbnailsAsync(0, { maxWidth: 96, maxHeight: 96 });
|
|
20
|
+
try {
|
|
21
|
+
const frame = frames[0];
|
|
22
|
+
if (frame)
|
|
23
|
+
accept(frame.width, frame.height);
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
for (const frame of frames)
|
|
27
|
+
frame.release();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch { /* Keep the platform default if this source cannot provide a frame. */ }
|
|
31
|
+
finally {
|
|
32
|
+
pending = false;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
if (posterUri)
|
|
36
|
+
Image.getSize(posterUri, accept, () => { void decode(); });
|
|
37
|
+
else
|
|
38
|
+
void decode();
|
|
39
|
+
const status = player.addListener('statusChange', () => { if (!posterUri)
|
|
40
|
+
void decode(); });
|
|
41
|
+
const source = player.addListener('sourceLoad', () => { if (!posterUri)
|
|
42
|
+
void decode(); });
|
|
43
|
+
return () => { alive = false; status.remove(); source.remove(); };
|
|
44
|
+
}, [player, posterUri]);
|
|
45
|
+
return _jsx(VideoView, { nativeControls: true, ...props, player: player, contentFit: "contain", fullscreenOptions: { enable: true, orientation } });
|
|
46
|
+
}
|
package/dist/native-outbox.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { MessageContent, MessageMedia, MessagingOutbox, SavedOutgoingMessage } from '@robono/messaging';
|
|
1
|
+
import type { TranslationPreference, MessageContent, MessageMedia, MessagingOutbox, SavedOutgoingMessage } from '@robono/messaging';
|
|
2
2
|
import type { LocalMessagingMedia } from './media-controller.js';
|
|
3
3
|
export interface NativeDraftFiles {
|
|
4
4
|
read(scope: string, conversation: string): Promise<NativeDraftState>;
|
|
@@ -9,6 +9,8 @@ export interface NativeDraftFiles {
|
|
|
9
9
|
clear(scope: string, conversation?: string): Promise<void>;
|
|
10
10
|
}
|
|
11
11
|
export interface NativeDraftState {
|
|
12
|
+
translationPreference?: TranslationPreference;
|
|
13
|
+
quickMessage?: string;
|
|
12
14
|
draft: string;
|
|
13
15
|
messages: Array<SavedOutgoingMessage & {
|
|
14
16
|
fileId?: string;
|
|
@@ -35,13 +37,17 @@ export declare class NativeMessagingOutbox implements MessagingOutbox {
|
|
|
35
37
|
needsPreparation?: boolean;
|
|
36
38
|
}[];
|
|
37
39
|
attachments: {
|
|
38
|
-
kind: "text" | "
|
|
40
|
+
kind: "text" | "video" | "image" | "document" | "poll" | "voice";
|
|
39
41
|
media: MessageMedia;
|
|
40
42
|
}[];
|
|
43
|
+
quickMessage?: string | undefined;
|
|
44
|
+
translationPreference?: TranslationPreference | undefined;
|
|
41
45
|
}>;
|
|
42
46
|
put(cid: string, message: SavedOutgoingMessage): Promise<void>;
|
|
43
47
|
remove(cid: string, id: string): Promise<void>;
|
|
44
48
|
draft(cid: string, text: string): Promise<void>;
|
|
49
|
+
translationPreference(cid: string, value: TranslationPreference): Promise<void>;
|
|
50
|
+
quickMessage(cid: string, emoji: string): Promise<void>;
|
|
45
51
|
clear(cid?: string): Promise<void>;
|
|
46
52
|
saveFile(cid: string, media: MessageMedia, file: LocalMessagingMedia, kind: MessageContent['kind']): Promise<void>;
|
|
47
53
|
file(cid: string, id: string): Promise<LocalMessagingMedia | undefined>;
|
package/dist/native-outbox.js
CHANGED
|
@@ -12,7 +12,7 @@ export class NativeMessagingOutbox {
|
|
|
12
12
|
}
|
|
13
13
|
serial(work) { const task = (queues.get(this.scope) ?? Promise.resolve()).catch(() => { }).then(work); queues.set(this.scope, task); void task.finally(() => { if (queues.get(this.scope) === task)
|
|
14
14
|
queues.delete(this.scope); }).catch(() => { }); return task; }
|
|
15
|
-
load(cid) { return this.serial(async () => { const saved = await this.files.read(this.scope, cid), used = new Set(saved.messages.map(m => m.fileId)); return { draft: saved.draft, messages: saved.messages.map(({ fileId, ...message }) => message), attachments: Object.entries(saved.files).filter(([id]) => !used.has(id)).map(([, f]) => ({ kind: f.kind, media: f.media })) }; }); }
|
|
15
|
+
load(cid) { return this.serial(async () => { const saved = await this.files.read(this.scope, cid), used = new Set(saved.messages.map(m => m.fileId)); return { ...(saved.translationPreference ? { translationPreference: saved.translationPreference } : {}), ...(saved.quickMessage ? { quickMessage: saved.quickMessage } : {}), draft: saved.draft, messages: saved.messages.map(({ fileId, ...message }) => message), attachments: Object.entries(saved.files).filter(([id]) => !used.has(id)).map(([, f]) => ({ kind: f.kind, media: f.media })) }; }); }
|
|
16
16
|
put(cid, message) { return this.serial(async () => { const state = await this.files.read(this.scope, cid), previous = state.messages.find(m => m.clientMessageId === message.clientMessageId); state.messages = state.messages.filter(m => m.clientMessageId !== message.clientMessageId); state.messages.push({ ...message, fileId: message.needsPreparation ? message.content.media?.assetId : previous?.fileId }); await this.files.write(this.scope, cid, state); }); }
|
|
17
17
|
remove(cid, id) { return this.serial(async () => { const state = await this.files.read(this.scope, cid), fileId = state.messages.find(m => m.clientMessageId === id)?.fileId; state.messages = state.messages.filter(m => m.clientMessageId !== id); if (fileId && !state.messages.some(m => m.fileId === fileId)) {
|
|
18
18
|
delete state.files[fileId];
|
|
@@ -22,6 +22,8 @@ export class NativeMessagingOutbox {
|
|
|
22
22
|
else
|
|
23
23
|
await this.files.write(this.scope, cid, state); }); }
|
|
24
24
|
draft(cid, text) { return this.serial(async () => { const state = await this.files.read(this.scope, cid); state.draft = text.slice(0, 10000); await this.files.write(this.scope, cid, state); }); }
|
|
25
|
+
translationPreference(cid, value) { return this.serial(async () => { const state = await this.files.read(this.scope, cid); state.translationPreference = value; await this.files.write(this.scope, cid, state); }); }
|
|
26
|
+
quickMessage(cid, emoji) { return this.serial(async () => { const state = await this.files.read(this.scope, cid); state.quickMessage = emoji; await this.files.write(this.scope, cid, state); }); }
|
|
25
27
|
clear(cid) { this.generation++; epochs.set(this.scope, (epochs.get(this.scope) ?? 0) + 1); return this.serial(() => this.files.clear(this.scope, cid)); }
|
|
26
28
|
saveFile(cid, media, file, kind) {
|
|
27
29
|
const generation = this.generation, epoch = epochs.get(this.scope) ?? 0;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Messenger photo transforms, shared by native touches and browser pointers. */
|
|
2
|
+
export type PhotoPoint = {
|
|
3
|
+
x: number;
|
|
4
|
+
y: number;
|
|
5
|
+
};
|
|
6
|
+
export type PhotoTransform = {
|
|
7
|
+
zoom: number;
|
|
8
|
+
x: number;
|
|
9
|
+
y: number;
|
|
10
|
+
};
|
|
11
|
+
export declare function createPhotoGesture(changed: (value: PhotoTransform) => void): {
|
|
12
|
+
readonly value: PhotoTransform;
|
|
13
|
+
layout(w: number, h: number): void;
|
|
14
|
+
image(w: number, h: number): void;
|
|
15
|
+
reset(): void;
|
|
16
|
+
zoom(zoom: number): void;
|
|
17
|
+
begin(points: PhotoPoint[]): void;
|
|
18
|
+
move(points: PhotoPoint[]): void;
|
|
19
|
+
end(points: PhotoPoint[], cancelled?: boolean): boolean;
|
|
20
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
const clamp = (n, min, max) => Math.max(min, Math.min(max, n));
|
|
2
|
+
const center = (p) => ({ x: p.reduce((n, p) => n + p.x, 0) / p.length, y: p.reduce((n, p) => n + p.y, 0) / p.length });
|
|
3
|
+
const distance = (p) => p.length > 1 ? Math.hypot(p[0].x - p[1].x, p[0].y - p[1].y) : 0;
|
|
4
|
+
export function createPhotoGesture(changed) {
|
|
5
|
+
let value = { zoom: 1, x: 0, y: 0 }, width = 1, height = 1, imageWidth = 1, imageHeight = 1;
|
|
6
|
+
let start = null;
|
|
7
|
+
let tap = false, started = 0;
|
|
8
|
+
function apply(zoom, x, y) {
|
|
9
|
+
zoom = clamp(zoom, 1, 5);
|
|
10
|
+
const fit = Math.min(width / imageWidth, height / imageHeight);
|
|
11
|
+
const maxX = Math.max(0, (imageWidth * fit * zoom - width) / 2), maxY = Math.max(0, (imageHeight * fit * zoom - height) / 2);
|
|
12
|
+
value = { zoom, x: clamp(x, -maxX, maxX), y: clamp(y, -maxY, maxY) };
|
|
13
|
+
changed(value);
|
|
14
|
+
}
|
|
15
|
+
function rebase(points) { start = points.length ? { value: { ...value }, center: center(points), distance: distance(points), count: points.length } : null; }
|
|
16
|
+
return {
|
|
17
|
+
get value() { return value; },
|
|
18
|
+
layout(w, h) { width = Math.max(1, w); height = Math.max(1, h); apply(value.zoom, value.x, value.y); },
|
|
19
|
+
image(w, h) { imageWidth = Math.max(1, w); imageHeight = Math.max(1, h); apply(value.zoom, value.x, value.y); },
|
|
20
|
+
reset() { tap = false; start = null; apply(1, 0, 0); },
|
|
21
|
+
zoom(zoom) { tap = false; apply(zoom, 0, 0); },
|
|
22
|
+
begin(points) { if (!start) {
|
|
23
|
+
tap = points.length === 1;
|
|
24
|
+
started = Date.now();
|
|
25
|
+
}
|
|
26
|
+
else
|
|
27
|
+
tap = false; rebase(points); },
|
|
28
|
+
move(points) {
|
|
29
|
+
if (!points.length)
|
|
30
|
+
return;
|
|
31
|
+
if (!start || start.count !== points.length) {
|
|
32
|
+
tap = false;
|
|
33
|
+
rebase(points);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const at = center(points), dx = at.x - start.center.x, dy = at.y - start.center.y;
|
|
37
|
+
if (points.length > 1 || Math.abs(dx) > 8 || Math.abs(dy) > 8)
|
|
38
|
+
tap = false;
|
|
39
|
+
const zoom = points.length > 1 && start.distance > 0 ? start.value.zoom * distance(points) / start.distance : start.value.zoom;
|
|
40
|
+
apply(zoom, start.value.x + dx, start.value.y + dy);
|
|
41
|
+
},
|
|
42
|
+
end(points, cancelled = false) {
|
|
43
|
+
const tapped = !cancelled && !points.length && tap && Date.now() - started < 260;
|
|
44
|
+
if (points.length)
|
|
45
|
+
tap = false;
|
|
46
|
+
rebase(points);
|
|
47
|
+
if (!points.length)
|
|
48
|
+
tap = false;
|
|
49
|
+
return tapped;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
package/dist/polls.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { type MessagingAppearance, type MessagingMessage, type MessagePoll } from '@robono/messaging';
|
|
3
3
|
/** Optional reusable poll content. Votes are submitted as a complete selection, not a toggle request. */
|
|
4
|
-
export declare function PollMessage({ message, userId, ink, accent, enabled, onVote, onClose, resolveVoterName, background, foreground, renderDetails, detailsOpen, onDetailsOpenChange }: {
|
|
4
|
+
export declare function PollMessage({ message, userId, ink, accent, enabled, onVote, onClose, resolveVoterName, background, foreground, renderDetails, detailsOpen, onDetailsOpenChange, onLongPress }: {
|
|
5
|
+
onLongPress?: () => void;
|
|
5
6
|
detailsOpen?: boolean;
|
|
6
7
|
onDetailsOpenChange?: (open: boolean) => void;
|
|
7
8
|
renderDetails?: (content: React.ReactNode, onClose: () => void) => React.ReactNode;
|