@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.
Files changed (172) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/README.md +43 -33
  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/api/client.js +40 -0
  11. package/lib/commonjs/api/client.js.map +1 -1
  12. package/lib/commonjs/attachments/pickers.js +80 -23
  13. package/lib/commonjs/attachments/pickers.js.map +1 -1
  14. package/lib/commonjs/audio/native-player.js +97 -0
  15. package/lib/commonjs/audio/native-player.js.map +1 -0
  16. package/lib/commonjs/audio/voice.js +34 -7
  17. package/lib/commonjs/audio/voice.js.map +1 -1
  18. package/lib/commonjs/i18n.js +2 -0
  19. package/lib/commonjs/i18n.js.map +1 -1
  20. package/lib/commonjs/locales.js +1 -0
  21. package/lib/commonjs/locales.js.map +1 -1
  22. package/lib/commonjs/state/widget-store.js +238 -40
  23. package/lib/commonjs/state/widget-store.js.map +1 -1
  24. package/lib/commonjs/types.js.map +1 -1
  25. package/lib/commonjs/ui/WidgetProvider.js +8 -6
  26. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  27. package/lib/commonjs/ui/components/ImageViewer.js +20 -37
  28. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  29. package/lib/commonjs/ui/components/MessagesSkeleton.js +126 -0
  30. package/lib/commonjs/ui/components/MessagesSkeleton.js.map +1 -0
  31. package/lib/commonjs/ui/components/VoiceNote.js +8 -9
  32. package/lib/commonjs/ui/components/VoiceNote.js.map +1 -1
  33. package/lib/commonjs/ui/components/ZoomableImage.js +299 -0
  34. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -0
  35. package/lib/commonjs/ui/components/icons.js +22 -16
  36. package/lib/commonjs/ui/components/icons.js.map +1 -1
  37. package/lib/commonjs/ui/screens/chat/ChatView.js +42 -18
  38. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  39. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +17 -9
  40. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  41. package/lib/commonjs/ui/styles/theme.js +1 -0
  42. package/lib/commonjs/ui/styles/theme.js.map +1 -1
  43. package/lib/commonjs/ui/utils/conversations.js +17 -1
  44. package/lib/commonjs/ui/utils/conversations.js.map +1 -1
  45. package/lib/module/api/client.js +40 -0
  46. package/lib/module/api/client.js.map +1 -1
  47. package/lib/module/attachments/pickers.js +80 -23
  48. package/lib/module/attachments/pickers.js.map +1 -1
  49. package/lib/module/audio/native-player.js +93 -0
  50. package/lib/module/audio/native-player.js.map +1 -0
  51. package/lib/module/audio/voice.js +32 -6
  52. package/lib/module/audio/voice.js.map +1 -1
  53. package/lib/module/i18n.js +2 -0
  54. package/lib/module/i18n.js.map +1 -1
  55. package/lib/module/locales.js +1 -0
  56. package/lib/module/locales.js.map +1 -1
  57. package/lib/module/state/widget-store.js +238 -40
  58. package/lib/module/state/widget-store.js.map +1 -1
  59. package/lib/module/types.js.map +1 -1
  60. package/lib/module/ui/WidgetProvider.js +8 -6
  61. package/lib/module/ui/WidgetProvider.js.map +1 -1
  62. package/lib/module/ui/components/ImageViewer.js +21 -38
  63. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  64. package/lib/module/ui/components/MessagesSkeleton.js +124 -0
  65. package/lib/module/ui/components/MessagesSkeleton.js.map +1 -0
  66. package/lib/module/ui/components/VoiceNote.js +9 -10
  67. package/lib/module/ui/components/VoiceNote.js.map +1 -1
  68. package/lib/module/ui/components/ZoomableImage.js +291 -0
  69. package/lib/module/ui/components/ZoomableImage.js.map +1 -0
  70. package/lib/module/ui/components/icons.js +21 -15
  71. package/lib/module/ui/components/icons.js.map +1 -1
  72. package/lib/module/ui/screens/chat/ChatView.js +44 -20
  73. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  74. package/lib/module/ui/screens/chat/ChatView.styles.js +17 -9
  75. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  76. package/lib/module/ui/styles/theme.js +1 -0
  77. package/lib/module/ui/styles/theme.js.map +1 -1
  78. package/lib/module/ui/utils/conversations.js +15 -1
  79. package/lib/module/ui/utils/conversations.js.map +1 -1
  80. package/lib/typescript/commonjs/api/client.d.ts +27 -1
  81. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/attachments/pickers.d.ts +22 -10
  83. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/audio/native-player.d.ts +36 -0
  85. package/lib/typescript/commonjs/audio/native-player.d.ts.map +1 -0
  86. package/lib/typescript/commonjs/audio/voice.d.ts +17 -4
  87. package/lib/typescript/commonjs/audio/voice.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/i18n.d.ts +1 -0
  89. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  91. package/lib/typescript/commonjs/state/widget-store.d.ts +38 -3
  92. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  93. package/lib/typescript/commonjs/types.d.ts +17 -0
  94. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  95. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +2 -2
  97. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts +10 -0
  99. package/lib/typescript/commonjs/ui/components/MessagesSkeleton.d.ts.map +1 -0
  100. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts +2 -2
  101. package/lib/typescript/commonjs/ui/components/VoiceNote.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +45 -0
  103. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -0
  104. package/lib/typescript/commonjs/ui/components/icons.d.ts +2 -1
  105. package/lib/typescript/commonjs/ui/components/icons.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +1 -1
  107. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  108. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +14 -9
  109. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  110. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +4 -0
  111. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/ui/styles/theme.d.ts +2 -0
  113. package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
  115. package/lib/typescript/commonjs/ui/utils/conversations.d.ts.map +1 -1
  116. package/lib/typescript/module/api/client.d.ts +27 -1
  117. package/lib/typescript/module/api/client.d.ts.map +1 -1
  118. package/lib/typescript/module/attachments/pickers.d.ts +22 -10
  119. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  120. package/lib/typescript/module/audio/native-player.d.ts +36 -0
  121. package/lib/typescript/module/audio/native-player.d.ts.map +1 -0
  122. package/lib/typescript/module/audio/voice.d.ts +17 -4
  123. package/lib/typescript/module/audio/voice.d.ts.map +1 -1
  124. package/lib/typescript/module/i18n.d.ts +1 -0
  125. package/lib/typescript/module/i18n.d.ts.map +1 -1
  126. package/lib/typescript/module/locales.d.ts.map +1 -1
  127. package/lib/typescript/module/state/widget-store.d.ts +38 -3
  128. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  129. package/lib/typescript/module/types.d.ts +17 -0
  130. package/lib/typescript/module/types.d.ts.map +1 -1
  131. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  132. package/lib/typescript/module/ui/components/ImageViewer.d.ts +2 -2
  133. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  134. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts +10 -0
  135. package/lib/typescript/module/ui/components/MessagesSkeleton.d.ts.map +1 -0
  136. package/lib/typescript/module/ui/components/VoiceNote.d.ts +2 -2
  137. package/lib/typescript/module/ui/components/VoiceNote.d.ts.map +1 -1
  138. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +45 -0
  139. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -0
  140. package/lib/typescript/module/ui/components/icons.d.ts +2 -1
  141. package/lib/typescript/module/ui/components/icons.d.ts.map +1 -1
  142. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +1 -1
  143. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  144. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +14 -9
  145. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  146. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +4 -0
  147. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  148. package/lib/typescript/module/ui/styles/theme.d.ts +2 -0
  149. package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
  150. package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
  151. package/lib/typescript/module/ui/utils/conversations.d.ts.map +1 -1
  152. package/package.json +10 -3
  153. package/react-native.config.js +12 -0
  154. package/src/api/client.ts +66 -2
  155. package/src/attachments/pickers.ts +87 -25
  156. package/src/audio/native-player.ts +106 -0
  157. package/src/audio/voice.ts +30 -6
  158. package/src/i18n.ts +2 -0
  159. package/src/locales.ts +1 -0
  160. package/src/state/widget-store.ts +233 -33
  161. package/src/types.ts +17 -0
  162. package/src/ui/WidgetProvider.tsx +16 -7
  163. package/src/ui/components/ImageViewer.tsx +21 -36
  164. package/src/ui/components/MessagesSkeleton.tsx +92 -0
  165. package/src/ui/components/VoiceNote.tsx +6 -14
  166. package/src/ui/components/ZoomableImage.tsx +250 -0
  167. package/src/ui/components/icons.tsx +10 -12
  168. package/src/ui/screens/chat/ChatView.styles.ts +17 -9
  169. package/src/ui/screens/chat/ChatView.tsx +49 -25
  170. package/src/ui/screens/chat/ChatView.types.ts +4 -0
  171. package/src/ui/styles/theme.ts +6 -0
  172. 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-to-zoom
