@repzo/widget-react-native 1.3.1 → 1.5.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 (240) hide show
  1. package/CHANGELOG.md +173 -5
  2. package/README.md +99 -13
  3. package/lib/commonjs/api/client.js +125 -28
  4. package/lib/commonjs/api/client.js.map +1 -1
  5. package/lib/commonjs/attachments/file-size.js +52 -0
  6. package/lib/commonjs/attachments/file-size.js.map +1 -0
  7. package/lib/commonjs/attachments/local-previews.js +40 -0
  8. package/lib/commonjs/attachments/local-previews.js.map +1 -0
  9. package/lib/commonjs/attachments/pickers.js +20 -29
  10. package/lib/commonjs/attachments/pickers.js.map +1 -1
  11. package/lib/commonjs/i18n.js +22 -3
  12. package/lib/commonjs/i18n.js.map +1 -1
  13. package/lib/commonjs/index.js.map +1 -1
  14. package/lib/commonjs/locales.js +12 -3
  15. package/lib/commonjs/locales.js.map +1 -1
  16. package/lib/commonjs/state/user-update-queue.js +168 -0
  17. package/lib/commonjs/state/user-update-queue.js.map +1 -0
  18. package/lib/commonjs/state/widget-store.js +303 -33
  19. package/lib/commonjs/state/widget-store.js.map +1 -1
  20. package/lib/commonjs/storage.js +3 -1
  21. package/lib/commonjs/storage.js.map +1 -1
  22. package/lib/commonjs/types.js.map +1 -1
  23. package/lib/commonjs/ui/WidgetProvider.js +33 -13
  24. package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
  25. package/lib/commonjs/ui/components/AttachmentChips.js +109 -40
  26. package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -1
  27. package/lib/commonjs/ui/components/ConversationRow.js +65 -10
  28. package/lib/commonjs/ui/components/ConversationRow.js.map +1 -1
  29. package/lib/commonjs/ui/components/ImageViewer.js +229 -24
  30. package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
  31. package/lib/commonjs/ui/components/RatingCard.js +5 -2
  32. package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
  33. package/lib/commonjs/ui/components/ZoomableImage.js +58 -10
  34. package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -1
  35. package/lib/commonjs/ui/hooks/use-retained-state.js +39 -0
  36. package/lib/commonjs/ui/hooks/use-retained-state.js.map +1 -0
  37. package/lib/commonjs/ui/screens/chat/ChatView.js +651 -165
  38. package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
  39. package/lib/commonjs/ui/screens/chat/ChatView.styles.js +138 -1
  40. package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
  41. package/lib/commonjs/ui/screens/conversations/ConversationsList.js +1 -0
  42. package/lib/commonjs/ui/screens/conversations/ConversationsList.js.map +1 -1
  43. package/lib/commonjs/ui/screens/home/HomeView.js +38 -55
  44. package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -1
  45. package/lib/commonjs/ui/screens/home/HomeView.styles.js +36 -45
  46. package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -1
  47. package/lib/commonjs/ui/utils/html.js +13 -0
  48. package/lib/commonjs/ui/utils/html.js.map +1 -1
  49. package/lib/commonjs/ui/utils/image-load-queue.js +114 -0
  50. package/lib/commonjs/ui/utils/image-load-queue.js.map +1 -0
  51. package/lib/commonjs/ui/utils/inline-attachments.js +88 -0
  52. package/lib/commonjs/ui/utils/inline-attachments.js.map +1 -0
  53. package/lib/module/api/client.js +123 -27
  54. package/lib/module/api/client.js.map +1 -1
  55. package/lib/module/attachments/file-size.js +47 -0
  56. package/lib/module/attachments/file-size.js.map +1 -0
  57. package/lib/module/attachments/local-previews.js +33 -0
  58. package/lib/module/attachments/local-previews.js.map +1 -0
  59. package/lib/module/attachments/pickers.js +15 -25
  60. package/lib/module/attachments/pickers.js.map +1 -1
  61. package/lib/module/i18n.js +22 -3
  62. package/lib/module/i18n.js.map +1 -1
  63. package/lib/module/index.js.map +1 -1
  64. package/lib/module/locales.js +12 -3
  65. package/lib/module/locales.js.map +1 -1
  66. package/lib/module/state/user-update-queue.js +163 -0
  67. package/lib/module/state/user-update-queue.js.map +1 -0
  68. package/lib/module/state/widget-store.js +305 -35
  69. package/lib/module/state/widget-store.js.map +1 -1
  70. package/lib/module/storage.js +2 -0
  71. package/lib/module/storage.js.map +1 -1
  72. package/lib/module/types.js.map +1 -1
  73. package/lib/module/ui/WidgetProvider.js +33 -13
  74. package/lib/module/ui/WidgetProvider.js.map +1 -1
  75. package/lib/module/ui/components/AttachmentChips.js +111 -42
  76. package/lib/module/ui/components/AttachmentChips.js.map +1 -1
  77. package/lib/module/ui/components/ConversationRow.js +65 -10
  78. package/lib/module/ui/components/ConversationRow.js.map +1 -1
  79. package/lib/module/ui/components/ImageViewer.js +231 -26
  80. package/lib/module/ui/components/ImageViewer.js.map +1 -1
  81. package/lib/module/ui/components/RatingCard.js +5 -2
  82. package/lib/module/ui/components/RatingCard.js.map +1 -1
  83. package/lib/module/ui/components/ZoomableImage.js +58 -10
  84. package/lib/module/ui/components/ZoomableImage.js.map +1 -1
  85. package/lib/module/ui/hooks/use-retained-state.js +34 -0
  86. package/lib/module/ui/hooks/use-retained-state.js.map +1 -0
  87. package/lib/module/ui/screens/chat/ChatView.js +655 -169
  88. package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
  89. package/lib/module/ui/screens/chat/ChatView.styles.js +140 -2
  90. package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
  91. package/lib/module/ui/screens/conversations/ConversationsList.js +1 -0
  92. package/lib/module/ui/screens/conversations/ConversationsList.js.map +1 -1
  93. package/lib/module/ui/screens/home/HomeView.js +40 -57
  94. package/lib/module/ui/screens/home/HomeView.js.map +1 -1
  95. package/lib/module/ui/screens/home/HomeView.styles.js +36 -45
  96. package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -1
  97. package/lib/module/ui/utils/html.js +12 -0
  98. package/lib/module/ui/utils/html.js.map +1 -1
  99. package/lib/module/ui/utils/image-load-queue.js +109 -0
  100. package/lib/module/ui/utils/image-load-queue.js.map +1 -0
  101. package/lib/module/ui/utils/inline-attachments.js +85 -0
  102. package/lib/module/ui/utils/inline-attachments.js.map +1 -0
  103. package/lib/typescript/commonjs/api/client.d.ts +20 -6
  104. package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
  105. package/lib/typescript/commonjs/attachments/file-size.d.ts +15 -0
  106. package/lib/typescript/commonjs/attachments/file-size.d.ts.map +1 -0
  107. package/lib/typescript/commonjs/attachments/local-previews.d.ts +14 -0
  108. package/lib/typescript/commonjs/attachments/local-previews.d.ts.map +1 -0
  109. package/lib/typescript/commonjs/attachments/pickers.d.ts +15 -2
  110. package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/i18n.d.ts +12 -3
  112. package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/index.d.ts +5 -0
  114. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/locales.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/state/user-update-queue.d.ts +62 -0
  117. package/lib/typescript/commonjs/state/user-update-queue.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/state/widget-store.d.ts +44 -1
  119. package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
  120. package/lib/typescript/commonjs/storage.d.ts +2 -0
  121. package/lib/typescript/commonjs/storage.d.ts.map +1 -1
  122. package/lib/typescript/commonjs/types.d.ts +53 -0
  123. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  124. package/lib/typescript/commonjs/ui/WidgetProvider.d.ts.map +1 -1
  125. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts +13 -3
  126. package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -1
  127. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts +15 -1
  128. package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts.map +1 -1
  129. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +26 -6
  130. package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
  131. package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -1
  132. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +10 -1
  133. package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -1
  134. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts +11 -0
  135. package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts.map +1 -0
  136. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +4 -4
  137. package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
  138. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +144 -0
  139. package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  140. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts +26 -1
  141. package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  142. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  143. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  144. package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  145. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +7 -5
  146. package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -1
  147. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +30 -35
  148. package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  149. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts +5 -6
  150. package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts.map +1 -1
  151. package/lib/typescript/commonjs/ui/utils/html.d.ts +5 -0
  152. package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
  153. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts +44 -0
  154. package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts.map +1 -0
  155. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts +29 -0
  156. package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts.map +1 -0
  157. package/lib/typescript/module/api/client.d.ts +20 -6
  158. package/lib/typescript/module/api/client.d.ts.map +1 -1
  159. package/lib/typescript/module/attachments/file-size.d.ts +15 -0
  160. package/lib/typescript/module/attachments/file-size.d.ts.map +1 -0
  161. package/lib/typescript/module/attachments/local-previews.d.ts +14 -0
  162. package/lib/typescript/module/attachments/local-previews.d.ts.map +1 -0
  163. package/lib/typescript/module/attachments/pickers.d.ts +15 -2
  164. package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
  165. package/lib/typescript/module/i18n.d.ts +12 -3
  166. package/lib/typescript/module/i18n.d.ts.map +1 -1
  167. package/lib/typescript/module/index.d.ts +5 -0
  168. package/lib/typescript/module/index.d.ts.map +1 -1
  169. package/lib/typescript/module/locales.d.ts.map +1 -1
  170. package/lib/typescript/module/state/user-update-queue.d.ts +62 -0
  171. package/lib/typescript/module/state/user-update-queue.d.ts.map +1 -0
  172. package/lib/typescript/module/state/widget-store.d.ts +44 -1
  173. package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
  174. package/lib/typescript/module/storage.d.ts +2 -0
  175. package/lib/typescript/module/storage.d.ts.map +1 -1
  176. package/lib/typescript/module/types.d.ts +53 -0
  177. package/lib/typescript/module/types.d.ts.map +1 -1
  178. package/lib/typescript/module/ui/WidgetProvider.d.ts.map +1 -1
  179. package/lib/typescript/module/ui/components/AttachmentChips.d.ts +13 -3
  180. package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -1
  181. package/lib/typescript/module/ui/components/ConversationRow.d.ts +15 -1
  182. package/lib/typescript/module/ui/components/ConversationRow.d.ts.map +1 -1
  183. package/lib/typescript/module/ui/components/ImageViewer.d.ts +26 -6
  184. package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
  185. package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -1
  186. package/lib/typescript/module/ui/components/ZoomableImage.d.ts +10 -1
  187. package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -1
  188. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts +11 -0
  189. package/lib/typescript/module/ui/hooks/use-retained-state.d.ts.map +1 -0
  190. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +4 -4
  191. package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
  192. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +144 -0
  193. package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts.map +1 -1
  194. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts +26 -1
  195. package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
  196. package/lib/typescript/module/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
  197. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
  198. package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
  199. package/lib/typescript/module/ui/screens/home/HomeView.d.ts +7 -5
  200. package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -1
  201. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +30 -35
  202. package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -1
  203. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts +5 -6
  204. package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts.map +1 -1
  205. package/lib/typescript/module/ui/utils/html.d.ts +5 -0
  206. package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
  207. package/lib/typescript/module/ui/utils/image-load-queue.d.ts +44 -0
  208. package/lib/typescript/module/ui/utils/image-load-queue.d.ts.map +1 -0
  209. package/lib/typescript/module/ui/utils/inline-attachments.d.ts +29 -0
  210. package/lib/typescript/module/ui/utils/inline-attachments.d.ts.map +1 -0
  211. package/package.json +1 -1
  212. package/src/api/client.ts +149 -38
  213. package/src/attachments/file-size.ts +42 -0
  214. package/src/attachments/local-previews.ts +32 -0
  215. package/src/attachments/pickers.ts +23 -25
  216. package/src/i18n.ts +22 -3
  217. package/src/index.tsx +5 -0
  218. package/src/locales.ts +12 -3
  219. package/src/state/user-update-queue.ts +178 -0
  220. package/src/state/widget-store.ts +346 -51
  221. package/src/storage.ts +2 -0
  222. package/src/types.ts +53 -0
  223. package/src/ui/WidgetProvider.tsx +35 -8
  224. package/src/ui/components/AttachmentChips.tsx +100 -29
  225. package/src/ui/components/ConversationRow.tsx +90 -9
  226. package/src/ui/components/ImageViewer.tsx +242 -24
  227. package/src/ui/components/RatingCard.tsx +5 -2
  228. package/src/ui/components/ZoomableImage.tsx +58 -5
  229. package/src/ui/hooks/use-retained-state.ts +35 -0
  230. package/src/ui/screens/chat/ChatView.styles.ts +139 -1
  231. package/src/ui/screens/chat/ChatView.tsx +735 -198
  232. package/src/ui/screens/chat/ChatView.types.ts +26 -1
  233. package/src/ui/screens/conversations/ConversationsList.tsx +1 -0
  234. package/src/ui/screens/conversations/ConversationsList.types.ts +2 -0
  235. package/src/ui/screens/home/HomeView.styles.ts +36 -45
  236. package/src/ui/screens/home/HomeView.tsx +54 -70
  237. package/src/ui/screens/home/HomeView.types.ts +5 -6
  238. package/src/ui/utils/html.ts +26 -0
  239. package/src/ui/utils/image-load-queue.ts +108 -0
  240. package/src/ui/utils/inline-attachments.ts +71 -0
