@repzo/widget-react-native 1.2.2 → 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.
Files changed (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +35 -29
  3. package/android/build.gradle +22 -0
  4. package/android/src/main/java/expo/modules/repzowidget/RepzoVoicePlayer.kt +222 -0
  5. package/android/src/main/java/expo/modules/repzowidget/RepzoWidgetAudioModule.kt +68 -0
  6. package/expo-module.config.json +9 -0
  7. package/ios/RepzoVoicePlayer.swift +175 -0
  8. package/ios/RepzoWidgetAudio.podspec +26 -0
  9. package/ios/RepzoWidgetAudioModule.swift +65 -0
  10. package/lib/commonjs/audio/native-player.js +97 -0
  11. package/lib/commonjs/audio/native-player.js.map +1 -0
  12. package/lib/commonjs/audio/voice.js +34 -7
  13. package/lib/commonjs/audio/voice.js.map +1 -1
  14. package/lib/commonjs/state/widget-store.js +23 -1
  15. package/lib/commonjs/state/widget-store.js.map +1 -1
  16. package/lib/commonjs/ui/WidgetProvider.js +1 -3
  17. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  18. package/lib/commonjs/ui/components/ImageViewer.js +17 -35
  19. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  20. package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
  21. package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
  22. package/lib/commonjs/ui/components/VoiceNote.js +8 -9
  23. package/lib/commonjs/ui/components/VoiceNote.js.map +1 -1
  24. package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
  25. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
  26. package/lib/commonjs/ui/screens/chat/ChatView.js +6 -4
  27. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  28. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +5 -7
  29. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  30. package/lib/module/audio/native-player.js +93 -0
  31. package/lib/module/audio/native-player.js.map +1 -0
  32. package/lib/module/audio/voice.js +32 -6
  33. package/lib/module/audio/voice.js.map +1 -1
  34. package/lib/module/state/widget-store.js +23 -1
  35. package/lib/module/state/widget-store.js.map +1 -1
  36. package/lib/module/ui/WidgetProvider.js +1 -3
  37. package/lib/module/ui/WidgetProvider.js.map +1 -1
  38. package/lib/module/ui/components/ImageViewer.js +18 -36
  39. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  40. package/lib/module/ui/components/MessagesSkeleton.js +124 -0
  41. package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
  42. package/lib/module/ui/components/VoiceNote.js +9 -10
  43. package/lib/module/ui/components/VoiceNote.js.map +1 -1
  44. package/lib/module/ui/components/ZoomableImage.js +291 -0
  45. package/lib/module/ui/components/ZoomableImage.js.map +1 -0
  46. package/lib/module/ui/screens/chat/ChatView.js +6 -4
  47. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  48. package/lib/module/ui/screens/chat/ChatView.styles.js +5 -7
  49. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  50. package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
  51. package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
  52. package/lib/typescript/commonjs/audio/voice.d.ts +17 -4
  53. package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/state/widget-store.d.ts +11 -0
  55. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  57. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +2 -2
  58. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  59. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
  60. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
  61. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +2 -2
  62. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
  64. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
  65. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +4 -7
  67. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  68. package/lib/typescript/module/audio/native-player.d.ts +36 -0
  69. package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
  70. package/lib/typescript/module/audio/voice.d.ts +17 -4
  71. package/lib/typescript/module/audio/voice.d.ts.map +1 -1
  72. package/lib/typescript/module/state/widget-store.d.ts +11 -0
  73. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  74. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  75. package/lib/typescript/module/ui/components/ImageViewer.d.ts +2 -2
  76. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  77. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
  78. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
  79. package/lib/typescript/module/ui/components/VoiceNote.d.ts +2 -2
  80. package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
  82. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
  83. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  84. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +4 -7
  85. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  86. package/package.json +10 -3
  87. package/react-native.config.js +12 -0
  88. package/src/audio/native-player.ts +106 -0
  89. package/src/audio/voice.ts +30 -6
  90. package/src/state/widget-store.ts +24 -1
  91. package/src/ui/WidgetProvider.tsx +1 -3
  92. package/src/ui/components/ImageViewer.tsx +11 -30
  93. package/src/ui/components/MessagesSkeleton.tsx +92 -0
  94. package/src/ui/components/VoiceNote.tsx +6 -14
  95. package/src/ui/components/ZoomableImage.tsx +250 -0
  96. package/src/ui/screens/chat/ChatView.styles.ts +5 -7
  97. package/src/ui/screens/chat/ChatView.tsx +4 -3
@@ -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
+ });
@@ -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
  },
@@ -62,6 +62,7 @@ import { isHttpUrl, openExternalUrl } from '../../utils/safe-url';
62
62
  import { isPlayableAudio } from '../../../audio/voice';
63
63
  import { VoiceNote, type VoiceNoteLabels } from '../../components/VoiceNote';
64
64
  import { FileCard } from '../../components/FileCard';
65
+ import { MessagesSkeleton } from '../../components/MessagesSkeleton';
65
66
  import { backDirection, isRTL } from '../../utils/rtl';
66
67
  import { styles } from './ChatView.styles';
67
68
  import type { ChatLabels, ChatViewProps } from './ChatView.types';
@@ -331,6 +332,7 @@ export function ChatView({
331
332
  const headerTextColor = contrastingTextColor(color);
332
333
  const onDarkHeader = headerTextColor === palette.white;
333
334
  const isEmptyThread = !loading && messages.length === 0;
335
+ const isLoadingThread = loading && messages.length === 0;
334
336
  const agentIsActive = agentOnline ?? isOnline;
335
337
  // "Waiting for a teammate" until someone replies: no teammate in the header
336
338
  // and the visitor spoke last (bot replies before that still count as waiting).
@@ -439,6 +441,7 @@ export function ChatView({
439
441
  contentContainerStyle={[
440
442
  styles.messagesContent,
441
443
  isEmptyThread && styles.messagesContentEmpty,
444
+ isLoadingThread && styles.messagesContentLoading,
442
445
  ]}
443
446
  keyboardShouldPersistTaps="handled"
444
447
  // Interactive dismissal drags the keyboard down under the finger while
@@ -455,9 +458,7 @@ export function ChatView({
455
458
  </Pressable>
456
459
  ) : null}
457
460
 
458
- {loading && messages.length === 0 ? (
459
- <Text style={styles.stateText}>{labels.loading}</Text>
460
- ) : null}
461
+ {isLoadingThread ? <MessagesSkeleton color={color} label={labels.loading} /> : null}
461
462
 
462
463
  {isEmptyThread ? (
463
464
  <View style={styles.emptyState}>