@repzo/widget-react-native 1.2.1 → 1.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 +69 -0
- package/README.md +40 -3
- package/android/build.gradle +22 -0
- package/android/src/main/java/expo/modules/repzowidget/RepzoVoicePlayer.kt +222 -0
- package/android/src/main/java/expo/modules/repzowidget/RepzoWidgetAudioModule.kt +68 -0
- package/expo-module.config.json +9 -0
- package/ios/RepzoVoicePlayer.swift +175 -0
- package/ios/RepzoWidgetAudio.podspec +26 -0
- package/ios/RepzoWidgetAudioModule.swift +65 -0
- package/lib/commonjs/api/client.js +47 -2
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/audio/native-player.js +97 -0
- package/lib/commonjs/audio/native-player.js.map +1 -0
- package/lib/commonjs/audio/voice.js +146 -0
- package/lib/commonjs/audio/voice.js.map +1 -0
- package/lib/commonjs/i18n.js +6 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/locales.js +4 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/use-widget-state.js +31 -0
- package/lib/commonjs/state/use-widget-state.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +44 -1
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +7 -3
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/FileCard.js +134 -0
- package/lib/commonjs/ui/components/FileCard.js.map +1 -0
- package/lib/commonjs/ui/components/ImageViewer.js +17 -35
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
- package/lib/commonjs/ui/components/Launcher.js +15 -1
- package/lib/commonjs/ui/components/Launcher.js.map +1 -1
- package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
- package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
- package/lib/commonjs/ui/components/VoiceNote.js +353 -0
- package/lib/commonjs/ui/components/VoiceNote.js.map +1 -0
- package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
- package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
- package/lib/commonjs/ui/components/icons.js +57 -0
- package/lib/commonjs/ui/components/icons.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.js +39 -18
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +5 -16
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/api/client.js +48 -2
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/audio/native-player.js +93 -0
- package/lib/module/audio/native-player.js.map +1 -0
- package/lib/module/audio/voice.js +136 -0
- package/lib/module/audio/voice.js.map +1 -0
- package/lib/module/i18n.js +6 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/locales.js +4 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/use-widget-state.js +30 -1
- package/lib/module/state/use-widget-state.js.map +1 -1
- package/lib/module/state/widget-store.js +44 -1
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +7 -3
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/FileCard.js +130 -0
- package/lib/module/ui/components/FileCard.js.map +1 -0
- package/lib/module/ui/components/ImageViewer.js +18 -36
- package/lib/module/ui/components/ImageViewer.js.map +1 -1
- package/lib/module/ui/components/Launcher.js +16 -2
- package/lib/module/ui/components/Launcher.js.map +1 -1
- package/lib/module/ui/components/MessagesSkeleton.js +124 -0
- package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
- package/lib/module/ui/components/VoiceNote.js +349 -0
- package/lib/module/ui/components/VoiceNote.js.map +1 -0
- package/lib/module/ui/components/ZoomableImage.js +291 -0
- package/lib/module/ui/components/ZoomableImage.js.map +1 -0
- package/lib/module/ui/components/icons.js +55 -0
- package/lib/module/ui/components/icons.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.js +39 -18
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +5 -16
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +12 -0
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
- package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
- package/lib/typescript/commonjs/audio/voice.d.ts +42 -0
- package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -0
- package/lib/typescript/commonjs/i18n.d.ts +4 -0
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/use-widget-state.d.ts +8 -0
- package/lib/typescript/commonjs/state/use-widget-state.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +13 -0
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/FileCard.d.ts +21 -0
- package/lib/typescript/commonjs/ui/components/FileCard.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +2 -2
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/Launcher.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
- package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +32 -0
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts +4 -0
- package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +4 -16
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +4 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +12 -0
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/audio/native-player.d.ts +36 -0
- package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
- package/lib/typescript/module/audio/voice.d.ts +42 -0
- package/lib/typescript/module/audio/voice.d.ts.map +1 -0
- package/lib/typescript/module/i18n.d.ts +4 -0
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/use-widget-state.d.ts +8 -0
- package/lib/typescript/module/state/use-widget-state.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +13 -0
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/FileCard.d.ts +21 -0
- package/lib/typescript/module/ui/components/FileCard.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/ImageViewer.d.ts +2 -2
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/Launcher.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
- package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/VoiceNote.d.ts +32 -0
- package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
- package/lib/typescript/module/ui/components/icons.d.ts +4 -0
- package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +4 -16
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +4 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/package.json +14 -3
- package/react-native.config.js +12 -0
- package/src/api/client.ts +56 -2
- package/src/audio/native-player.ts +106 -0
- package/src/audio/voice.ts +127 -0
- package/src/i18n.ts +6 -0
- package/src/locales.ts +4 -0
- package/src/state/use-widget-state.ts +30 -1
- package/src/state/widget-store.ts +45 -1
- package/src/ui/WidgetProvider.tsx +7 -3
- package/src/ui/components/FileCard.tsx +111 -0
- package/src/ui/components/ImageViewer.tsx +11 -30
- package/src/ui/components/Launcher.tsx +7 -2
- package/src/ui/components/MessagesSkeleton.tsx +92 -0
- package/src/ui/components/VoiceNote.tsx +355 -0
- package/src/ui/components/ZoomableImage.tsx +250 -0
- package/src/ui/components/icons.tsx +33 -0
- package/src/ui/screens/chat/ChatView.styles.ts +5 -16
- package/src/ui/screens/chat/ChatView.tsx +43 -16
- package/src/ui/screens/chat/ChatView.types.ts +4 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Placeholder bubbles while a conversation's messages load: the shape of a
|
|
3
|
+
* thread (team on one side, visitor on the other) that gently pulses, so the
|
|
4
|
+
* screen reads as "messages are coming" instead of a line of text.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useEffect, useState } from 'react';
|
|
8
|
+
import { Animated, Easing, StyleSheet, View } from 'react-native';
|
|
9
|
+
|
|
10
|
+
import { useReduceMotion } from '../hooks/use-reduce-motion';
|
|
11
|
+
import { palette, radius, spacing, withAlpha } from '../styles/theme';
|
|
12
|
+
import { isRTL } from '../utils/rtl';
|
|
13
|
+
|
|
14
|
+
/** Who "sent" each placeholder, its width, and how many lines it holds. */
|
|
15
|
+
const ROWS: { visitor: boolean; width: `${number}%`; lines: number }[] = [
|
|
16
|
+
{ visitor: false, width: '62%', lines: 2 },
|
|
17
|
+
{ visitor: false, width: '40%', lines: 1 },
|
|
18
|
+
{ visitor: true, width: '48%', lines: 1 },
|
|
19
|
+
{ visitor: false, width: '70%', lines: 3 },
|
|
20
|
+
{ visitor: true, width: '56%', lines: 2 },
|
|
21
|
+
{ visitor: false, width: '34%', lines: 1 },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const LINE_HEIGHT = 20;
|
|
25
|
+
const PULSE_MS = 900;
|
|
26
|
+
|
|
27
|
+
export function MessagesSkeleton({ color, label }: { color: string; label: string }) {
|
|
28
|
+
const reduceMotion = useReduceMotion();
|
|
29
|
+
const [opacity] = useState(() => new Animated.Value(1));
|
|
30
|
+
const rtl = isRTL();
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
// `null` (not known yet) holds still too, so the bubbles never jump.
|
|
34
|
+
if (reduceMotion !== false) return undefined;
|
|
35
|
+
const pulse = Animated.loop(
|
|
36
|
+
Animated.sequence([
|
|
37
|
+
Animated.timing(opacity, {
|
|
38
|
+
toValue: 0.45,
|
|
39
|
+
duration: PULSE_MS,
|
|
40
|
+
easing: Easing.inOut(Easing.ease),
|
|
41
|
+
useNativeDriver: true,
|
|
42
|
+
}),
|
|
43
|
+
Animated.timing(opacity, {
|
|
44
|
+
toValue: 1,
|
|
45
|
+
duration: PULSE_MS,
|
|
46
|
+
easing: Easing.inOut(Easing.ease),
|
|
47
|
+
useNativeDriver: true,
|
|
48
|
+
}),
|
|
49
|
+
])
|
|
50
|
+
);
|
|
51
|
+
pulse.start();
|
|
52
|
+
return () => pulse.stop();
|
|
53
|
+
}, [opacity, reduceMotion]);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Animated.View
|
|
57
|
+
style={[styles.root, { opacity }]}
|
|
58
|
+
accessible
|
|
59
|
+
accessibilityRole="progressbar"
|
|
60
|
+
accessibilityLabel={label}
|
|
61
|
+
accessibilityState={{ busy: true }}>
|
|
62
|
+
{ROWS.map((row, i) => (
|
|
63
|
+
<View key={i} style={row.visitor ? styles.rowVisitor : styles.rowAgent}>
|
|
64
|
+
<View
|
|
65
|
+
style={[
|
|
66
|
+
styles.bubble,
|
|
67
|
+
row.visitor
|
|
68
|
+
? [styles.bubbleVisitor, rtl && styles.bubbleVisitorRTL, { backgroundColor: withAlpha(color, 0.16) }]
|
|
69
|
+
: [styles.bubbleAgent, rtl && styles.bubbleAgentRTL],
|
|
70
|
+
{ width: row.width, height: row.lines * LINE_HEIGHT + spacing.sm * 2 + 4 },
|
|
71
|
+
]}
|
|
72
|
+
/>
|
|
73
|
+
</View>
|
|
74
|
+
))}
|
|
75
|
+
</Animated.View>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The same corners as real bubbles, so the thread doesn't reshape when it loads.
|
|
80
|
+
const BUBBLE_RADIUS = radius.lg + 4;
|
|
81
|
+
const TAIL_RADIUS = 4;
|
|
82
|
+
|
|
83
|
+
const styles = StyleSheet.create({
|
|
84
|
+
root: { gap: spacing.sm },
|
|
85
|
+
rowAgent: { alignItems: 'flex-start' },
|
|
86
|
+
rowVisitor: { alignItems: 'flex-end' },
|
|
87
|
+
bubble: { borderRadius: BUBBLE_RADIUS },
|
|
88
|
+
bubbleAgent: { backgroundColor: palette.surfaceAlt, borderBottomLeftRadius: TAIL_RADIUS },
|
|
89
|
+
bubbleAgentRTL: { borderBottomLeftRadius: BUBBLE_RADIUS, borderBottomRightRadius: TAIL_RADIUS },
|
|
90
|
+
bubbleVisitor: { borderBottomRightRadius: TAIL_RADIUS },
|
|
91
|
+
bubbleVisitorRTL: { borderBottomRightRadius: BUBBLE_RADIUS, borderBottomLeftRadius: TAIL_RADIUS },
|
|
92
|
+
});
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A voice note (or any audio attachment) played inside the chat, laid out the
|
|
3
|
+
* way messaging apps do it: a play button beside a waveform the visitor can
|
|
4
|
+
* tap or drag to jump through the note, the time under it, and a speed chip
|
|
5
|
+
* once the note has been started. Only rendered when the app can play audio
|
|
6
|
+
* (see `src/audio/voice.ts`); otherwise audio stays a file row.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
10
|
+
import {
|
|
11
|
+
ActivityIndicator,
|
|
12
|
+
PanResponder,
|
|
13
|
+
Pressable,
|
|
14
|
+
StyleSheet,
|
|
15
|
+
Text,
|
|
16
|
+
View,
|
|
17
|
+
type GestureResponderHandlers,
|
|
18
|
+
type LayoutChangeEvent,
|
|
19
|
+
} from 'react-native';
|
|
20
|
+
|
|
21
|
+
import { claimVoicePlayback, formatVoiceClock, useVoicePlayer, voiceWaveformLevels } from '../../audio/voice';
|
|
22
|
+
import { PauseIcon, PlayIcon } from './icons';
|
|
23
|
+
import { fontSize, palette, withAlpha } from '../styles/theme';
|
|
24
|
+
|
|
25
|
+
export interface VoiceNoteLabels {
|
|
26
|
+
play: string;
|
|
27
|
+
pause: string;
|
|
28
|
+
speed: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface VoiceNoteProps {
|
|
32
|
+
url: string;
|
|
33
|
+
/** Keeps the made-up waveform the same for this note on every render. */
|
|
34
|
+
seed: string;
|
|
35
|
+
/** The workspace color. */
|
|
36
|
+
color: string;
|
|
37
|
+
/** In a visitor bubble, which is filled with `color`: the player turns white. */
|
|
38
|
+
onBrandBubble: boolean;
|
|
39
|
+
labels: VoiceNoteLabels;
|
|
40
|
+
/** The note can't be played (expired link, unsupported format). */
|
|
41
|
+
onError: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const BAR_COUNT = 32;
|
|
45
|
+
const BAR_WIDTH = 3;
|
|
46
|
+
const BAR_MIN_HEIGHT = 4;
|
|
47
|
+
const BAR_MAX_HEIGHT = 24;
|
|
48
|
+
const BUTTON_SIZE = 36;
|
|
49
|
+
const KNOB_SIZE = 12;
|
|
50
|
+
const SPEEDS = [1, 1.5, 2] as const;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The player is only created once the visitor taps play. The whole thread
|
|
54
|
+
* renders at once, so a player per note would download every note in the
|
|
55
|
+
* conversation as soon as the chat opened.
|
|
56
|
+
*/
|
|
57
|
+
export function VoiceNote(props: VoiceNoteProps) {
|
|
58
|
+
const [started, setStarted] = useState(false);
|
|
59
|
+
if (started) return <ActiveVoiceNote {...props} />;
|
|
60
|
+
return (
|
|
61
|
+
<VoiceNoteLayout
|
|
62
|
+
{...props}
|
|
63
|
+
playing={false}
|
|
64
|
+
loading={false}
|
|
65
|
+
progress={0}
|
|
66
|
+
time={null}
|
|
67
|
+
onToggle={() => setStarted(true)}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A note that has been started: owns its player and plays on mount. */
|
|
73
|
+
function ActiveVoiceNote(props: VoiceNoteProps) {
|
|
74
|
+
const { url, onError } = props;
|
|
75
|
+
const { player, status } = useVoicePlayer(url);
|
|
76
|
+
const [speedIndex, setSpeedIndex] = useState(0);
|
|
77
|
+
const [scrub, setScrub] = useState<number | null>(null);
|
|
78
|
+
const [trackWidth, setTrackWidth] = useState(0);
|
|
79
|
+
// Whether the visitor asked for playback, so a note still loading shows a
|
|
80
|
+
// spinner, and one they paused while it loaded doesn't.
|
|
81
|
+
const [wantsPlay, setWantsPlay] = useState(true);
|
|
82
|
+
// This note's hold on playback: starting it pauses any other note.
|
|
83
|
+
const releasePlayback = useRef<(() => void) | null>(null);
|
|
84
|
+
|
|
85
|
+
const duration = Math.max(Number(status?.duration) || 0, 0);
|
|
86
|
+
const currentTime = Math.max(Number(status?.currentTime) || 0, 0);
|
|
87
|
+
const playing = !!status?.playing;
|
|
88
|
+
const finished = !!status?.didJustFinish || (duration > 0 && currentTime >= duration - 0.1);
|
|
89
|
+
// `isLoaded` / `isBuffering` are compared strictly: a player that
|
|
90
|
+
// doesn't report them never gets a spinner that can't go away.
|
|
91
|
+
const loading = wantsPlay && !playing && (status?.isLoaded === false || status?.isBuffering === true);
|
|
92
|
+
|
|
93
|
+
const pause = () => {
|
|
94
|
+
setWantsPlay(false);
|
|
95
|
+
player.pause();
|
|
96
|
+
};
|
|
97
|
+
const start = () => {
|
|
98
|
+
releasePlayback.current?.();
|
|
99
|
+
releasePlayback.current = claimVoicePlayback(pause);
|
|
100
|
+
setWantsPlay(true);
|
|
101
|
+
player.play();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// The visitor tapped play on the idle note, which created this player.
|
|
105
|
+
// Neither callback changes for the player's lifetime.
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
start();
|
|
108
|
+
return () => releasePlayback.current?.();
|
|
109
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// The error is only on the status update that reports it (the next one
|
|
113
|
+
// clears it), so it is acted on as it arrives.
|
|
114
|
+
const error = status?.error;
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
if (error) onError();
|
|
117
|
+
}, [error, onError]);
|
|
118
|
+
|
|
119
|
+
const togglePlay = async () => {
|
|
120
|
+
if (playing || loading) {
|
|
121
|
+
pause();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (finished) await player.seekTo(0);
|
|
125
|
+
start();
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const cycleSpeed = () => {
|
|
129
|
+
const next = (speedIndex + 1) % SPEEDS.length;
|
|
130
|
+
setSpeedIndex(next);
|
|
131
|
+
try {
|
|
132
|
+
player.setPlaybackRate(SPEEDS[next], 'high');
|
|
133
|
+
} catch {
|
|
134
|
+
// Some versions take no pitch-correction argument.
|
|
135
|
+
try {
|
|
136
|
+
player.setPlaybackRate(SPEEDS[next]);
|
|
137
|
+
} catch {
|
|
138
|
+
// Speed unsupported: the note keeps playing at 1×.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
// The latest values for the (stable) PanResponder below.
|
|
144
|
+
const latest = useRef({ trackWidth, duration, player });
|
|
145
|
+
latest.current = { trackWidth, duration, player };
|
|
146
|
+
const ratioAt = (x: number) => {
|
|
147
|
+
const width = latest.current.trackWidth;
|
|
148
|
+
return width > 0 ? Math.min(1, Math.max(0, x / width)) : 0;
|
|
149
|
+
};
|
|
150
|
+
// Where the finger first touched the track. Later positions are that plus
|
|
151
|
+
// the drag distance: a move event's own `locationX` is measured from
|
|
152
|
+
// whatever view is under the finger, not from the track.
|
|
153
|
+
const startX = useRef(0);
|
|
154
|
+
const pan = useRef(
|
|
155
|
+
PanResponder.create({
|
|
156
|
+
onStartShouldSetPanResponder: () => true,
|
|
157
|
+
onMoveShouldSetPanResponder: () => true,
|
|
158
|
+
onPanResponderGrant: (event) => {
|
|
159
|
+
startX.current = event.nativeEvent.locationX;
|
|
160
|
+
setScrub(ratioAt(startX.current));
|
|
161
|
+
},
|
|
162
|
+
onPanResponderMove: (_, gesture) => setScrub(ratioAt(startX.current + gesture.dx)),
|
|
163
|
+
onPanResponderRelease: (_, gesture) => {
|
|
164
|
+
const { duration: length, player: current } = latest.current;
|
|
165
|
+
if (length > 0) void current.seekTo(ratioAt(startX.current + gesture.dx) * length);
|
|
166
|
+
setScrub(null);
|
|
167
|
+
},
|
|
168
|
+
onPanResponderTerminate: () => setScrub(null),
|
|
169
|
+
})
|
|
170
|
+
).current;
|
|
171
|
+
|
|
172
|
+
const displayTime =
|
|
173
|
+
scrub != null ? scrub * duration : playing ? currentTime : finished ? duration : currentTime || duration;
|
|
174
|
+
// The chip joins once the note was started, so an untouched note shows only its length.
|
|
175
|
+
const showSpeed = speedIndex > 0 || playing || (currentTime > 0 && !finished);
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<VoiceNoteLayout
|
|
179
|
+
{...props}
|
|
180
|
+
playing={playing}
|
|
181
|
+
loading={loading}
|
|
182
|
+
progress={scrub ?? (duration > 0 ? Math.min(1, currentTime / duration) : 0)}
|
|
183
|
+
// Nothing to show until the player knows the note's length.
|
|
184
|
+
time={duration > 0 ? formatVoiceClock(displayTime) : null}
|
|
185
|
+
onToggle={() => void togglePlay()}
|
|
186
|
+
track={{ panHandlers: pan.panHandlers, onWidth: setTrackWidth }}
|
|
187
|
+
speed={showSpeed ? { value: SPEEDS[speedIndex], changed: speedIndex > 0, onPress: cycleSpeed } : null}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function VoiceNoteLayout({
|
|
193
|
+
seed,
|
|
194
|
+
color,
|
|
195
|
+
onBrandBubble,
|
|
196
|
+
labels,
|
|
197
|
+
playing,
|
|
198
|
+
loading,
|
|
199
|
+
progress,
|
|
200
|
+
time,
|
|
201
|
+
onToggle,
|
|
202
|
+
track,
|
|
203
|
+
speed,
|
|
204
|
+
}: VoiceNoteProps & {
|
|
205
|
+
playing: boolean;
|
|
206
|
+
loading: boolean;
|
|
207
|
+
/** 0–1 through the note. */
|
|
208
|
+
progress: number;
|
|
209
|
+
time: string | null;
|
|
210
|
+
onToggle: () => void;
|
|
211
|
+
/** Scrubbing; missing on a note that hasn't been started. */
|
|
212
|
+
track?: { panHandlers: GestureResponderHandlers; onWidth: (width: number) => void };
|
|
213
|
+
speed?: { value: number; changed: boolean; onPress: () => void } | null;
|
|
214
|
+
}) {
|
|
215
|
+
const accent = onBrandBubble ? palette.white : color;
|
|
216
|
+
const onAccent = onBrandBubble ? color : palette.white;
|
|
217
|
+
const trackColor = onBrandBubble ? withAlpha(palette.white, 0.4) : palette.disabled;
|
|
218
|
+
const subtle = onBrandBubble ? withAlpha(palette.white, 0.8) : palette.textMuted;
|
|
219
|
+
const levels = useMemo(() => voiceWaveformLevels(seed, BAR_COUNT), [seed]);
|
|
220
|
+
const [trackWidth, setTrackWidth] = useState(0);
|
|
221
|
+
const onTrackLayout = (event: LayoutChangeEvent) => {
|
|
222
|
+
setTrackWidth(event.nativeEvent.layout.width);
|
|
223
|
+
track?.onWidth(event.nativeEvent.layout.width);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// Bar i's centre sits at this share of the track; the playhead travels the same span.
|
|
227
|
+
const barCentre = (i: number) =>
|
|
228
|
+
trackWidth > BAR_WIDTH
|
|
229
|
+
? (BAR_WIDTH / 2 + (i * (trackWidth - BAR_WIDTH)) / (BAR_COUNT - 1)) / trackWidth
|
|
230
|
+
: i / (BAR_COUNT - 1);
|
|
231
|
+
const knobOffset = trackWidth > 0 ? Math.round(progress * (trackWidth - KNOB_SIZE)) : 0;
|
|
232
|
+
|
|
233
|
+
let icon: ReactNode = <PlayIcon size={14} color={onAccent} />;
|
|
234
|
+
if (loading) icon = <ActivityIndicator size="small" color={onAccent} />;
|
|
235
|
+
else if (playing) icon = <PauseIcon size={14} color={onAccent} />;
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<View style={styles.root}>
|
|
239
|
+
<View style={styles.row}>
|
|
240
|
+
<Pressable
|
|
241
|
+
onPress={onToggle}
|
|
242
|
+
accessibilityRole="button"
|
|
243
|
+
accessibilityLabel={playing || loading ? labels.pause : labels.play}
|
|
244
|
+
accessibilityState={{ busy: loading }}
|
|
245
|
+
hitSlop={4}
|
|
246
|
+
style={({ pressed }) => [
|
|
247
|
+
styles.playButton,
|
|
248
|
+
{ backgroundColor: accent, opacity: pressed ? 0.8 : 1 },
|
|
249
|
+
]}>
|
|
250
|
+
{icon}
|
|
251
|
+
</Pressable>
|
|
252
|
+
|
|
253
|
+
<View
|
|
254
|
+
style={styles.track}
|
|
255
|
+
onLayout={onTrackLayout}
|
|
256
|
+
{...track?.panHandlers}>
|
|
257
|
+
{levels.map((level, i) => (
|
|
258
|
+
<View
|
|
259
|
+
key={i}
|
|
260
|
+
// Touches land on the track itself, so their position is measured from its edge.
|
|
261
|
+
pointerEvents="none"
|
|
262
|
+
style={[
|
|
263
|
+
styles.bar,
|
|
264
|
+
{
|
|
265
|
+
height: BAR_MIN_HEIGHT + level * (BAR_MAX_HEIGHT - BAR_MIN_HEIGHT),
|
|
266
|
+
backgroundColor: barCentre(i) <= progress ? accent : trackColor,
|
|
267
|
+
},
|
|
268
|
+
]}
|
|
269
|
+
/>
|
|
270
|
+
))}
|
|
271
|
+
{trackWidth > 0 ? (
|
|
272
|
+
<View
|
|
273
|
+
pointerEvents="none"
|
|
274
|
+
style={[
|
|
275
|
+
styles.knob,
|
|
276
|
+
{ backgroundColor: accent, transform: [{ translateX: knobOffset }] },
|
|
277
|
+
]}
|
|
278
|
+
/>
|
|
279
|
+
) : null}
|
|
280
|
+
</View>
|
|
281
|
+
</View>
|
|
282
|
+
|
|
283
|
+
<View style={styles.meta}>
|
|
284
|
+
{time != null ? <Text style={[styles.time, { color: subtle }]}>{time}</Text> : null}
|
|
285
|
+
{speed ? (
|
|
286
|
+
<Pressable
|
|
287
|
+
onPress={speed.onPress}
|
|
288
|
+
accessibilityRole="button"
|
|
289
|
+
accessibilityLabel={labels.speed}
|
|
290
|
+
hitSlop={12}
|
|
291
|
+
style={({ pressed }) => [
|
|
292
|
+
styles.speed,
|
|
293
|
+
{ backgroundColor: speed.changed ? accent : trackColor, opacity: pressed ? 0.6 : 1 },
|
|
294
|
+
]}>
|
|
295
|
+
<Text
|
|
296
|
+
style={[
|
|
297
|
+
styles.speedText,
|
|
298
|
+
{ color: speed.changed ? onAccent : onBrandBubble ? palette.white : palette.text },
|
|
299
|
+
]}>
|
|
300
|
+
{speed.value}×
|
|
301
|
+
</Text>
|
|
302
|
+
</Pressable>
|
|
303
|
+
) : null}
|
|
304
|
+
</View>
|
|
305
|
+
</View>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const styles = StyleSheet.create({
|
|
310
|
+
// A timeline runs left to right in every language, so RTL doesn't mirror it
|
|
311
|
+
// (the touch position and the playhead stay in the same coordinates).
|
|
312
|
+
root: { minWidth: 200, direction: 'ltr' },
|
|
313
|
+
row: { flexDirection: 'row', alignItems: 'center', gap: 10 },
|
|
314
|
+
playButton: {
|
|
315
|
+
width: BUTTON_SIZE,
|
|
316
|
+
height: BUTTON_SIZE,
|
|
317
|
+
borderRadius: BUTTON_SIZE / 2,
|
|
318
|
+
alignItems: 'center',
|
|
319
|
+
justifyContent: 'center',
|
|
320
|
+
},
|
|
321
|
+
track: {
|
|
322
|
+
flex: 1,
|
|
323
|
+
minWidth: 0,
|
|
324
|
+
height: BUTTON_SIZE,
|
|
325
|
+
flexDirection: 'row',
|
|
326
|
+
alignItems: 'center',
|
|
327
|
+
justifyContent: 'space-between',
|
|
328
|
+
},
|
|
329
|
+
bar: { width: BAR_WIDTH, borderRadius: BAR_WIDTH / 2 },
|
|
330
|
+
knob: {
|
|
331
|
+
position: 'absolute',
|
|
332
|
+
left: 0,
|
|
333
|
+
width: KNOB_SIZE,
|
|
334
|
+
height: KNOB_SIZE,
|
|
335
|
+
borderRadius: KNOB_SIZE / 2,
|
|
336
|
+
},
|
|
337
|
+
meta: {
|
|
338
|
+
minHeight: 18,
|
|
339
|
+
marginTop: -2,
|
|
340
|
+
marginLeft: BUTTON_SIZE + 10,
|
|
341
|
+
flexDirection: 'row',
|
|
342
|
+
alignItems: 'center',
|
|
343
|
+
gap: 6,
|
|
344
|
+
},
|
|
345
|
+
time: { fontSize: fontSize.caption, fontWeight: '500', fontVariant: ['tabular-nums'] },
|
|
346
|
+
speed: {
|
|
347
|
+
minWidth: 30,
|
|
348
|
+
height: 18,
|
|
349
|
+
paddingHorizontal: 6,
|
|
350
|
+
borderRadius: 9,
|
|
351
|
+
alignItems: 'center',
|
|
352
|
+
justifyContent: 'center',
|
|
353
|
+
},
|
|
354
|
+
speedText: { fontSize: 11, fontWeight: '700', fontVariant: ['tabular-nums'] },
|
|
355
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An image the visitor can pinch to zoom, drag around once zoomed, and
|
|
3
|
+
* double-tap to zoom in on a spot (and again to zoom back out). Built on
|
|
4
|
+
* PanResponder and Animated, so it behaves the same on iOS and Android
|
|
5
|
+
* without a gesture library in the host app.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useRef, useState } from 'react';
|
|
9
|
+
import {
|
|
10
|
+
Animated,
|
|
11
|
+
PanResponder,
|
|
12
|
+
StyleSheet,
|
|
13
|
+
View,
|
|
14
|
+
type GestureResponderEvent,
|
|
15
|
+
type ImageLoadEventData,
|
|
16
|
+
type LayoutChangeEvent,
|
|
17
|
+
type NativeSyntheticEvent,
|
|
18
|
+
} from 'react-native';
|
|
19
|
+
|
|
20
|
+
import { useReduceMotion } from '../hooks/use-reduce-motion';
|
|
21
|
+
|
|
22
|
+
export const MAX_ZOOM = 4;
|
|
23
|
+
export const DOUBLE_TAP_ZOOM = 2.5;
|
|
24
|
+
const DOUBLE_TAP_MS = 300;
|
|
25
|
+
const DOUBLE_TAP_SLOP = 30;
|
|
26
|
+
const TAP_SLOP = 10;
|
|
27
|
+
|
|
28
|
+
/** Scale and offset (from the centre of the view) of the image. */
|
|
29
|
+
export interface ZoomView {
|
|
30
|
+
scale: number;
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Size {
|
|
36
|
+
width: number;
|
|
37
|
+
height: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The size the image is drawn at with `contain`, before any zoom. */
|
|
41
|
+
export function fittedSize(box: Size, image: Size | null): Size {
|
|
42
|
+
if (!image || image.width <= 0 || image.height <= 0) return box;
|
|
43
|
+
const fit = Math.min(box.width / image.width, box.height / image.height);
|
|
44
|
+
return { width: image.width * fit, height: image.height * fit };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Keeps the image inside the screen: zoom between 1× and `MAX_ZOOM`, and no
|
|
49
|
+
* empty space past an edge the zoomed image is wider (or taller) than.
|
|
50
|
+
*/
|
|
51
|
+
export function clampView(view: ZoomView, box: Size, content: Size): ZoomView {
|
|
52
|
+
const scale = Math.min(MAX_ZOOM, Math.max(1, view.scale));
|
|
53
|
+
const maxX = Math.max(0, (content.width * scale - box.width) / 2);
|
|
54
|
+
const maxY = Math.max(0, (content.height * scale - box.height) / 2);
|
|
55
|
+
return {
|
|
56
|
+
scale,
|
|
57
|
+
x: Math.min(maxX, Math.max(-maxX, view.x)),
|
|
58
|
+
y: Math.min(maxY, Math.max(-maxY, view.y)),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The view at `scale` that keeps the spot that was under `from` (a point
|
|
64
|
+
* relative to the centre, in the `start` view) under `to`. Pinching zooms
|
|
65
|
+
* around the fingers this way, and moving them pans at the same time.
|
|
66
|
+
*/
|
|
67
|
+
export function zoomAround(start: ZoomView, scale: number, from: { x: number; y: number }, to: { x: number; y: number }): ZoomView {
|
|
68
|
+
const spotX = (from.x - start.x) / start.scale;
|
|
69
|
+
const spotY = (from.y - start.y) / start.scale;
|
|
70
|
+
return { scale, x: to.x - scale * spotX, y: to.y - scale * spotY };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface Touch {
|
|
74
|
+
pageX: number;
|
|
75
|
+
pageY: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const centreOf = (touches: Touch[]) => ({
|
|
79
|
+
x: touches.reduce((sum, t) => sum + t.pageX, 0) / touches.length,
|
|
80
|
+
y: touches.reduce((sum, t) => sum + t.pageY, 0) / touches.length,
|
|
81
|
+
});
|
|
82
|
+
const spreadOf = (touches: Touch[]) =>
|
|
83
|
+
touches.length < 2 ? 0 : Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY);
|
|
84
|
+
|
|
85
|
+
export function ZoomableImage({
|
|
86
|
+
uri,
|
|
87
|
+
accessibilityLabel,
|
|
88
|
+
onLoad,
|
|
89
|
+
onError,
|
|
90
|
+
}: {
|
|
91
|
+
uri: string;
|
|
92
|
+
accessibilityLabel: string;
|
|
93
|
+
onLoad: () => void;
|
|
94
|
+
onError: () => void;
|
|
95
|
+
}) {
|
|
96
|
+
const reduceMotion = useReduceMotion();
|
|
97
|
+
const [scale] = useState(() => new Animated.Value(1));
|
|
98
|
+
const [x] = useState(() => new Animated.Value(0));
|
|
99
|
+
const [y] = useState(() => new Animated.Value(0));
|
|
100
|
+
|
|
101
|
+
// Gesture state, read by the (stable) PanResponder below.
|
|
102
|
+
const g = useRef({
|
|
103
|
+
view: { scale: 1, x: 0, y: 0 } as ZoomView,
|
|
104
|
+
box: { width: 0, height: 0 } as Size,
|
|
105
|
+
// Where the box sits on screen, so touches can be measured from its centre.
|
|
106
|
+
origin: { x: 0, y: 0 },
|
|
107
|
+
image: null as Size | null,
|
|
108
|
+
// The view, finger centre and finger spread when the current set of fingers went down.
|
|
109
|
+
start: { view: { scale: 1, x: 0, y: 0 } as ZoomView, centre: { x: 0, y: 0 }, spread: 0, count: 0 },
|
|
110
|
+
moved: false,
|
|
111
|
+
grantedAt: 0,
|
|
112
|
+
lastTap: null as { at: number; x: number; y: number } | null,
|
|
113
|
+
reduceMotion: false as boolean | null,
|
|
114
|
+
native: false,
|
|
115
|
+
}).current;
|
|
116
|
+
g.reduceMotion = reduceMotion;
|
|
117
|
+
|
|
118
|
+
const boxRef = useRef<View>(null);
|
|
119
|
+
|
|
120
|
+
const show = (view: ZoomView, animate: boolean) => {
|
|
121
|
+
g.view = view;
|
|
122
|
+
if (!animate || g.reduceMotion !== false) {
|
|
123
|
+
scale.setValue(view.scale);
|
|
124
|
+
x.setValue(view.x);
|
|
125
|
+
y.setValue(view.y);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const spring = (value: Animated.Value, toValue: number) =>
|
|
129
|
+
Animated.spring(value, { toValue, useNativeDriver: true, bounciness: 0, speed: 18 });
|
|
130
|
+
Animated.parallel([spring(scale, view.scale), spring(x, view.x), spring(y, view.y)]).start();
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Hands the transform to the native driver as soon as the image is on
|
|
134
|
+
// screen. Until a native animation had run, Android (new architecture) didn't
|
|
135
|
+
// draw finger moves, so pinching only worked after a double tap.
|
|
136
|
+
const goNative = () => {
|
|
137
|
+
if (g.native) return;
|
|
138
|
+
g.native = true;
|
|
139
|
+
const settle = (value: Animated.Value, toValue: number) =>
|
|
140
|
+
Animated.spring(value, { toValue, useNativeDriver: true, bounciness: 0, speed: 18 });
|
|
141
|
+
Animated.parallel([settle(scale, g.view.scale), settle(x, g.view.x), settle(y, g.view.y)]).start();
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const fromCentre = (point: { x: number; y: number }) => ({
|
|
145
|
+
x: point.x - g.origin.x - g.box.width / 2,
|
|
146
|
+
y: point.y - g.origin.y - g.box.height / 2,
|
|
147
|
+
});
|
|
148
|
+
const content = () => fittedSize(g.box, g.image);
|
|
149
|
+
|
|
150
|
+
const begin = (touches: Touch[]) => {
|
|
151
|
+
g.start = { view: g.view, centre: fromCentre(centreOf(touches)), spread: spreadOf(touches), count: touches.length };
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const pan = useRef(
|
|
155
|
+
PanResponder.create({
|
|
156
|
+
onStartShouldSetPanResponder: () => true,
|
|
157
|
+
onMoveShouldSetPanResponder: () => true,
|
|
158
|
+
// Never hand the gesture over: nothing else in the full-screen viewer
|
|
159
|
+
// wants it, and giving it up at 1× ended a pinch as it started.
|
|
160
|
+
onPanResponderTerminationRequest: () => false,
|
|
161
|
+
onPanResponderGrant: (event: GestureResponderEvent) => {
|
|
162
|
+
g.moved = false;
|
|
163
|
+
g.grantedAt = Date.now();
|
|
164
|
+
begin(event.nativeEvent.touches);
|
|
165
|
+
},
|
|
166
|
+
onPanResponderMove: (event: GestureResponderEvent, gesture) => {
|
|
167
|
+
const touches = event.nativeEvent.touches;
|
|
168
|
+
if (touches.length === 0) return;
|
|
169
|
+
// A finger landed or lifted: carry on from where the image is now.
|
|
170
|
+
if (touches.length !== g.start.count) begin(touches);
|
|
171
|
+
if (Math.abs(gesture.dx) > TAP_SLOP || Math.abs(gesture.dy) > TAP_SLOP || touches.length > 1) {
|
|
172
|
+
g.moved = true;
|
|
173
|
+
}
|
|
174
|
+
const centre = fromCentre(centreOf(touches));
|
|
175
|
+
if (touches.length > 1 && g.start.spread > 0) {
|
|
176
|
+
// Past the limits while pinching, then springs back on release.
|
|
177
|
+
const zoom = Math.min(MAX_ZOOM * 1.25, Math.max(0.75, (g.start.view.scale * spreadOf(touches)) / g.start.spread));
|
|
178
|
+
show(zoomAround(g.start.view, zoom, g.start.centre, centre), false);
|
|
179
|
+
} else if (g.start.view.scale > 1) {
|
|
180
|
+
// One finger drags the zoomed image; at 1× there's nothing to drag.
|
|
181
|
+
const dragged = {
|
|
182
|
+
...g.start.view,
|
|
183
|
+
x: g.start.view.x + centre.x - g.start.centre.x,
|
|
184
|
+
y: g.start.view.y + centre.y - g.start.centre.y,
|
|
185
|
+
};
|
|
186
|
+
show(clampView(dragged, g.box, content()), false);
|
|
187
|
+
}
|
|
188
|
+
},
|
|
189
|
+
onPanResponderRelease: (event: GestureResponderEvent) => {
|
|
190
|
+
const now = Date.now();
|
|
191
|
+
const { pageX, pageY } = event.nativeEvent;
|
|
192
|
+
const isTap = !g.moved && now - g.grantedAt < DOUBLE_TAP_MS;
|
|
193
|
+
const last = g.lastTap;
|
|
194
|
+
if (
|
|
195
|
+
isTap &&
|
|
196
|
+
last &&
|
|
197
|
+
now - last.at < DOUBLE_TAP_MS &&
|
|
198
|
+
Math.hypot(pageX - last.x, pageY - last.y) < DOUBLE_TAP_SLOP
|
|
199
|
+
) {
|
|
200
|
+
g.lastTap = null;
|
|
201
|
+
// Zooming in keeps the tapped spot under the finger (as far as the edges allow).
|
|
202
|
+
const tapped = fromCentre({ x: pageX, y: pageY });
|
|
203
|
+
const target =
|
|
204
|
+
g.view.scale > 1.01
|
|
205
|
+
? { scale: 1, x: 0, y: 0 }
|
|
206
|
+
: zoomAround({ scale: 1, x: 0, y: 0 }, DOUBLE_TAP_ZOOM, tapped, tapped);
|
|
207
|
+
show(clampView(target, g.box, content()), true);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
g.lastTap = isTap ? { at: now, x: pageX, y: pageY } : null;
|
|
211
|
+
show(clampView(g.view, g.box, content()), true);
|
|
212
|
+
},
|
|
213
|
+
onPanResponderTerminate: () => show(clampView(g.view, g.box, content()), true),
|
|
214
|
+
})
|
|
215
|
+
).current;
|
|
216
|
+
|
|
217
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
218
|
+
const { width, height } = event.nativeEvent.layout;
|
|
219
|
+
g.box = { width, height };
|
|
220
|
+
goNative();
|
|
221
|
+
boxRef.current?.measureInWindow?.((left, top) => {
|
|
222
|
+
g.origin = { x: left, y: top };
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const handleLoad = (event: NativeSyntheticEvent<ImageLoadEventData>) => {
|
|
227
|
+
const source = event?.nativeEvent?.source;
|
|
228
|
+
if (source && source.width > 0 && source.height > 0) g.image = { width: source.width, height: source.height };
|
|
229
|
+
onLoad();
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<View ref={boxRef} style={styles.box} onLayout={onLayout} {...pan.panHandlers}>
|
|
234
|
+
<Animated.Image
|
|
235
|
+
source={{ uri }}
|
|
236
|
+
style={[styles.image, { transform: [{ translateX: x }, { translateY: y }, { scale }] }]}
|
|
237
|
+
resizeMode="contain"
|
|
238
|
+
accessibilityLabel={accessibilityLabel}
|
|
239
|
+
accessibilityIgnoresInvertColors
|
|
240
|
+
onLoad={handleLoad}
|
|
241
|
+
onError={onError}
|
|
242
|
+
/>
|
|
243
|
+
</View>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const styles = StyleSheet.create({
|
|
248
|
+
box: { flex: 1, overflow: 'hidden' },
|
|
249
|
+
image: { width: '100%', height: '100%' },
|
|
250
|
+
});
|