@@ -1,47 +1,176 @@
1
1
  /**
2
2
  * Full-screen viewer for image attachments, so a tap on a photo the agent sent
3
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).
4
+ * and double-tap zoom work on iOS and Android (see ZoomableImage). With
5
+ * several photos in one message, a sideways swipe moves between them.
5
6
  */
6
7
 
7
- import { useState } from 'react';
8
+ import { useLayoutEffect, useRef, useState } from 'react';
8
9
  import {
9
10
  ActivityIndicator,
11
+ Animated,
12
+ Image,
10
13
  Modal,
11
14
  Platform,
12
15
  Pressable,
13
16
  StyleSheet,
14
17
  Text,
15
18
  View,
19
+ type AccessibilityActionEvent,
16
20
  } from 'react-native';
17
21
 
22
+ import { formatLabel } from '../../i18n';
23
+ import { useReduceMotion } from '../hooks/use-reduce-motion';
18
24
  import { useWidgetInsets } from '../hooks/use-widget-insets';
19
25
  import { fontSize, palette, spacing } from '../styles/theme';
26
+ import { isRTL } from '../utils/rtl';
20
27
  import { isHttpUrl } from '../utils/safe-url';
21
28
  import { CloseIcon } from './icons';
22
- import { ZoomableImage } from './ZoomableImage';
29
+ import { ZoomableImage, type SwipeHandlers } from './ZoomableImage';
23
30
 