4
- * uses the native ScrollView zoom (iOS); Android shows the image fitted.
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
- <ScrollView
56
- style={styles.fill}
57
- contentContainerStyle={styles.center}
58
- maximumZoomScale={4}
59
- minimumZoomScale={1}
60
- centerContent
61
- showsHorizontalScrollIndicator={false}
62
- showsVerticalScrollIndicator={false}
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
- <Pressable
84
- onPress={() => onOpenExternally(image.url)}
85
- accessibilityRole="button"
86
- style={({ pressed }) => [styles.externalButton, pressed && styles.pressed]}>
87
- <Text style={styles.externalLabel}>{labels.openExternally}</Text>
88
- </Pressable>
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 host app has
6
- * `expo-audio` (see `src/audio/voice.ts`); without it audio stays a file row.
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 expo-audio player and plays on mount. */
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 audio = voicePlaybackModule();
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: an expo-audio that
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
- export function PaperclipIcon({ size = 18, color = palette.textMuted, style }: IconProps) {
326
- const thickness = Math.max(1.5, size * 0.1);
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
- style={{
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: fontSize.caption,
219
- marginTop: spacing.xs,
225
+ fontSize: 11,
226
+ fontWeight: '600',
227
+ color: palette.danger,
220
228
  },
221
229
  deletedBubble: {
222
230
  maxWidth: '80%',