@repzo/widget-react-native 1.2.2 → 1.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 +88 -0
- package/README.md +43 -33
- 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 +40 -0
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/attachments/pickers.js +80 -23
- package/lib/commonjs/attachments/pickers.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 +34 -7
- package/lib/commonjs/audio/voice.js.map +1 -1
- package/lib/commonjs/i18n.js +2 -0
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/locales.js +1 -0
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +238 -40
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +8 -6
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/ImageViewer.js +20 -37
- package/lib/commonjs/ui/components/ImageViewer.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 +8 -9
- package/lib/commonjs/ui/components/VoiceNote.js.map +1 -1
- 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 +22 -16
- package/lib/commonjs/ui/components/icons.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.js +42 -18
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +17 -9
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/commonjs/ui/styles/theme.js +1 -0
- package/lib/commonjs/ui/styles/theme.js.map +1 -1
- package/lib/commonjs/ui/utils/conversations.js +17 -1
- package/lib/commonjs/ui/utils/conversations.js.map +1 -1
- package/lib/module/api/client.js +40 -0
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/attachments/pickers.js +80 -23
- package/lib/module/attachments/pickers.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 +32 -6
- package/lib/module/audio/voice.js.map +1 -1
- package/lib/module/i18n.js +2 -0
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/locales.js +1 -0
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +238 -40
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +8 -6
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/ImageViewer.js +21 -38
- package/lib/module/ui/components/ImageViewer.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 +9 -10
- package/lib/module/ui/components/VoiceNote.js.map +1 -1
- 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 +21 -15
- package/lib/module/ui/components/icons.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.js +44 -20
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +17 -9
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/ui/styles/theme.js +1 -0
- package/lib/module/ui/styles/theme.js.map +1 -1
- package/lib/module/ui/utils/conversations.js +15 -1
- package/lib/module/ui/utils/conversations.js.map +1 -1
- package/lib/typescript/commonjs/api/client.d.ts +27 -1
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/attachments/pickers.d.ts +22 -10
- package/lib/typescript/commonjs/attachments/pickers.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 +17 -4
- package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +1 -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/widget-store.d.ts +38 -3
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +17 -0
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
- 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/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 +2 -2
- package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -1
- 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 +2 -1
- package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +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 +14 -9
- 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/commonjs/ui/styles/theme.d.ts +2 -0
- package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -1
- package/lib/typescript/module/api/client.d.ts +27 -1
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/attachments/pickers.d.ts +22 -10
- package/lib/typescript/module/attachments/pickers.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 +17 -4
- package/lib/typescript/module/audio/voice.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +1 -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/widget-store.d.ts +38 -3
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +17 -0
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
- 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/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 +2 -2
- package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -1
- 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 +2 -1
- package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +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 +14 -9
- 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/lib/typescript/module/ui/styles/theme.d.ts +2 -0
- package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -1
- package/package.json +10 -3
- package/react-native.config.js +12 -0
- package/src/api/client.ts +66 -2
- package/src/attachments/pickers.ts +87 -25
- package/src/audio/native-player.ts +106 -0
- package/src/audio/voice.ts +30 -6
- package/src/i18n.ts +2 -0
- package/src/locales.ts +1 -0
- package/src/state/widget-store.ts +233 -33
- package/src/types.ts +17 -0
- package/src/ui/WidgetProvider.tsx +16 -7
- package/src/ui/components/ImageViewer.tsx +21 -36
- package/src/ui/components/MessagesSkeleton.tsx +92 -0
- package/src/ui/components/VoiceNote.tsx +6 -14
- package/src/ui/components/ZoomableImage.tsx +250 -0
- package/src/ui/components/icons.tsx +10 -12
- package/src/ui/screens/chat/ChatView.styles.ts +17 -9
- package/src/ui/screens/chat/ChatView.tsx +49 -25
- package/src/ui/screens/chat/ChatView.types.ts +4 -0
- package/src/ui/styles/theme.ts +6 -0
- package/src/ui/utils/conversations.ts +16 -2
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Full-screen viewer for image attachments, so a tap on a photo the agent sent
|
|
3
|
-
* stays inside the messenger instead of bouncing to the browser. Pinch
|
|
4
|
-
*
|
|
3
|
+
* stays inside the messenger instead of bouncing to the browser. Pinch, drag
|
|
4
|
+
* and double-tap zoom work on iOS and Android (see ZoomableImage).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { useState } from 'react';
|
|
8
8
|
import {
|
|
9
9
|
ActivityIndicator,
|
|
10
|
-
Image,
|
|
11
10
|
Modal,
|
|
12
11
|
Platform,
|
|
13
12
|
Pressable,
|
|
14
|
-
ScrollView,
|
|
15
13
|
StyleSheet,
|
|
16
14
|
Text,
|
|
17
15
|
View,
|
|
@@ -19,7 +17,9 @@ import {
|
|
|
19
17
|
|
|
20
18
|
import { useWidgetInsets } from '../hooks/use-widget-insets';
|
|
21
19
|
import { fontSize, palette, spacing } from '../styles/theme';
|
|
20
|
+
import { isHttpUrl } from '../utils/safe-url';
|
|
22
21
|
import { CloseIcon } from './icons';
|
|
22
|
+
import { ZoomableImage } from './ZoomableImage';
|
|
23
23
|
|
|
24
24
|
export interface ImageViewerLabels {
|
|
25
25
|
close: string;
|
|
@@ -52,25 +52,14 @@ export function ImageViewer({ image, labels, onClose, onOpenExternally }: ImageV
|
|
|
52
52
|
onRequestClose={onClose}>
|
|
53
53
|
<View style={styles.backdrop} accessibilityViewIsModal>
|
|
54
54
|
{image ? (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
bouncesZoom>
|
|
64
|
-
<Image
|
|
65
|
-
source={{ uri: image.url }}
|
|
66
|
-
style={styles.image}
|
|
67
|
-
resizeMode="contain"
|
|
68
|
-
accessibilityLabel={image.name}
|
|
69
|
-
accessibilityIgnoresInvertColors
|
|
70
|
-
onLoad={() => setStatus({ url: image.url, state: 'ready' })}
|
|
71
|
-
onError={() => setStatus({ url: image.url, state: 'failed' })}
|
|
72
|
-
/>
|
|
73
|
-
</ScrollView>
|
|
55
|
+
// Keyed by URL so the next image opens at 1×, not at the last one's zoom.
|
|
56
|
+
<ZoomableImage
|
|
57
|
+
key={image.url}
|
|
58
|
+
uri={image.url}
|
|
59
|
+
accessibilityLabel={image.name}
|
|
60
|
+
onLoad={() => setStatus({ url: image.url, state: 'ready' })}
|
|
61
|
+
onError={() => setStatus({ url: image.url, state: 'failed' })}
|
|
62
|
+
/>
|
|
74
63
|
) : null}
|
|
75
64
|
|
|
76
65
|
{image && state === 'loading' ? (
|
|
@@ -80,12 +69,15 @@ export function ImageViewer({ image, labels, onClose, onOpenExternally }: ImageV
|
|
|
80
69
|
{image && state === 'failed' ? (
|
|
81
70
|
<View style={[StyleSheet.absoluteFill, styles.center, styles.failed]}>
|
|
82
71
|
<Text style={styles.failedText}>{labels.loadFailed}</Text>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
72
|
+
{/* A visitor's local photo has no web copy to open yet. */}
|
|
73
|
+
{isHttpUrl(image.url) ? (
|
|
74
|
+
<Pressable
|
|
75
|
+
onPress={() => onOpenExternally(image.url)}
|
|
76
|
+
accessibilityRole="button"
|
|
77
|
+
style={({ pressed }) => [styles.externalButton, pressed && styles.pressed]}>
|
|
78
|
+
<Text style={styles.externalLabel}>{labels.openExternally}</Text>
|
|
79
|
+
</Pressable>
|
|
80
|
+
) : null}
|
|
89
81
|
</View>
|
|
90
82
|
) : null}
|
|
91
83
|
|
|
@@ -112,18 +104,11 @@ const styles = StyleSheet.create({
|
|
|
112
104
|
flex: 1,
|
|
113
105
|
backgroundColor: '#000000',
|
|
114
106
|
},
|
|
115
|
-
fill: {
|
|
116
|
-
flex: 1,
|
|
117
|
-
},
|
|
118
107
|
center: {
|
|
119
108
|
flexGrow: 1,
|
|
120
109
|
alignItems: 'center',
|
|
121
110
|
justifyContent: 'center',
|
|
122
111
|
},
|
|
123
|
-
image: {
|
|
124
|
-
width: '100%',
|
|
125
|
-
height: '100%',
|
|
126
|
-
},
|
|
127
112
|
header: {
|
|
128
113
|
position: 'absolute',
|
|
129
114
|
top: 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
|
+
});
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* A voice note (or any audio attachment) played inside the chat, laid out the
|
|
3
3
|
* way messaging apps do it: a play button beside a waveform the visitor can
|
|
4
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
|
|
6
|
-
*
|
|
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
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
@@ -18,13 +18,7 @@ import {
|
|
|
18
18
|
type LayoutChangeEvent,
|
|
19
19
|
} from 'react-native';
|
|
20
20
|
|
|
21
|
-
import {
|
|
22
|
-
audioPlayerOptions,
|
|
23
|
-
claimVoicePlayback,
|
|
24
|
-
formatVoiceClock,
|
|
25
|
-
voicePlaybackModule,
|
|
26
|
-
voiceWaveformLevels,
|
|
27
|
-
} from '../../audio/voice';
|
|
21
|
+
import { claimVoicePlayback, formatVoiceClock, useVoicePlayer, voiceWaveformLevels } from '../../audio/voice';
|
|
28
22
|
import { PauseIcon, PlayIcon } from './icons';
|
|
29
23
|
import { fontSize, palette, withAlpha } from '../styles/theme';
|
|
30
24
|
|
|
@@ -75,12 +69,10 @@ export function VoiceNote(props: VoiceNoteProps) {
|
|
|
75
69
|
);
|
|
76
70
|
}
|
|
77
71
|
|
|
78
|
-
/** A note that has been started: owns its
|
|
72
|
+
/** A note that has been started: owns its player and plays on mount. */
|
|
79
73
|
function ActiveVoiceNote(props: VoiceNoteProps) {
|
|
80
74
|
const { url, onError } = props;
|
|
81
|
-
const
|
|
82
|
-
const player = audio.useAudioPlayer({ uri: url }, audioPlayerOptions());
|
|
83
|
-
const status = audio.useAudioPlayerStatus(player);
|
|
75
|
+
const { player, status } = useVoicePlayer(url);
|
|
84
76
|
const [speedIndex, setSpeedIndex] = useState(0);
|
|
85
77
|
const [scrub, setScrub] = useState<number | null>(null);
|
|
86
78
|
const [trackWidth, setTrackWidth] = useState(0);
|
|
@@ -94,7 +86,7 @@ function ActiveVoiceNote(props: VoiceNoteProps) {
|
|
|
94
86
|
const currentTime = Math.max(Number(status?.currentTime) || 0, 0);
|
|
95
87
|
const playing = !!status?.playing;
|
|
96
88
|
const finished = !!status?.didJustFinish || (duration > 0 && currentTime >= duration - 0.1);
|
|
97
|
-
// `isLoaded` / `isBuffering` are compared strictly:
|
|
89
|
+
// `isLoaded` / `isBuffering` are compared strictly: a player that
|
|
98
90
|
// doesn't report them never gets a spinner that can't go away.
|
|
99
91
|
const loading = wantsPlay && !playing && (status?.isLoaded === false || status?.isBuffering === true);
|
|
100
92
|
|
|
@@ -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
|
+
});
|
|
@@ -322,20 +322,18 @@ export function AlertIcon({ size = 16, color = palette.warningIcon, style }: Ico
|
|
|
322
322
|
);
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
|
|
326
|
-
|
|
325
|
+
/** Two upright crossed bars — the composer's attach button. */
|
|
326
|
+
export function PlusIcon({ size = 18, color = palette.textMuted, style }: IconProps) {
|
|
327
|
+
const thickness = Math.max(2, size * 0.1);
|
|
328
|
+
const bar: ViewStyle = {
|
|
329
|
+
position: 'absolute',
|
|
330
|
+
borderRadius: thickness / 2,
|
|
331
|
+
backgroundColor: color,
|
|
332
|
+
};
|
|
327
333
|
return (
|
|
328
334
|
<View style={[{ width: size, height: size }, styles.center, style]}>
|
|
329
|
-
<View
|
|
330
|
-
|
|
331
|
-
width: size * 0.42,
|
|
332
|
-
height: size * 0.82,
|
|
333
|
-
borderRadius: size * 0.21,
|
|
334
|
-
borderWidth: thickness,
|
|
335
|
-
borderColor: color,
|
|
336
|
-
transform: [{ rotate: '35deg' }],
|
|
337
|
-
}}
|
|
338
|
-
/>
|
|
335
|
+
<View style={[bar, { width: size * 0.8, height: thickness }]} />
|
|
336
|
+
<View style={[bar, { width: thickness, height: size * 0.8 }]} />
|
|
339
337
|
</View>
|
|
340
338
|
);
|
|
341
339
|
}
|
|
@@ -84,6 +84,11 @@ export const styles = StyleSheet.create({
|
|
|
84
84
|
flexGrow: 1,
|
|
85
85
|
justifyContent: 'center',
|
|
86
86
|
},
|
|
87
|
+
// Placeholders sit at the bottom, where the newest messages will land.
|
|
88
|
+
messagesContentLoading: {
|
|
89
|
+
flexGrow: 1,
|
|
90
|
+
justifyContent: 'flex-end',
|
|
91
|
+
},
|
|
87
92
|
emptyState: {
|
|
88
93
|
alignItems: 'center',
|
|
89
94
|
paddingHorizontal: spacing.xl,
|
|
@@ -117,13 +122,6 @@ export const styles = StyleSheet.create({
|
|
|
117
122
|
fontSize: fontSize.meta,
|
|
118
123
|
fontWeight: '600',
|
|
119
124
|
},
|
|
120
|
-
stateText: {
|
|
121
|
-
textAlign: 'center',
|
|
122
|
-
paddingVertical: 40,
|
|
123
|
-
paddingHorizontal: spacing.xl,
|
|
124
|
-
color: palette.textSubtle,
|
|
125
|
-
fontSize: fontSize.meta,
|
|
126
|
-
},
|
|
127
125
|
messageRow: {
|
|
128
126
|
maxWidth: '100%',
|
|
129
127
|
},
|
|
@@ -214,9 +212,19 @@ export const styles = StyleSheet.create({
|
|
|
214
212
|
height: 140,
|
|
215
213
|
borderRadius: radius.md,
|
|
216
214
|
},
|
|
215
|
+
// Follows the bubble's side. Under the visitor's it lines up with
|
|
216
|
+
// `receiptText`, which it replaces on a failed message.
|
|
217
|
+
failedRow: {
|
|
218
|
+
flexDirection: 'row',
|
|
219
|
+
alignItems: 'center',
|
|
220
|
+
gap: 4,
|
|
221
|
+
marginTop: 3,
|
|
222
|
+
marginEnd: spacing.xs,
|
|
223
|
+
},
|
|
217
224
|
failedText: {
|
|
218
|
-
fontSize:
|
|
219
|
-
|
|
225
|
+
fontSize: 11,
|
|
226
|
+
fontWeight: '600',
|
|
227
|
+
color: palette.danger,
|
|
220
228
|
},
|
|
221
229
|
deletedBubble: {
|
|
222
230
|
maxWidth: '80%',
|