24
31
  export interface ImageViewerLabels {
25
32
  close: string;
26
33
  loadFailed: string;
27
34
  openExternally: string;
35
+ /** Screen-reader position in a message's photos; keeps `{current}` and `{total}`. */
36
+ position: string;
28
37
  }
29
38
 
39
+ export interface ViewerImage {
40
+ url: string;
41
+ name: string;
42
+ /**
43
+ * A download big enough to take its turn in the chat (Android's download
44
+ * pool, see `ImageLoadQueue`): it isn't fetched ahead of a swipe, only once
45
+ * it's the photo on screen.
46
+ */
47
+ large?: boolean;
48
+ /**
49
+ * Shown instead when `url` can't load: the stored copy behind a photo the
50
+ * visitor sent, whose copy on the phone the OS may have cleared.
51
+ */
52
+ fallbackUrl?: string;
53
+ }
54
+
55
+ /** The photos of one message, opened at the one tapped. */
56
+ export interface ViewerGallery {
57
+ images: ViewerImage[];
58
+ index: number;
59
+ }
60
+
61
+ /** A swipe changes photo past this share of the screen, or this fast (px/ms). */
62
+ const SWIPE_DISTANCE = 0.25;
63
+ const SWIPE_VELOCITY = 0.5;
64
+ /** Past the first or last photo the strip only gives this much. */
65
+ const EDGE_RESISTANCE = 0.25;
66
+
30
67
  interface ImageViewerProps {
31
- image: { url: string; name: string } | null;
68
+ gallery: ViewerGallery | null;
32
69
  labels: ImageViewerLabels;
33
70
  onClose: () => void;
34
71
  /** Escape hatch when the image can't render here (e.g. unsupported format). */
35
72
  onOpenExternally: (url: string) => void;
36
73
  }
