@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.
- package/CHANGELOG.md +173 -5
- package/README.md +99 -13
- package/lib/commonjs/api/client.js +125 -28
- package/lib/commonjs/api/client.js.map +1 -1
- package/lib/commonjs/attachments/file-size.js +52 -0
- package/lib/commonjs/attachments/file-size.js.map +1 -0
- package/lib/commonjs/attachments/local-previews.js +40 -0
- package/lib/commonjs/attachments/local-previews.js.map +1 -0
- package/lib/commonjs/attachments/pickers.js +20 -29
- package/lib/commonjs/attachments/pickers.js.map +1 -1
- package/lib/commonjs/i18n.js +22 -3
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/locales.js +12 -3
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/user-update-queue.js +168 -0
- package/lib/commonjs/state/user-update-queue.js.map +1 -0
- package/lib/commonjs/state/widget-store.js +303 -33
- package/lib/commonjs/state/widget-store.js.map +1 -1
- package/lib/commonjs/storage.js +3 -1
- package/lib/commonjs/storage.js.map +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/commonjs/ui/WidgetProvider.js +33 -13
- package/lib/commonjs/ui/WidgetProvider.js.map +1 -1
- package/lib/commonjs/ui/components/AttachmentChips.js +109 -40
- package/lib/commonjs/ui/components/AttachmentChips.js.map +1 -1
- package/lib/commonjs/ui/components/ConversationRow.js +65 -10
- package/lib/commonjs/ui/components/ConversationRow.js.map +1 -1
- package/lib/commonjs/ui/components/ImageViewer.js +229 -24
- package/lib/commonjs/ui/components/ImageViewer.js.map +1 -1
- package/lib/commonjs/ui/components/RatingCard.js +5 -2
- package/lib/commonjs/ui/components/RatingCard.js.map +1 -1
- package/lib/commonjs/ui/components/ZoomableImage.js +58 -10
- package/lib/commonjs/ui/components/ZoomableImage.js.map +1 -1
- package/lib/commonjs/ui/hooks/use-retained-state.js +39 -0
- package/lib/commonjs/ui/hooks/use-retained-state.js.map +1 -0
- package/lib/commonjs/ui/screens/chat/ChatView.js +651 -165
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +138 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/commonjs/ui/screens/conversations/ConversationsList.js +1 -0
- package/lib/commonjs/ui/screens/conversations/ConversationsList.js.map +1 -1
- package/lib/commonjs/ui/screens/home/HomeView.js +38 -55
- package/lib/commonjs/ui/screens/home/HomeView.js.map +1 -1
- package/lib/commonjs/ui/screens/home/HomeView.styles.js +36 -45
- package/lib/commonjs/ui/screens/home/HomeView.styles.js.map +1 -1
- package/lib/commonjs/ui/utils/html.js +13 -0
- package/lib/commonjs/ui/utils/html.js.map +1 -1
- package/lib/commonjs/ui/utils/image-load-queue.js +114 -0
- package/lib/commonjs/ui/utils/image-load-queue.js.map +1 -0
- package/lib/commonjs/ui/utils/inline-attachments.js +88 -0
- package/lib/commonjs/ui/utils/inline-attachments.js.map +1 -0
- package/lib/module/api/client.js +123 -27
- package/lib/module/api/client.js.map +1 -1
- package/lib/module/attachments/file-size.js +47 -0
- package/lib/module/attachments/file-size.js.map +1 -0
- package/lib/module/attachments/local-previews.js +33 -0
- package/lib/module/attachments/local-previews.js.map +1 -0
- package/lib/module/attachments/pickers.js +15 -25
- package/lib/module/attachments/pickers.js.map +1 -1
- package/lib/module/i18n.js +22 -3
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/locales.js +12 -3
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/user-update-queue.js +163 -0
- package/lib/module/state/user-update-queue.js.map +1 -0
- package/lib/module/state/widget-store.js +305 -35
- package/lib/module/state/widget-store.js.map +1 -1
- package/lib/module/storage.js +2 -0
- package/lib/module/storage.js.map +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/module/ui/WidgetProvider.js +33 -13
- package/lib/module/ui/WidgetProvider.js.map +1 -1
- package/lib/module/ui/components/AttachmentChips.js +111 -42
- package/lib/module/ui/components/AttachmentChips.js.map +1 -1
- package/lib/module/ui/components/ConversationRow.js +65 -10
- package/lib/module/ui/components/ConversationRow.js.map +1 -1
- package/lib/module/ui/components/ImageViewer.js +231 -26
- package/lib/module/ui/components/ImageViewer.js.map +1 -1
- package/lib/module/ui/components/RatingCard.js +5 -2
- package/lib/module/ui/components/RatingCard.js.map +1 -1
- package/lib/module/ui/components/ZoomableImage.js +58 -10
- package/lib/module/ui/components/ZoomableImage.js.map +1 -1
- package/lib/module/ui/hooks/use-retained-state.js +34 -0
- package/lib/module/ui/hooks/use-retained-state.js.map +1 -0
- package/lib/module/ui/screens/chat/ChatView.js +655 -169
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +140 -2
- package/lib/module/ui/screens/chat/ChatView.styles.js.map +1 -1
- package/lib/module/ui/screens/conversations/ConversationsList.js +1 -0
- package/lib/module/ui/screens/conversations/ConversationsList.js.map +1 -1
- package/lib/module/ui/screens/home/HomeView.js +40 -57
- package/lib/module/ui/screens/home/HomeView.js.map +1 -1
- package/lib/module/ui/screens/home/HomeView.styles.js +36 -45
- package/lib/module/ui/screens/home/HomeView.styles.js.map +1 -1
- package/lib/module/ui/utils/html.js +12 -0
- package/lib/module/ui/utils/html.js.map +1 -1
- package/lib/module/ui/utils/image-load-queue.js +109 -0
- package/lib/module/ui/utils/image-load-queue.js.map +1 -0
- package/lib/module/ui/utils/inline-attachments.js +85 -0
- package/lib/module/ui/utils/inline-attachments.js.map +1 -0
- package/lib/typescript/commonjs/api/client.d.ts +20 -6
- package/lib/typescript/commonjs/api/client.d.ts.map +1 -1
- package/lib/typescript/commonjs/attachments/file-size.d.ts +15 -0
- package/lib/typescript/commonjs/attachments/file-size.d.ts.map +1 -0
- package/lib/typescript/commonjs/attachments/local-previews.d.ts +14 -0
- package/lib/typescript/commonjs/attachments/local-previews.d.ts.map +1 -0
- package/lib/typescript/commonjs/attachments/pickers.d.ts +15 -2
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +12 -3
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +5 -0
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/user-update-queue.d.ts +62 -0
- package/lib/typescript/commonjs/state/user-update-queue.d.ts.map +1 -0
- package/lib/typescript/commonjs/state/widget-store.d.ts +44 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/commonjs/storage.d.ts +2 -0
- package/lib/typescript/commonjs/storage.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +53 -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/AttachmentChips.d.ts +13 -3
- package/lib/typescript/commonjs/ui/components/AttachmentChips.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts +15 -1
- package/lib/typescript/commonjs/ui/components/ConversationRow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts +26 -6
- package/lib/typescript/commonjs/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/RatingCard.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts +10 -1
- package/lib/typescript/commonjs/ui/components/ZoomableImage.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts +11 -0
- package/lib/typescript/commonjs/ui/hooks/use-retained-state.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts +4 -4
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.styles.d.ts +144 -0
- 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 +26 -1
- package/lib/typescript/commonjs/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
- package/lib/typescript/commonjs/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts +7 -5
- package/lib/typescript/commonjs/ui/screens/home/HomeView.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts +30 -35
- package/lib/typescript/commonjs/ui/screens/home/HomeView.styles.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts +5 -6
- package/lib/typescript/commonjs/ui/screens/home/HomeView.types.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/html.d.ts +5 -0
- package/lib/typescript/commonjs/ui/utils/html.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts +44 -0
- package/lib/typescript/commonjs/ui/utils/image-load-queue.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts +29 -0
- package/lib/typescript/commonjs/ui/utils/inline-attachments.d.ts.map +1 -0
- package/lib/typescript/module/api/client.d.ts +20 -6
- package/lib/typescript/module/api/client.d.ts.map +1 -1
- package/lib/typescript/module/attachments/file-size.d.ts +15 -0
- package/lib/typescript/module/attachments/file-size.d.ts.map +1 -0
- package/lib/typescript/module/attachments/local-previews.d.ts +14 -0
- package/lib/typescript/module/attachments/local-previews.d.ts.map +1 -0
- package/lib/typescript/module/attachments/pickers.d.ts +15 -2
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +12 -3
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +5 -0
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/user-update-queue.d.ts +62 -0
- package/lib/typescript/module/state/user-update-queue.d.ts.map +1 -0
- package/lib/typescript/module/state/widget-store.d.ts +44 -1
- package/lib/typescript/module/state/widget-store.d.ts.map +1 -1
- package/lib/typescript/module/storage.d.ts +2 -0
- package/lib/typescript/module/storage.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +53 -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/AttachmentChips.d.ts +13 -3
- package/lib/typescript/module/ui/components/AttachmentChips.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ConversationRow.d.ts +15 -1
- package/lib/typescript/module/ui/components/ConversationRow.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ImageViewer.d.ts +26 -6
- package/lib/typescript/module/ui/components/ImageViewer.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/RatingCard.d.ts.map +1 -1
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts +10 -1
- package/lib/typescript/module/ui/components/ZoomableImage.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/use-retained-state.d.ts +11 -0
- package/lib/typescript/module/ui/hooks/use-retained-state.d.ts.map +1 -0
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts +4 -4
- package/lib/typescript/module/ui/screens/chat/ChatView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.styles.d.ts +144 -0
- 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 +26 -1
- package/lib/typescript/module/ui/screens/chat/ChatView.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/conversations/ConversationsList.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts +2 -0
- package/lib/typescript/module/ui/screens/conversations/ConversationsList.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/home/HomeView.d.ts +7 -5
- package/lib/typescript/module/ui/screens/home/HomeView.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts +30 -35
- package/lib/typescript/module/ui/screens/home/HomeView.styles.d.ts.map +1 -1
- package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts +5 -6
- package/lib/typescript/module/ui/screens/home/HomeView.types.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/html.d.ts +5 -0
- package/lib/typescript/module/ui/utils/html.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/image-load-queue.d.ts +44 -0
- package/lib/typescript/module/ui/utils/image-load-queue.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/inline-attachments.d.ts +29 -0
- package/lib/typescript/module/ui/utils/inline-attachments.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/api/client.ts +149 -38
- package/src/attachments/file-size.ts +42 -0
- package/src/attachments/local-previews.ts +32 -0
- package/src/attachments/pickers.ts +23 -25
- package/src/i18n.ts +22 -3
- package/src/index.tsx +5 -0
- package/src/locales.ts +12 -3
- package/src/state/user-update-queue.ts +178 -0
- package/src/state/widget-store.ts +346 -51
- package/src/storage.ts +2 -0
- package/src/types.ts +53 -0
- package/src/ui/WidgetProvider.tsx +35 -8
- package/src/ui/components/AttachmentChips.tsx +100 -29
- package/src/ui/components/ConversationRow.tsx +90 -9
- package/src/ui/components/ImageViewer.tsx +242 -24
- package/src/ui/components/RatingCard.tsx +5 -2
- package/src/ui/components/ZoomableImage.tsx +58 -5
- package/src/ui/hooks/use-retained-state.ts +35 -0
- package/src/ui/screens/chat/ChatView.styles.ts +139 -1
- package/src/ui/screens/chat/ChatView.tsx +735 -198
- package/src/ui/screens/chat/ChatView.types.ts +26 -1
- package/src/ui/screens/conversations/ConversationsList.tsx +1 -0
- package/src/ui/screens/conversations/ConversationsList.types.ts +2 -0
- package/src/ui/screens/home/HomeView.styles.ts +36 -45
- package/src/ui/screens/home/HomeView.tsx +54 -70
- package/src/ui/screens/home/HomeView.types.ts +5 -6
- package/src/ui/utils/html.ts +26 -0
- package/src/ui/utils/image-load-queue.ts +108 -0
- 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
|
-
|
|
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({
|
|
75
|
+
export function ImageViewer({ gallery, labels, onClose, onOpenExternally }: ImageViewerProps) {
|
|
39
76
|
const insets = useWidgetInsets();
|
|
40
|
-
|
|
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 ===
|
|
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
|
|
54
|
-
{
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
{
|
|
66
|
-
|
|
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}>
|
|
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(
|
|
261
|
+
{isHttpUrl(source) ? (
|
|
74
262
|
<Pressable
|
|
75
|
-
onPress={() => onOpenExternally(
|
|
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
|
-
|
|
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] =
|
|
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: () =>
|
|
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
|
-
|
|
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
|
+
}
|