37
74
 
38
- export function ImageViewer({ image, labels, onClose, onOpenExternally }: ImageViewerProps) {
75
+ export function ImageViewer({ gallery, labels, onClose, onOpenExternally }: ImageViewerProps) {
39
76
  const insets = useWidgetInsets();
40
- // Keyed by URL so opening another image starts from a clean loading state.
77
+ const reduceMotion = useReduceMotion();
78
+ // The photo on screen, for the gallery it belongs to: a new gallery opens at its own.
79
+ const [position, setPosition] = useState<{ of: ViewerGallery | null; index: number }>({
80
+ of: null,
81
+ index: 0,
82
+ });
83
+ const images = gallery?.images ?? [];
84
+ const index = position.of === gallery ? position.index : (gallery?.index ?? 0);
85
+ const image = images[index] ?? null;
86
+ // Photos whose own URL failed, now shown from their fallback.
87
+ const [fellBack, setFellBack] = useState<ReadonlySet<string>>(() => new Set());
88
+ const sourceOf = (photo: ViewerImage) =>
89
+ photo.fallbackUrl && fellBack.has(photo.url) ? photo.fallbackUrl : photo.url;
90
+ const source = image ? sourceOf(image) : null;
91
+ // Keyed by URL so each photo starts from a clean loading state.
41
92
  const [status, setStatus] = useState<{ url: string | null; state: 'loading' | 'ready' | 'failed' }>(
42
93
  { url: null, state: 'loading' }
43
94
  );
44
- const state = status.url === image?.url ? status.state : 'loading';
95
+ const state = status.url === source ? status.state : 'loading';
96
+ // False once there's nothing left to try, so the caller shows the failure.
97
+ const fallBack = (photo: ViewerImage): boolean => {
98
+ if (!photo.fallbackUrl || fellBack.has(photo.url)) return false;
99
+ setFellBack((current) => new Set(current).add(photo.url));
100
+ return true;
101
+ };
102
+ // Photos whose screen-size copy is drawn: no spinner over those while the full one decodes.
103
+ const [previewed, setPreviewed] = useState<ReadonlySet<string>>(() => new Set());
104
+ const markPreviewed = (url: string) =>
105
+ setPreviewed((current) => (current.has(url) ? current : new Set(current).add(url)));
106
+
107
+ const [width, setWidth] = useState(0);
108
+ // One page per photo, never reordered: a swipe slides the strip to the photo
109
+ // it lands on and leaves it there. (Re-centring three pages instead showed the
110
+ // wrong photo, then a black frame, for a moment on Android.)
111
+ const [offset] = useState(() => new Animated.Value(0));
112
+ const rtl = isRTL();
113
+ // Pages run left to right in every language; in Arabic the first photo is the rightmost.
114
+ const restingAt = (photo: number) => -(rtl ? images.length - 1 - photo : photo) * width;
115
+ const swiping = useRef(false);
116
+ // Closing the viewer mid-swipe stops the slide unfinished, so it never
117
+ // settles: the next gallery is placed afresh.
118
+ useLayoutEffect(() => {
119
+ swiping.current = false;
120
+ }, [gallery]);
121
+ // Places the strip when the viewer opens, the screen turns or the screen reader
122
+ // moves; after a swipe it is already there.
123
+ useLayoutEffect(() => {
124
+ if (!swiping.current) offset.setValue(restingAt(index));
125
+ });
126
+
127
+ // In Arabic the next photo is the one on the left.
128
+ const step = rtl ? -1 : 1;
129
+ const neighbour = (side: -1 | 1): number | null => {
130
+ const next = index + side * step;
131
+ return next >= 0 && next < images.length ? next : null;
132
+ };
133
+ const show = (next: number | null) => {
134
+ if (next !== null) setPosition({ of: gallery, index: next });
135
+ };
136
+
137
+ const swipe: SwipeHandlers = {
138
+ move: (dx) => {
139
+ swiping.current = true;
140
+ const resisted = neighbour(dx < 0 ? 1 : -1) === null ? dx * EDGE_RESISTANCE : dx;
141
+ offset.setValue(restingAt(index) + resisted);
142
+ },
143
+ end: (dx, vx) => {
144
+ const next = neighbour(dx < 0 ? 1 : -1);
145
+ const far =
146
+ Math.abs(dx) > width * SWIPE_DISTANCE ||
147
+ (Math.abs(vx) > SWIPE_VELOCITY && Math.sign(vx) === Math.sign(dx));
148
+ const target = next !== null && dx !== 0 && far ? next : index;
149
+ const toValue = restingAt(target);
150
+ // Switched as the finger lifts: the counter changes at once, and the full
151
+ // photo decodes while the strip is still sliding to it.
152
+ show(target === index ? null : target);
153
+ if (reduceMotion !== false) {
154
+ offset.setValue(toValue);
155
+ swiping.current = false;
156
+ return;
157
+ }
158
+ const animation =
159
+ target === index
160
+ ? Animated.spring(offset, { toValue, useNativeDriver: true, bounciness: 0, speed: 18 })
161
+ : Animated.timing(offset, { toValue, duration: 180, useNativeDriver: true });
162
+ // A swipe that starts before this one lands takes over the strip; it settles it.
163
+ animation.start(({ finished }) => {
164
+ if (finished) swiping.current = false;
165
+ });
166
+ },
167
+ };
168
+
169
+ const several = images.length > 1;
170
+ const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
171
+ if (event.nativeEvent.actionName === 'increment' && index < images.length - 1) show(index + 1);
172
+ if (event.nativeEvent.actionName === 'decrement' && index > 0) show(index - 1);
173
+ };
45
174
 
46
175
  return (
47
176
  <Modal
@@ -50,29 +179,88 @@ export function ImageViewer({ image, labels, onClose, onOpenExternally }: ImageV
50
179
  animationType="fade"
51
180
  statusBarTranslucent
52
181
  onRequestClose={onClose}>
53
- <View style={styles.backdrop} accessibilityViewIsModal>
54
- {image ? (
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
- />
182
+ <View
183
+ style={styles.backdrop}
184
+ accessibilityViewIsModal
185
+ onLayout={(event) => setWidth(event.nativeEvent.layout.width)}>
186
+ {image && width > 0 ? (
187
+ <Animated.View
188
+ style={[styles.strip, { width: width * images.length, transform: [{ translateX: offset }] }]}>
189
+ {(rtl ? [...images].reverse() : images).map((photo) => {
190
+ const page = images.indexOf(photo);
191
+ const centre = page === index;
192
+ const uri = sourceOf(photo);
193
+ // Only the photo on screen and the ones a swipe reveals are drawn.
194
+ const near = Math.abs(page - index) <= 1;
195
+ return (
196
+ <View
197
+ key={`${page}:${photo.url}`}
198
+ style={[styles.page, { width }]}
199
+ {...(centre && several
200
+ ? {
201
+ accessible: true,
202
+ accessibilityRole: 'adjustable' as const,
203
+ accessibilityLabel: photo.name,
204
+ accessibilityValue: {
205
+ text: formatLabel(labels.position, { current: index + 1, total: images.length }),
206
+ },
207
+ accessibilityActions: [{ name: 'increment' }, { name: 'decrement' }],
208
+ onAccessibilityAction: handleAccessibilityAction,
209
+ }
210
+ : null)}>
211
+ {!near ? null : photo.large ? (
212
+ centre ? null : <ActivityIndicator style={styles.layer} color={palette.white} />
213
+ ) : (
214
+ // Decoded at the screen's size, so it's ready mid-swipe, and shown
215
+ // until the full photo on top has decoded.
216
+ <Image
217
+ source={{ uri }}
218
+ style={[styles.layer, centre && state === 'ready' && styles.covered]}
219
+ resizeMode="contain"
220
+ resizeMethod="resize"
221
+ onLoad={() => markPreviewed(uri)}
222
+ onError={() => fallBack(photo)}
223
+ importantForAccessibility="no"
224
+ accessibilityIgnoresInvertColors
225
+ />
226
+ )}
227
+ {centre ? (
228
+ // Keyed by URL so each photo opens at 1×, not at the last one's zoom.
229
+ <View key={uri} style={styles.layer}>
230
+ <ZoomableImage
231
+ uri={uri}
232
+ accessibilityLabel={photo.name}
233
+ onLoad={() => setStatus({ url: uri, state: 'ready' })}
234
+ onError={() => {
235
+ if (!fallBack(photo)) setStatus({ url: uri, state: 'failed' });
236
+ }}
237
+ onSwipe={several ? swipe : undefined}
238
+ />
239
+ </View>
240
+ ) : null}
241
+ </View>
242
+ );
243
+ })}
244
+ </Animated.View>
63
245
  ) : null}
64
246
 
65
- {image && state === 'loading' ? (
66
- <ActivityIndicator style={StyleSheet.absoluteFill} color={palette.white} />
247
+ {/* Both cover the screen but let touches through, so a swipe still
248
+ moves on from a photo that is loading or failed. */}
249
+ {image && source && state === 'loading' && !previewed.has(source) ? (
250
+ <View style={StyleSheet.absoluteFill} pointerEvents="none">
251
+ <ActivityIndicator style={StyleSheet.absoluteFill} color={palette.white} />
252
+ </View>
67
253
  ) : null}
68
254
 
69
- {image && state === 'failed' ? (
70
- <View style={[StyleSheet.absoluteFill, styles.center, styles.failed]}>
71
- <Text style={styles.failedText}>{labels.loadFailed}</Text>
255
+ {image && source && state === 'failed' ? (
256
+ <View style={[StyleSheet.absoluteFill, styles.center, styles.failed]} pointerEvents="box-none">
257
+ <Text style={styles.failedText} pointerEvents="none">
258
+ {labels.loadFailed}
259
+ </Text>
72
260
  {/* A visitor's local photo has no web copy to open yet. */}
73
- {isHttpUrl(image.url) ? (
261
+ {isHttpUrl(source) ? (
74
262
  <Pressable
75
- onPress={() => onOpenExternally(image.url)}
263
+ onPress={() => onOpenExternally(source)}
76
264
  accessibilityRole="button"
77
265
  style={({ pressed }) => [styles.externalButton, pressed && styles.pressed]}>
78
266
  <Text style={styles.externalLabel}>{labels.openExternally}</Text>
@@ -85,6 +273,11 @@ export function ImageViewer({ image, labels, onClose, onOpenExternally }: ImageV
85
273
  <Text style={styles.title} numberOfLines={1}>
86
274
  {image?.name ?? ''}
87
275
  </Text>
276
+ {several ? (
277
+ <Text style={styles.counter} accessibilityElementsHidden importantForAccessibility="no">
278
+ {index + 1} / {images.length}
279
+ </Text>
280
+ ) : null}
88
281
  <Pressable
89
282
  onPress={onClose}
90
283
  accessibilityRole="button"
@@ -103,6 +296,26 @@ const styles = StyleSheet.create({
103
296
  backdrop: {
104
297
  flex: 1,
105
298
  backgroundColor: '#000000',
299
+ overflow: 'hidden',
300
+ },
301
+ // One screen per photo, laid out left to right in every language (see `restingAt`).
302
+ strip: {
303
+ position: 'absolute',
304
+ top: 0,
305
+ bottom: 0,
306
+ left: 0,
307
+ flexDirection: 'row',
308
+ direction: 'ltr',
309
+ },
310
+ page: {
311
+ height: '100%',
312
+ },
313
+ layer: {
314
+ ...StyleSheet.absoluteFillObject,
315
+ },
316
+ // Hidden, not removed: it shows again the moment its page stops being the middle one.
317
+ covered: {
318
+ opacity: 0,
106
319
  },
107
320
  center: {
108
321
  flexGrow: 1,
@@ -128,6 +341,11 @@ const styles = StyleSheet.create({
128
341
  fontSize: fontSize.body,
129
342
  fontWeight: '600',
130
343
  },
344
+ counter: {
345
+ color: 'rgba(255,255,255,0.8)',
346
+ fontSize: fontSize.meta,
347
+ fontVariant: ['tabular-nums'],
348
+ },
131
349
  closeButton: {
132
350
  width: 36,
133
351
  height: 36,
@@ -12,6 +12,7 @@ import { useState } from 'react';
12
12
  import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native';
13
13
 
14
14
  import type { ConversationRatingResult, RatingReason } from '../../types';
15
+ import { useRetainedState } from '../hooks/use-retained-state';
15
16
  import { contrastingTextColor, fontSize, palette, radius, spacing, withAlpha } from '../styles/theme';
16
17
 
17
18
  /** 1..5, worst to best — Intercom's five faces. */
@@ -102,12 +103,14 @@ export function RatingCard({
102
103
  const [rating, setRating] = useState<number | null>(initialRating);
103
104
  const [reason, setReason] = useState<string | null>(initialReason);
104
105
  const [comment, setComment] = useState<string | null>(initialComment);
105
- const [draft, setDraft] = useState('');
106
+ // Kept while the thread unmounts the card far off screen; the rating
107
+ // itself is saved on the message.
108
+ const [draft, setDraft] = useRetainedState(`${ratingId}:draft`, '');
106
109
  // The comment is optional: "Done" with nothing typed just closes the box —
107
110
  // the face (and reason) are already saved. A card that opens with a saved
108
111
  // rating starts closed too, so "Done" sticks when the thread is reopened;
109
112
  // tapping the chosen face opens the box again.
110
- const [finished, setFinished] = useState(initialRating != null);
113
+ const [finished, setFinished] = useRetainedState(`${ratingId}:finished`, initialRating != null);
111
114
  const [pending, setPending] = useState(false);
112
115
  const [closedReason, setClosedReason] = useState<'locked' | 'expired' | null>(initialComment ? 'locked' : null);
113
116
  const [failed, setFailed] = useState(false);
@@ -82,16 +82,27 @@ const centreOf = (touches: Touch[]) => ({
82
82
  const spreadOf = (touches: Touch[]) =>
83
83
  touches.length < 2 ? 0 : Math.hypot(touches[0].pageX - touches[1].pageX, touches[0].pageY - touches[1].pageY);
84
84
 
85
+ /** A sideways drag at 1×, which the viewer turns into moving between photos. */
86
+ export interface SwipeHandlers {
87
+ /** Follows the finger: how far it has moved sideways, in px. */
88
+ move: (dx: number) => void;
89
+ /** The finger lifted (or a second one landed, with 0s). */
90
+ end: (dx: number, vx: number) => void;
91
+ }
92
+
85
93
  export function ZoomableImage({
86
94
  uri,
87
95
  accessibilityLabel,
88
96
  onLoad,
89
97
  onError,
98
+ onSwipe,
90
99
  }: {
91
100
  uri: string;
92
101
  accessibilityLabel: string;
93
102
  onLoad: () => void;
94
103
  onError: () => void;
104
+ /** Absent: a drag at 1× leaves the image where it is. */
105
+ onSwipe?: SwipeHandlers;
95
106
  }) {
96
107
  const reduceMotion = useReduceMotion();
97
108
  const [scale] = useState(() => new Animated.Value(1));
@@ -112,8 +123,13 @@ export function ZoomableImage({
112
123
  lastTap: null as { at: number; x: number; y: number } | null,
113
124
  reduceMotion: false as boolean | null,
114
125
  native: false,
126
+ swipe: undefined as SwipeHandlers | undefined,
127
+ // A sideways drag at 1× is moving between photos, and how far it got.
128
+ swiping: false,
129
+ swipeDx: 0,
115
130
  }).current;
116
131
  g.reduceMotion = reduceMotion;
132
+ g.swipe = onSwipe;
117
133
 
118
134
  const boxRef = useRef<View>(null);
119
135
 
@@ -147,8 +163,24 @@ export function ZoomableImage({
147
163
  });
148
164
  const content = () => fittedSize(g.box, g.image);
149
165
 
166
+ // Measured again whenever fingers go down, not only on layout: a photo swiped
167
+ // to lays out while the strip is still sliding, so that first reading is off.
168
+ const measure = () => {
169
+ boxRef.current?.measureInWindow?.((left, top) => {
170
+ // Fingers already down were measured from the old spot: keep them where they are.
171
+ g.start.centre = { x: g.start.centre.x - (left - g.origin.x), y: g.start.centre.y - (top - g.origin.y) };
172
+ g.origin = { x: left, y: top };
173
+ });
174
+ };
175
+
150
176
  const begin = (touches: Touch[]) => {
151
177
  g.start = { view: g.view, centre: fromCentre(centreOf(touches)), spread: spreadOf(touches), count: touches.length };
178
+ measure();
179
+ };
180
+
181
+ const endSwipe = (dx: number, vx: number) => {
182
+ g.swiping = false;
183
+ g.swipe?.end(dx, vx);
152
184
  };
153
185
 
154
186
  const pan = useRef(
@@ -160,6 +192,7 @@ export function ZoomableImage({
160
192
  onPanResponderTerminationRequest: () => false,
161
193
  onPanResponderGrant: (event: GestureResponderEvent) => {
162
194
  g.moved = false;
195
+ g.swiping = false;
163
196
  g.grantedAt = Date.now();
164
197
  begin(event.nativeEvent.touches);
165
198
  },
@@ -172,6 +205,8 @@ export function ZoomableImage({
172
205
  g.moved = true;
173
206
  }
174
207
  const centre = fromCentre(centreOf(touches));
208
+ // A second finger turns a swipe into a pinch: the photo slides back.
209
+ if (touches.length > 1 && g.swiping) endSwipe(0, 0);
175
210
  if (touches.length > 1 && g.start.spread > 0) {
176
211
  // Past the limits while pinching, then springs back on release.
177
212
  const zoom = Math.min(MAX_ZOOM * 1.25, Math.max(0.75, (g.start.view.scale * spreadOf(touches)) / g.start.spread));
@@ -184,9 +219,26 @@ export function ZoomableImage({
184
219
  y: g.start.view.y + centre.y - g.start.centre.y,
185
220
  };
186
221
  show(clampView(dragged, g.box, content()), false);
222
+ } else if (g.swipe && touches.length === 1 && Math.abs(g.start.view.scale - 1) < 0.01) {
223
+ // At 1× a sideways drag moves between the photos instead. Measured
224
+ // from where this finger went down alone, not from a pinch before it;
225
+ // below 1× the finger does nothing and the photo springs back on release.
226
+ const dx = centre.x - g.start.centre.x;
227
+ const dy = centre.y - g.start.centre.y;
228
+ if (g.swiping || (Math.abs(dx) > TAP_SLOP && Math.abs(dx) > Math.abs(dy))) {
229
+ g.swiping = true;
230
+ g.swipeDx = dx;
231
+ g.swipe.move(dx);
232
+ }
187
233
  }
188
234
  },
189
- onPanResponderRelease: (event: GestureResponderEvent) => {
235
+ onPanResponderRelease: (event: GestureResponderEvent, gesture?: { vx: number }) => {
236
+ if (g.swiping) {
237
+ endSwipe(g.swipeDx, gesture?.vx ?? 0);
238
+ // A pinch earlier in the gesture may have nudged the photo: it settles too.
239
+ show(clampView(g.view, g.box, content()), true);
240
+ return;
241
+ }
190
242
  const now = Date.now();
191
243
  const { pageX, pageY } = event.nativeEvent;
192
244
  const isTap = !g.moved && now - g.grantedAt < DOUBLE_TAP_MS;
@@ -210,7 +262,10 @@ export function ZoomableImage({
210
262
  g.lastTap = isTap ? { at: now, x: pageX, y: pageY } : null;
211
263
  show(clampView(g.view, g.box, content()), true);
212
264
  },
213
- onPanResponderTerminate: () => show(clampView(g.view, g.box, content()), true),
265
+ onPanResponderTerminate: () => {
266
+ if (g.swiping) endSwipe(0, 0);
267
+ show(clampView(g.view, g.box, content()), true);
268
+ },
214
269
  })
215
270
  ).current;
216
271
 
@@ -218,9 +273,7 @@ export function ZoomableImage({
218
273
  const { width, height } = event.nativeEvent.layout;
219
274
  g.box = { width, height };
220
275
  goNative();
221
- boxRef.current?.measureInWindow?.((left, top) => {
222
- g.origin = { x: left, y: top };
223
- });
276
+ measure();
224
277
  };
225
278
 
226
279
  const handleLoad = (event: NativeSyntheticEvent<ImageLoadEventData>) => {
@@ -0,0 +1,35 @@
1
+ /**
2
+ * `useState` that outlives its component. The thread is a FlatList, which
3
+ * unmounts rows far off screen; a form the visitor half filled in, or a
4
+ * rating comment, must still be there when they scroll back. Kept in memory
5
+ * for the session, by a key unique to the message.
6
+ */
7
+
8
+ import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react';
9
+
10
+ const MAX_RETAINED = 100;
11
+ const retained = new Map<string, unknown>();
12
+
13
+ export function useRetainedState<T>(key: string, initial: T): [T, Dispatch<SetStateAction<T>>] {
14
+ const [value, setValue] = useState<T>(() => (retained.has(key) ? (retained.get(key) as T) : initial));
15
+ const mounted = useRef(false);
16
+
17
+ useEffect(() => {
18
+ // Nothing changed yet on mount: only what the visitor did is worth keeping.
19
+ if (!mounted.current) {
20
+ mounted.current = true;
21
+ return;
22
+ }
23
+ retained.delete(key);
24
+ retained.set(key, value);
25
+ // Oldest first: a Map iterates in insertion order.
26
+ if (retained.size > MAX_RETAINED) retained.delete(retained.keys().next().value as string);
27
+ }, [key, value]);
28
+
29
+ return [value, setValue];
30
+ }
31
+
32
+ /** Signing out forgets them: the next visitor on the phone starts clean. */
33
+ export function clearRetainedState(): void {
34
+ retained.clear();
35
+ }