@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
|
@@ -2,16 +2,28 @@
|
|
|
2
2
|
* Chat thread — message list, typing indicator and composer.
|
|
3
3
|
* Port of `client/src/widget/components/ChatView.tsx`.
|
|
4
4
|
*
|
|
5
|
-
* Mobile differences:
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* Mobile differences: the thread is an inverted list, so it opens on the
|
|
6
|
+
* newest message and older pages load as the visitor scrolls up, and the
|
|
7
|
+
* attach button only shows when the host app has a picker to open (see
|
|
8
8
|
* `attachments/pickers.ts`).
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { Fragment, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
12
11
|
import {
|
|
12
|
+
createContext,
|
|
13
|
+
memo,
|
|
14
|
+
useCallback,
|
|
15
|
+
useContext,
|
|
16
|
+
useEffect,
|
|
17
|
+
useMemo,
|
|
18
|
+
useRef,
|
|
19
|
+
useState,
|
|
20
|
+
useSyncExternalStore,
|
|
21
|
+
} from 'react';
|
|
22
|
+
import {
|
|
23
|
+
ActivityIndicator,
|
|
13
24
|
Animated,
|
|
14
25
|
Easing,
|
|
26
|
+
FlatList,
|
|
15
27
|
Image,
|
|
16
28
|
Platform,
|
|
17
29
|
Pressable,
|
|
@@ -20,16 +32,25 @@ import {
|
|
|
20
32
|
TextInput,
|
|
21
33
|
View,
|
|
22
34
|
} from 'react-native';
|
|
23
|
-
import type {
|
|
35
|
+
import type {
|
|
36
|
+
ListRenderItem,
|
|
37
|
+
NativeScrollEvent,
|
|
38
|
+
NativeSyntheticEvent,
|
|
39
|
+
StyleProp,
|
|
40
|
+
ViewStyle,
|
|
41
|
+
ViewToken,
|
|
42
|
+
} from 'react-native';
|
|
24
43
|
|
|
25
44
|
import {
|
|
26
45
|
formatAgentUiActionMessage,
|
|
27
46
|
getAgentUiBlocks,
|
|
28
47
|
getProcedureForm,
|
|
29
48
|
} from '../../../agent-ui-blocks';
|
|
49
|
+
import { forgetLocalPreview, localPreviewFor } from '../../../attachments/local-previews';
|
|
30
50
|
import {
|
|
31
51
|
MAX_ATTACHMENTS,
|
|
32
52
|
isImageAttachment,
|
|
53
|
+
uploadsOnPick,
|
|
33
54
|
type AttachmentProblem,
|
|
34
55
|
type DraftAttachment,
|
|
35
56
|
} from '../../../attachments/pickers';
|
|
@@ -45,7 +66,7 @@ import { AgentUiBlocks } from '../../components/AgentUiBlocks';
|
|
|
45
66
|
import { AttachmentChips } from '../../components/AttachmentChips';
|
|
46
67
|
import { AttachmentMenu, showNativeAttachmentMenu } from '../../components/AttachmentMenu';
|
|
47
68
|
import { Avatar } from '../../components/Avatar';
|
|
48
|
-
import { ImageViewer } from '../../components/ImageViewer';
|
|
69
|
+
import { ImageViewer, type ViewerGallery } from '../../components/ImageViewer';
|
|
49
70
|
import { getRatingRequest, RatingCard } from '../../components/RatingCard';
|
|
50
71
|
import { WidgetErrorBoundary } from '../../components/ErrorBoundary';
|
|
51
72
|
import { GradientBackground } from '../../components/Gradient';
|
|
@@ -53,20 +74,85 @@ import { AlertIcon, ArrowUpIcon, ChatOutlineIcon, ChevronIcon, ClockIcon, PlusIc
|
|
|
53
74
|
import { TeamAvatars } from '../../components/TeamAvatars';
|
|
54
75
|
import { Markdown } from '../../components/Markdown';
|
|
55
76
|
import { useReduceMotion } from '../../hooks/use-reduce-motion';
|
|
77
|
+
import { useRetainedState } from '../../hooks/use-retained-state';
|
|
56
78
|
import { useTopInset } from '../../hooks/use-top-inset';
|
|
57
79
|
import { globalStyles } from '../../styles/global';
|
|
58
80
|
import { contrastingTextColor, mixHex, palette, spacing, withAlpha } from '../../styles/theme';
|
|
59
81
|
import { canRetryMessage, formatClockTime, formatDayLabel, isSameDay } from '../../utils/conversations';
|
|
60
82
|
import { stripHtml } from '../../utils/html';
|
|
83
|
+
import { ImageLoadQueue } from '../../utils/image-load-queue';
|
|
84
|
+
import { splitInlineAttachments } from '../../utils/inline-attachments';
|
|
61
85
|
import { isHttpUrl, openExternalUrl } from '../../utils/safe-url';
|
|
62
86
|
import { isPlayableAudio } from '../../../audio/voice';
|
|
63
87
|
import { VoiceNote, type VoiceNoteLabels } from '../../components/VoiceNote';
|
|
64
|
-
import { FileCard } from '../../components/FileCard';
|
|
88
|
+
import { FileCard, fileTypeLabel } from '../../components/FileCard';
|
|
65
89
|
import { MessagesSkeleton } from '../../components/MessagesSkeleton';
|
|
66
90
|
import { backDirection, isRTL } from '../../utils/rtl';
|
|
67
91
|
import { styles } from './ChatView.styles';
|
|
68
92
|
import type { ChatLabels, ChatViewProps } from './ChatView.types';
|
|
69
93
|
|
|
94
|
+
/** A message with what its place in the thread decides about it. */
|
|
95
|
+
interface ThreadRow {
|
|
96
|
+
message: Message;
|
|
97
|
+
/** First message of its day: a day separator goes above it. */
|
|
98
|
+
startsDay: boolean;
|
|
99
|
+
/** First of a teammate's run: their name goes above it. */
|
|
100
|
+
startsRun: boolean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const rowKey = (row: ThreadRow) => row.message.id;
|
|
104
|
+
|
|
105
|
+
/** How far above the newest message the visitor scrolls before "jump to latest" shows. */
|
|
106
|
+
const JUMP_TO_LATEST_OFFSET = 240;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Workstation's refusal of a message that names an expired upload. Only this
|
|
110
|
+
* one puts the files up again: a deleted conversation or a rejected message
|
|
111
|
+
* would refuse the fresh uploads too.
|
|
112
|
+
*/
|
|
113
|
+
const UPLOAD_EXPIRED_STATUS = 410;
|
|
114
|
+
/**
|
|
115
|
+
* An upload this close to expiring goes up again with the message instead of
|
|
116
|
+
* being named by its token, which could lapse before the message arrives.
|
|
117
|
+
*/
|
|
118
|
+
const UPLOAD_EXPIRY_MARGIN_MS = 10 * 60 * 1000;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Photos above this take turns to download, two at a time; smaller ones load
|
|
122
|
+
* at once. Two 50 MB photos stay well inside Android's download pool (see
|
|
123
|
+
* `ImageLoadQueue`), and ordinary photos never wait.
|
|
124
|
+
*/
|
|
125
|
+
const LARGE_IMAGE_BYTES = 10_000_000;
|
|
126
|
+
const LARGE_IMAGE_LOADS_AT_ONCE = 2;
|
|
127
|
+
/** A photo that loads quicker than this never shows its loader. */
|
|
128
|
+
const PHOTO_LOADER_DELAY_MS = 200;
|
|
129
|
+
|
|
130
|
+
/** A photo download that waits its turn; the phone's own copy never does. */
|
|
131
|
+
const isLargeDownload = (url: string, size: number | undefined) =>
|
|
132
|
+
isHttpUrl(url) && (size ?? 0) > LARGE_IMAGE_BYTES;
|
|
133
|
+
|
|
134
|
+
const ImageQueueContext = createContext<ImageLoadQueue | null>(null);
|
|
135
|
+
const subscribeNowhere = () => () => {};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether an image may load yet, and the callback that tells the queue it
|
|
139
|
+
* has (or failed). Images the queue doesn't handle load right away.
|
|
140
|
+
*/
|
|
141
|
+
function useImageTurn(key: string, priority: number, queued: boolean): [boolean, () => void] {
|
|
142
|
+
const queue = useContext(ImageQueueContext);
|
|
143
|
+
const active = !!queue && queued;
|
|
144
|
+
useEffect(() => (active ? queue!.add(key, priority) : undefined), [active, queue, key, priority]);
|
|
145
|
+
const turn = useSyncExternalStore(queue ? queue.subscribe : subscribeNowhere, () =>
|
|
146
|
+
active ? queue!.canLoad(key) : true
|
|
147
|
+
);
|
|
148
|
+
const settled = useCallback(() => queue?.finish(key), [queue, key]);
|
|
149
|
+
return [turn, settled];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function ThreadGap() {
|
|
153
|
+
return <View style={styles.threadGap} />;
|
|
154
|
+
}
|
|
155
|
+
|
|
70
156
|
export function ChatView({
|
|
71
157
|
messages,
|
|
72
158
|
loading,
|
|
@@ -86,11 +172,13 @@ export function ChatView({
|
|
|
86
172
|
labels,
|
|
87
173
|
attachmentSources = [],
|
|
88
174
|
onPickAttachments,
|
|
175
|
+
onUploadAttachment,
|
|
89
176
|
onSendMessage,
|
|
90
177
|
onRetryMessage,
|
|
91
178
|
onIdentifyVisitor,
|
|
92
179
|
onRateConversation,
|
|
93
180
|
defaultLocale,
|
|
181
|
+
photoAlbums = true,
|
|
94
182
|
onLoadMore,
|
|
95
183
|
onTyping,
|
|
96
184
|
onBack,
|
|
@@ -100,31 +188,59 @@ export function ChatView({
|
|
|
100
188
|
const [composerFocused, setComposerFocused] = useState(false);
|
|
101
189
|
const [drafts, setDrafts] = useState<DraftAttachment[]>([]);
|
|
102
190
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
103
|
-
const [
|
|
191
|
+
const [viewerGallery, setViewerGallery] = useState<ViewerGallery | null>(null);
|
|
104
192
|
const [picking, setPicking] = useState(false);
|
|
105
193
|
// Only attachment sends hold the composer; text sends stay fire-and-forget.
|
|
106
194
|
const [sending, setSending] = useState(false);
|
|
107
195
|
const [notice, setNotice] = useState<string | null>(null);
|
|
108
196
|
const reduceMotion = useReduceMotion();
|
|
109
|
-
const
|
|
197
|
+
const listRef = useRef<FlatList<ThreadRow>>(null);
|
|
198
|
+
// One per thread: the screen remounts for each conversation.
|
|
199
|
+
const [largeImageQueue] = useState(() => new ImageLoadQueue(LARGE_IMAGE_LOADS_AT_ONCE));
|
|
110
200
|
const typingStopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
111
201
|
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
112
202
|
const pickerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
113
203
|
const mounted = useRef(false);
|
|
204
|
+
// Uploads in flight, by draft id. A removed chip cancels its own; a retry
|
|
205
|
+
// replaces it, and the replaced attempt's result is dropped.
|
|
206
|
+
const uploads = useRef(new Map<string, { cancel: () => void }>());
|
|
207
|
+
// Files already put up again after a refused send; never twice, so a
|
|
208
|
+
// refusal uploads can't fix costs one round, not one per tap on Send.
|
|
209
|
+
const reuploaded = useRef(new Set<string>());
|
|
210
|
+
// Set as the visitor sends: the list scrolls down once their bubble is in
|
|
211
|
+
// it. Scrolled up, scrolling first let the list hold its place as the new
|
|
212
|
+
// row arrived, which could cut the scroll short.
|
|
213
|
+
const followSend = useRef(false);
|
|
214
|
+
// Set by a tap on Send until the composer re-renders. Taps that pile up
|
|
215
|
+
// while the JS thread is busy (a slow phone, the host app at work) are all
|
|
216
|
+
// handled before React re-renders, so each still sees the typed text and
|
|
217
|
+
// would send it again. A ref changes at once: only the first goes out.
|
|
218
|
+
const sendTapped = useRef(false);
|
|
219
|
+
|
|
220
|
+
// After every render, and after the send button has taken this render's
|
|
221
|
+
// handler (its own effect runs first): from here a tap sees the composer as
|
|
222
|
+
// the last send left it.
|
|
223
|
+
useEffect(() => {
|
|
224
|
+
sendTapped.current = false;
|
|
225
|
+
});
|
|
114
226
|
|
|
115
227
|
useEffect(() => {
|
|
116
228
|
if (prefillText) setInput(prefillText);
|
|
117
229
|
}, [prefillText]);
|
|
118
230
|
|
|
119
231
|
// The provider keys this screen by the store's thread generation. Pending
|
|
120
|
-
// picks and sends belong to this mount, even before a draft gets a
|
|
232
|
+
// picks, uploads and sends belong to this mount, even before a draft gets a
|
|
233
|
+
// server ID.
|
|
121
234
|
useEffect(() => {
|
|
122
235
|
mounted.current = true;
|
|
236
|
+
const inFlight = uploads.current;
|
|
123
237
|
return () => {
|
|
124
238
|
mounted.current = false;
|
|
125
239
|
if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
|
|
126
240
|
if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
|
127
241
|
if (pickerTimer.current) clearTimeout(pickerTimer.current);
|
|
242
|
+
inFlight.forEach((upload) => upload.cancel());
|
|
243
|
+
inFlight.clear();
|
|
128
244
|
};
|
|
129
245
|
}, []);
|
|
130
246
|
|
|
@@ -134,51 +250,57 @@ export function ChatView({
|
|
|
134
250
|
if (message) noticeTimer.current = setTimeout(() => setNotice(null), 5000);
|
|
135
251
|
}, []);
|
|
136
252
|
|
|
253
|
+
// The list is inverted, so offset 0 is the newest message: the thread opens
|
|
254
|
+
// there without scrolling, stays there as the keyboard rises or replies
|
|
255
|
+
// arrive, and older pages land above it without moving the reader.
|
|
137
256
|
const scrollToBottom = useCallback((animated = true) => {
|
|
138
|
-
|
|
257
|
+
listRef.current?.scrollToOffset({ offset: 0, animated });
|
|
139
258
|
}, []);
|
|
140
259
|
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// handler below covers later growth (loads, images, incoming messages).
|
|
144
|
-
useEffect(() => {
|
|
145
|
-
const frame = requestAnimationFrame(() => scrollToBottom(false));
|
|
146
|
-
return () => cancelAnimationFrame(frame);
|
|
147
|
-
}, [scrollToBottom]);
|
|
148
|
-
|
|
149
|
-
// "Load earlier" PREPENDS history; skipping one auto-scroll keeps the reader
|
|
150
|
-
// anchored where they were instead of yanking them back to the bottom.
|
|
151
|
-
const skipNextAutoScroll = useRef(false);
|
|
260
|
+
// Fires as the visitor nears the oldest message; the row up there is the
|
|
261
|
+
// fallback when a page fails to load.
|
|
152
262
|
const handleLoadEarlier = useCallback(() => {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}, [onLoadMore]);
|
|
263
|
+
if (hasMoreMessages && !loading) onLoadMore();
|
|
264
|
+
}, [hasMoreMessages, loading, onLoadMore]);
|
|
156
265
|
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const handleViewportLayout = useCallback(
|
|
171
|
-
(event: LayoutChangeEvent) => {
|
|
172
|
-
const { height } = event.nativeEvent.layout;
|
|
173
|
-
const shrank = viewportHeight.current - height > 1;
|
|
174
|
-
viewportHeight.current = height;
|
|
175
|
-
if (shrank) scrollToBottom(false);
|
|
176
|
-
},
|
|
177
|
-
[scrollToBottom]
|
|
266
|
+
const showAttach = attachmentSources.length > 0 && !!onPickAttachments;
|
|
267
|
+
// Send waits until every picked file is up, so the message goes out at once.
|
|
268
|
+
const uploadsPending = drafts.some(
|
|
269
|
+
(draft) => draft.uploadStatus === 'uploading' || draft.uploadStatus === 'failed'
|
|
270
|
+
);
|
|
271
|
+
const canSend = !sending && !uploadsPending && (input.trim().length > 0 || drafts.length > 0);
|
|
272
|
+
|
|
273
|
+
const updateDraft = useCallback(
|
|
274
|
+
(id: string, patch: Partial<DraftAttachment>) =>
|
|
275
|
+
setDrafts((current) =>
|
|
276
|
+
current.map((draft) => (draft.id === id ? { ...draft, ...patch } : draft))
|
|
277
|
+
),
|
|
278
|
+
[]
|
|
178
279
|
);
|
|
179
280
|
|
|
180
|
-
|
|
181
|
-
const
|
|
281
|
+
/** Puts a picked local file up while the visitor writes; inline files need nothing. */
|
|
282
|
+
const startUpload = (draft: DraftAttachment) => {
|
|
283
|
+
if (!onUploadAttachment || !draft.fileUri) return;
|
|
284
|
+
uploads.current.get(draft.id)?.cancel();
|
|
285
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
286
|
+
const attempt = { cancel: () => controller?.abort() };
|
|
287
|
+
uploads.current.set(draft.id, attempt);
|
|
288
|
+
updateDraft(draft.id, { uploadStatus: 'uploading', uploadToken: undefined, uploadExpiresAt: undefined });
|
|
289
|
+
onUploadAttachment(draft, controller?.signal).then(
|
|
290
|
+
({ uploadToken, expiresAt }) => {
|
|
291
|
+
if (!mounted.current || uploads.current.get(draft.id) !== attempt) return;
|
|
292
|
+
uploads.current.delete(draft.id);
|
|
293
|
+
updateDraft(draft.id, { uploadStatus: 'uploaded', uploadToken, uploadExpiresAt: expiresAt });
|
|
294
|
+
},
|
|
295
|
+
(error: unknown) => {
|
|
296
|
+
if (!mounted.current || uploads.current.get(draft.id) !== attempt) return;
|
|
297
|
+
console.warn('[repzo-widget] attachment upload failed', error);
|
|
298
|
+
uploads.current.delete(draft.id);
|
|
299
|
+
updateDraft(draft.id, { uploadStatus: 'failed' });
|
|
300
|
+
showNotice(labels.attach.uploadFailedNotice);
|
|
301
|
+
}
|
|
302
|
+
);
|
|
303
|
+
};
|
|
182
304
|
|
|
183
305
|
const problemLabel = (problem: AttachmentProblem | null): string | null => {
|
|
184
306
|
switch (problem) {
|
|
@@ -205,6 +327,8 @@ export function ChatView({
|
|
|
205
327
|
if (!mounted.current) return;
|
|
206
328
|
if (result.added.length > 0) {
|
|
207
329
|
setDrafts((current) => [...current, ...result.added].slice(0, MAX_ATTACHMENTS));
|
|
330
|
+
// Opted out, the files go up when the visitor taps Send instead.
|
|
331
|
+
if (uploadsOnPick()) result.added.forEach(startUpload);
|
|
208
332
|
}
|
|
209
333
|
showNotice(problemLabel(result.problem));
|
|
210
334
|
} finally {
|
|
@@ -249,11 +373,12 @@ export function ChatView({
|
|
|
249
373
|
|
|
250
374
|
const handleSend = () => {
|
|
251
375
|
const trimmed = input.trim();
|
|
252
|
-
if (!canSend) return;
|
|
376
|
+
if (!canSend || sendTapped.current) return;
|
|
377
|
+
sendTapped.current = true;
|
|
253
378
|
setInput('');
|
|
254
379
|
if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
|
|
255
380
|
onTyping(false);
|
|
256
|
-
|
|
381
|
+
followSend.current = true;
|
|
257
382
|
|
|
258
383
|
if (drafts.length === 0) {
|
|
259
384
|
// The failed bubble already surfaces a failure to the visitor.
|
|
@@ -262,6 +387,11 @@ export function ChatView({
|
|
|
262
387
|
}
|
|
263
388
|
|
|
264
389
|
const sent = drafts;
|
|
390
|
+
// Named by its token while that lasts; otherwise the file goes up again
|
|
391
|
+
// as the message is sent.
|
|
392
|
+
const sendBy = Date.now() + UPLOAD_EXPIRY_MARGIN_MS;
|
|
393
|
+
const stillUploaded = (draft: DraftAttachment) =>
|
|
394
|
+
!!draft.uploadToken && (draft.uploadExpiresAt ?? Infinity) > sendBy;
|
|
265
395
|
setSending(true);
|
|
266
396
|
showNotice(null);
|
|
267
397
|
onSendMessage(
|
|
@@ -270,6 +400,7 @@ export function ChatView({
|
|
|
270
400
|
name: draft.name,
|
|
271
401
|
url: draft.dataUrl,
|
|
272
402
|
fileUri: draft.fileUri,
|
|
403
|
+
uploadToken: stillUploaded(draft) ? draft.uploadToken : undefined,
|
|
273
404
|
contentType: draft.contentType,
|
|
274
405
|
size: draft.size,
|
|
275
406
|
previewUrl: draft.previewUri,
|
|
@@ -288,6 +419,17 @@ export function ChatView({
|
|
|
288
419
|
setInput((current) => current || trimmed);
|
|
289
420
|
const status = (error as { status?: unknown } | null)?.status;
|
|
290
421
|
showNotice(status === 413 ? labels.attach.tooLargeTogether : labels.failedToSend);
|
|
422
|
+
// An upload expired before this phone's clock said it would: put the
|
|
423
|
+
// files sent by token up again, once. After a lost connection the
|
|
424
|
+
// uploads still stand for the next try.
|
|
425
|
+
if (status === UPLOAD_EXPIRED_STATUS) {
|
|
426
|
+
sent
|
|
427
|
+
.filter((draft) => stillUploaded(draft) && !reuploaded.current.has(draft.id))
|
|
428
|
+
.forEach((draft) => {
|
|
429
|
+
reuploaded.current.add(draft.id);
|
|
430
|
+
startUpload(draft);
|
|
431
|
+
});
|
|
432
|
+
}
|
|
291
433
|
})
|
|
292
434
|
.finally(() => {
|
|
293
435
|
if (mounted.current) setSending(false);
|
|
@@ -316,10 +458,10 @@ export function ChatView({
|
|
|
316
458
|
phone: stringPayload(action.payload.phone),
|
|
317
459
|
});
|
|
318
460
|
}
|
|
461
|
+
followSend.current = true;
|
|
319
462
|
onSendMessage(formatAgentUiActionMessage(action), undefined, action).catch(() => {});
|
|
320
|
-
scrollToBottom();
|
|
321
463
|
},
|
|
322
|
-
[onIdentifyVisitor, onSendMessage
|
|
464
|
+
[onIdentifyVisitor, onSendMessage]
|
|
323
465
|
);
|
|
324
466
|
|
|
325
467
|
const handleSubmitProcedureForm = useCallback(
|
|
@@ -331,10 +473,10 @@ export function ChatView({
|
|
|
331
473
|
// A second failure shows on the bubble again.
|
|
332
474
|
const handleRetryMessage = useCallback(
|
|
333
475
|
(messageId: string) => {
|
|
476
|
+
followSend.current = true;
|
|
334
477
|
onRetryMessage?.(messageId).catch(() => {});
|
|
335
|
-
scrollToBottom();
|
|
336
478
|
},
|
|
337
|
-
[onRetryMessage
|
|
479
|
+
[onRetryMessage]
|
|
338
480
|
);
|
|
339
481
|
|
|
340
482
|
// Recomputed only when the list changes — never on a keystroke.
|
|
@@ -356,6 +498,169 @@ export function ChatView({
|
|
|
356
498
|
const headerGradient = [color, mixHex(color, '#000000', 0.16)];
|
|
357
499
|
const headerControl = { backgroundColor: withAlpha(headerTextColor, 0.16) };
|
|
358
500
|
|
|
501
|
+
// Newest first, for the inverted list; each row still compares itself with
|
|
502
|
+
// the message sent before it.
|
|
503
|
+
const rows = useMemo<ThreadRow[]>(
|
|
504
|
+
() =>
|
|
505
|
+
messages
|
|
506
|
+
.map((message, index) => {
|
|
507
|
+
const previous = index > 0 ? messages[index - 1] : undefined;
|
|
508
|
+
return {
|
|
509
|
+
message,
|
|
510
|
+
startsDay: !previous || !isSameDay(message.createdAt, previous.createdAt),
|
|
511
|
+
// Name only at the top of a run, the way every messenger does it.
|
|
512
|
+
startsRun:
|
|
513
|
+
!previous ||
|
|
514
|
+
previous.direction === 'inbound' ||
|
|
515
|
+
previous.messageType === 'system' ||
|
|
516
|
+
previous.senderName !== message.senderName,
|
|
517
|
+
};
|
|
518
|
+
})
|
|
519
|
+
.reverse(),
|
|
520
|
+
[messages]
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
useEffect(() => {
|
|
524
|
+
if (!followSend.current) return;
|
|
525
|
+
followSend.current = false;
|
|
526
|
+
scrollToBottom();
|
|
527
|
+
}, [rows, scrollToBottom]);
|
|
528
|
+
|
|
529
|
+
// The thread's message ids when the visitor scrolled away from the newest,
|
|
530
|
+
// null while it's in view. Teammate replies newer than all of them are
|
|
531
|
+
// counted on the button. A set, not the newest id alone: that may be the
|
|
532
|
+
// visitor's own bubble, whose temporary id changes once the server has it.
|
|
533
|
+
const [awayFrom, setAwayFrom] = useState<ReadonlySet<string> | null>(null);
|
|
534
|
+
const rowIds = useRef<string[]>([]);
|
|
535
|
+
useEffect(() => {
|
|
536
|
+
rowIds.current = rows.map((row) => row.message.id);
|
|
537
|
+
}, [rows]);
|
|
538
|
+
|
|
539
|
+
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
540
|
+
// Inverted: the offset is the distance up from the newest message.
|
|
541
|
+
const away = event.nativeEvent.contentOffset.y > JUMP_TO_LATEST_OFFSET;
|
|
542
|
+
setAwayFrom((current) => (away ? (current ?? new Set(rowIds.current)) : null));
|
|
543
|
+
}, []);
|
|
544
|
+
|
|
545
|
+
const unseenReplies = useMemo(() => {
|
|
546
|
+
if (!awayFrom) return 0;
|
|
547
|
+
let count = 0;
|
|
548
|
+
for (const { message } of rows) {
|
|
549
|
+
if (awayFrom.has(message.id)) break;
|
|
550
|
+
if (message.direction !== 'inbound' && message.messageType !== 'system') count += 1;
|
|
551
|
+
}
|
|
552
|
+
return count;
|
|
553
|
+
}, [awayFrom, rows]);
|
|
554
|
+
|
|
555
|
+
const handleJumpToLatest = useCallback(() => scrollToBottom(), [scrollToBottom]);
|
|
556
|
+
|
|
557
|
+
// Android only: it detaches rows scrolled out of view, where a large photo
|
|
558
|
+
// still downloading would never finish and keep its turn. Keyed as in usePhotoSource.
|
|
559
|
+
const handleViewableItemsChanged = useCallback(
|
|
560
|
+
({ viewableItems }: { viewableItems: Array<ViewToken<ThreadRow>> }) =>
|
|
561
|
+
largeImageQueue.setOnScreen(
|
|
562
|
+
viewableItems.flatMap(({ item }) =>
|
|
563
|
+
(item.message.attachments ?? []).map((attachment) => attachment.id || attachment.url)
|
|
564
|
+
)
|
|
565
|
+
),
|
|
566
|
+
[largeImageQueue]
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const renderRow = useCallback<ListRenderItem<ThreadRow>>(
|
|
570
|
+
({ item: { message, startsDay, startsRun } }) => {
|
|
571
|
+
const isAgentMessage = message.direction !== 'inbound' && message.messageType !== 'system';
|
|
572
|
+
return (
|
|
573
|
+
<View style={styles.threadRow}>
|
|
574
|
+
{startsDay ? (
|
|
575
|
+
<View style={styles.daySeparator}>
|
|
576
|
+
<View style={styles.dayLine} />
|
|
577
|
+
<Text style={styles.dayLabel}>
|
|
578
|
+
{formatDayLabel(message.createdAt, locale, labels)}
|
|
579
|
+
</Text>
|
|
580
|
+
<View style={styles.dayLine} />
|
|
581
|
+
</View>
|
|
582
|
+
) : null}
|
|
583
|
+
{/* One malformed message (agent blocks, markdown) drops out of
|
|
584
|
+
the thread instead of blanking the whole chat. */}
|
|
585
|
+
<WidgetErrorBoundary fallback={null}>
|
|
586
|
+
<MessageBubble
|
|
587
|
+
message={message}
|
|
588
|
+
color={color}
|
|
589
|
+
labels={labels}
|
|
590
|
+
onOpenImage={setViewerGallery}
|
|
591
|
+
onRateConversation={onRateConversation}
|
|
592
|
+
locales={locales}
|
|
593
|
+
senderLabel={
|
|
594
|
+
isAgentMessage && (startsRun || startsDay)
|
|
595
|
+
? message.senderName || agentName || labels.team
|
|
596
|
+
: null
|
|
597
|
+
}
|
|
598
|
+
timeLabel={formatClockTime(message.createdAt, locale)}
|
|
599
|
+
receiptLabel={
|
|
600
|
+
message.id === thread.lastVisitorMessageId
|
|
601
|
+
? receiptLabel(message, labels)
|
|
602
|
+
: undefined
|
|
603
|
+
}
|
|
604
|
+
onSubmitProcedureForm={
|
|
605
|
+
message.id === thread.procedureFormMessageId ? handleSubmitProcedureForm : undefined
|
|
606
|
+
}
|
|
607
|
+
onAgentUiAction={
|
|
608
|
+
thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined
|
|
609
|
+
}
|
|
610
|
+
onRetry={onRetryMessage && canRetryMessage(message) ? handleRetryMessage : undefined}
|
|
611
|
+
photoAlbums={photoAlbums}
|
|
612
|
+
/>
|
|
613
|
+
</WidgetErrorBoundary>
|
|
614
|
+
</View>
|
|
615
|
+
);
|
|
616
|
+
},
|
|
617
|
+
[
|
|
618
|
+
agentName,
|
|
619
|
+
color,
|
|
620
|
+
handleAgentUiAction,
|
|
621
|
+
handleRetryMessage,
|
|
622
|
+
handleSubmitProcedureForm,
|
|
623
|
+
labels,
|
|
624
|
+
locale,
|
|
625
|
+
locales,
|
|
626
|
+
onRateConversation,
|
|
627
|
+
onRetryMessage,
|
|
628
|
+
photoAlbums,
|
|
629
|
+
thread,
|
|
630
|
+
]
|
|
631
|
+
);
|
|
632
|
+
|
|
633
|
+
const loadEarlierRow = hasMoreMessages ? (
|
|
634
|
+
<Pressable onPress={handleLoadEarlier} accessibilityRole="button" style={styles.loadMore}>
|
|
635
|
+
<Text style={[styles.loadMoreLabel, { color }]}>
|
|
636
|
+
{loading ? labels.loading : labels.loadEarlier}
|
|
637
|
+
</Text>
|
|
638
|
+
</Pressable>
|
|
639
|
+
) : null;
|
|
640
|
+
|
|
641
|
+
// Under the newest message.
|
|
642
|
+
const threadTail =
|
|
643
|
+
awaitingTeammate || agentTyping ? (
|
|
644
|
+
<View style={styles.threadTail}>
|
|
645
|
+
{awaitingTeammate ? (
|
|
646
|
+
<View style={styles.waitingRow}>
|
|
647
|
+
<TeamAvatars avatars={teamAvatars} size={20} color={color} />
|
|
648
|
+
<Text style={styles.waitingText}>{labels.waitingForTeammate}</Text>
|
|
649
|
+
</View>
|
|
650
|
+
) : null}
|
|
651
|
+
{agentTyping ? (
|
|
652
|
+
<View style={styles.typingRow}>
|
|
653
|
+
<View style={styles.typingBubble}>
|
|
654
|
+
<TypingDot index={0} reduceMotion={reduceMotion} />
|
|
655
|
+
<TypingDot index={1} reduceMotion={reduceMotion} />
|
|
656
|
+
<TypingDot index={2} reduceMotion={reduceMotion} />
|
|
657
|
+
</View>
|
|
658
|
+
<Text style={styles.typingLabel}>{labels.thinking}</Text>
|
|
659
|
+
</View>
|
|
660
|
+
) : null}
|
|
661
|
+
</View>
|
|
662
|
+
) : null;
|
|
663
|
+
|
|
359
664
|
return (
|
|
360
665
|
<View style={globalStyles.screen}>
|
|
361
666
|
{/* One bar instead of a brand band above a separate back row: the band
|
|
@@ -447,117 +752,89 @@ export function ChatView({
|
|
|
447
752
|
</Pressable>
|
|
448
753
|
</GradientBackground>
|
|
449
754
|
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
{
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
<Text style={styles.dayLabel}>
|
|
502
|
-
{formatDayLabel(message.createdAt, locale, labels)}
|
|
755
|
+
{messages.length > 0 ? (
|
|
756
|
+
<View style={globalStyles.fill}>
|
|
757
|
+
<ImageQueueContext.Provider value={largeImageQueue}>
|
|
758
|
+
<FlatList
|
|
759
|
+
ref={listRef}
|
|
760
|
+
data={rows}
|
|
761
|
+
inverted
|
|
762
|
+
keyExtractor={rowKey}
|
|
763
|
+
renderItem={renderRow}
|
|
764
|
+
style={globalStyles.fill}
|
|
765
|
+
contentContainerStyle={styles.threadContent}
|
|
766
|
+
ItemSeparatorComponent={ThreadGap}
|
|
767
|
+
// Inverted, the header sits under the newest message and the footer
|
|
768
|
+
// above the oldest.
|
|
769
|
+
ListHeaderComponent={threadTail}
|
|
770
|
+
ListHeaderComponentStyle={styles.threadTailInList}
|
|
771
|
+
ListFooterComponent={loadEarlierRow}
|
|
772
|
+
onEndReached={handleLoadEarlier}
|
|
773
|
+
onEndReachedThreshold={0.5}
|
|
774
|
+
onScroll={handleScroll}
|
|
775
|
+
scrollEventThrottle={100}
|
|
776
|
+
// A reply arriving below doesn't move what a visitor reads further
|
|
777
|
+
// up; near the newest message the list follows it into view.
|
|
778
|
+
maintainVisibleContentPosition={{
|
|
779
|
+
minIndexForVisible: 0,
|
|
780
|
+
autoscrollToTopThreshold: JUMP_TO_LATEST_OFFSET,
|
|
781
|
+
}}
|
|
782
|
+
onViewableItemsChanged={Platform.OS === 'android' ? handleViewableItemsChanged : undefined}
|
|
783
|
+
keyboardShouldPersistTaps="handled"
|
|
784
|
+
// Interactive dismissal drags the keyboard down under the finger while
|
|
785
|
+
// the composer stays where the last keyboard event left it, which opens
|
|
786
|
+
// a gap under the thread; on-drag hands us a normal hide event instead.
|
|
787
|
+
keyboardDismissMode="on-drag"
|
|
788
|
+
/>
|
|
789
|
+
</ImageQueueContext.Provider>
|
|
790
|
+
{awayFrom ? (
|
|
791
|
+
<Pressable
|
|
792
|
+
onPress={handleJumpToLatest}
|
|
793
|
+
accessibilityRole="button"
|
|
794
|
+
accessibilityLabel={
|
|
795
|
+
unseenReplies
|
|
796
|
+
? formatLabel(labels.jumpToLatestNew, { count: unseenReplies })
|
|
797
|
+
: labels.jumpToLatest
|
|
798
|
+
}
|
|
799
|
+
hitSlop={8}
|
|
800
|
+
style={({ pressed }) => [styles.jumpToLatest, pressed && globalStyles.pressed]}>
|
|
801
|
+
<ChevronIcon size={18} direction="down" color={palette.text} />
|
|
802
|
+
{unseenReplies ? (
|
|
803
|
+
<View style={[styles.jumpBadge, { backgroundColor: color }]}>
|
|
804
|
+
<Text style={[styles.jumpBadgeText, { color: contrastingTextColor(color) }]}>
|
|
805
|
+
{unseenReplies > 9 ? '9+' : unseenReplies}
|
|
503
806
|
</Text>
|
|
504
|
-
<View style={styles.dayLine} />
|
|
505
807
|
</View>
|
|
506
808
|
) : null}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
? handleSubmitProcedureForm
|
|
531
|
-
: undefined
|
|
532
|
-
}
|
|
533
|
-
onAgentUiAction={
|
|
534
|
-
thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined
|
|
535
|
-
}
|
|
536
|
-
onRetry={onRetryMessage && canRetryMessage(message) ? handleRetryMessage : undefined}
|
|
537
|
-
/>
|
|
538
|
-
</WidgetErrorBoundary>
|
|
539
|
-
</Fragment>
|
|
540
|
-
);
|
|
541
|
-
})}
|
|
542
|
-
|
|
543
|
-
{awaitingTeammate ? (
|
|
544
|
-
<View style={styles.waitingRow}>
|
|
545
|
-
<TeamAvatars avatars={teamAvatars} size={20} color={color} />
|
|
546
|
-
<Text style={styles.waitingText}>{labels.waitingForTeammate}</Text>
|
|
547
|
-
</View>
|
|
548
|
-
) : null}
|
|
549
|
-
|
|
550
|
-
{agentTyping ? (
|
|
551
|
-
<View style={styles.typingRow}>
|
|
552
|
-
<View style={styles.typingBubble}>
|
|
553
|
-
<TypingDot index={0} reduceMotion={reduceMotion} />
|
|
554
|
-
<TypingDot index={1} reduceMotion={reduceMotion} />
|
|
555
|
-
<TypingDot index={2} reduceMotion={reduceMotion} />
|
|
809
|
+
</Pressable>
|
|
810
|
+
) : null}
|
|
811
|
+
</View>
|
|
812
|
+
) : (
|
|
813
|
+
<ScrollView
|
|
814
|
+
style={globalStyles.fill}
|
|
815
|
+
contentContainerStyle={[
|
|
816
|
+
styles.messagesContent,
|
|
817
|
+
isEmptyThread && styles.messagesContentEmpty,
|
|
818
|
+
isLoadingThread && styles.messagesContentLoading,
|
|
819
|
+
]}
|
|
820
|
+
keyboardShouldPersistTaps="handled"
|
|
821
|
+
keyboardDismissMode="on-drag">
|
|
822
|
+
{loadEarlierRow}
|
|
823
|
+
|
|
824
|
+
{isLoadingThread ? <MessagesSkeleton color={color} label={labels.loading} /> : null}
|
|
825
|
+
|
|
826
|
+
{isEmptyThread ? (
|
|
827
|
+
<View style={styles.emptyState}>
|
|
828
|
+
<View style={[styles.emptyIcon, { backgroundColor: withAlpha(color, 0.08) }]}>
|
|
829
|
+
<ChatOutlineIcon size={28} color={color} />
|
|
830
|
+
</View>
|
|
831
|
+
<Text style={styles.emptyText}>{labels.emptyConversation}</Text>
|
|
556
832
|
</View>
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
833
|
+
) : null}
|
|
834
|
+
|
|
835
|
+
{threadTail}
|
|
836
|
+
</ScrollView>
|
|
837
|
+
)}
|
|
561
838
|
|
|
562
839
|
{offlineMessage ? (
|
|
563
840
|
<View style={styles.offlineBanner}>
|
|
@@ -578,11 +855,19 @@ export function ChatView({
|
|
|
578
855
|
<AttachmentChips
|
|
579
856
|
attachments={drafts}
|
|
580
857
|
disabled={sending}
|
|
581
|
-
|
|
858
|
+
labels={labels.attach}
|
|
582
859
|
onRemove={(id) => {
|
|
860
|
+
uploads.current.get(id)?.cancel();
|
|
861
|
+
uploads.current.delete(id);
|
|
583
862
|
setDrafts((current) => current.filter((draft) => draft.id !== id));
|
|
584
863
|
showNotice(null);
|
|
585
864
|
}}
|
|
865
|
+
onRetry={(id) => {
|
|
866
|
+
const draft = drafts.find((item) => item.id === id);
|
|
867
|
+
if (!draft) return;
|
|
868
|
+
showNotice(null);
|
|
869
|
+
startUpload(draft);
|
|
870
|
+
}}
|
|
586
871
|
/>
|
|
587
872
|
) : null}
|
|
588
873
|
<View
|
|
@@ -623,6 +908,7 @@ export function ChatView({
|
|
|
623
908
|
onPress={handleSend}
|
|
624
909
|
disabled={!canSend}
|
|
625
910
|
accessibilityRole="button"
|
|
911
|
+
accessibilityLabel={labels.send}
|
|
626
912
|
accessibilityState={{ disabled: !canSend }}
|
|
627
913
|
hitSlop={8}
|
|
628
914
|
style={({ pressed }) => [
|
|
@@ -648,11 +934,11 @@ export function ChatView({
|
|
|
648
934
|
/>
|
|
649
935
|
) : null}
|
|
650
936
|
<ImageViewer
|
|
651
|
-
|
|
937
|
+
gallery={viewerGallery}
|
|
652
938
|
labels={labels.viewer}
|
|
653
|
-
onClose={() =>
|
|
939
|
+
onClose={() => setViewerGallery(null)}
|
|
654
940
|
onOpenExternally={(url) => {
|
|
655
|
-
|
|
941
|
+
setViewerGallery(null);
|
|
656
942
|
void openExternalUrl(url);
|
|
657
943
|
}}
|
|
658
944
|
/>
|
|
@@ -677,6 +963,7 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
677
963
|
onRateConversation,
|
|
678
964
|
locales,
|
|
679
965
|
onRetry,
|
|
966
|
+
photoAlbums,
|
|
680
967
|
}: {
|
|
681
968
|
message: Message;
|
|
682
969
|
color: string;
|
|
@@ -686,11 +973,13 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
686
973
|
receiptLabel?: string;
|
|
687
974
|
onSubmitProcedureForm?: (body: string) => void;
|
|
688
975
|
onAgentUiAction?: (action: AgentUiAction) => void | Promise<void>;
|
|
689
|
-
onOpenImage: (
|
|
976
|
+
onOpenImage: (gallery: ViewerGallery) => void;
|
|
690
977
|
onRateConversation?: ChatViewProps['onRateConversation'];
|
|
691
978
|
locales: Array<string | null | undefined>;
|
|
692
979
|
/** Re-sends this message when it failed. */
|
|
693
980
|
onRetry?: (messageId: string) => void;
|
|
981
|
+
/** Off: each photo shows on its own, full width. */
|
|
982
|
+
photoAlbums: boolean;
|
|
694
983
|
}) {
|
|
695
984
|
const joinedName = message.metadata?.event === 'agent_joined' ? message.metadata?.agentName : null;
|
|
696
985
|
if (message.messageType === 'system' && typeof joinedName === 'string' && joinedName) {
|
|
@@ -753,6 +1042,54 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
753
1042
|
const procedureForm = !isVisitor ? getProcedureForm(message.metadata) : null;
|
|
754
1043
|
const agentUiBlocks = !isVisitor ? getAgentUiBlocks(message.metadata) : [];
|
|
755
1044
|
const textColor = isVisitor ? palette.white : palette.ink;
|
|
1045
|
+
// A reply with images between its paragraphs shows them where the teammate put them.
|
|
1046
|
+
const inlineParts = isVisitor ? null : splitInlineAttachments(message.body, message.attachments);
|
|
1047
|
+
const files = inlineParts ? inlineParts.files : (message.attachments ?? []);
|
|
1048
|
+
// Two or more photos share one album; any other files follow it.
|
|
1049
|
+
const photos = files.filter((attachment) => isImageAttachment(attachment.contentType));
|
|
1050
|
+
const album = photoAlbums && photos.length > 1 ? photos : null;
|
|
1051
|
+
const sentAt = Date.parse(message.createdAt) || 0;
|
|
1052
|
+
// A tap on one of the message's photos opens them all, at that one, in the
|
|
1053
|
+
// order the bubble shows them.
|
|
1054
|
+
const openPhotos = (tapped: { id: string; url: string; name: string }) => {
|
|
1055
|
+
const photos = [
|
|
1056
|
+
...(inlineParts?.parts.flatMap((part) => ('attachment' in part ? [part.attachment] : [])) ?? []),
|
|
1057
|
+
...files,
|
|
1058
|
+
]
|
|
1059
|
+
.filter((attachment) => isImageAttachment(attachment.contentType))
|
|
1060
|
+
.map((attachment) => {
|
|
1061
|
+
const url =
|
|
1062
|
+
attachment.id === tapped.id ? tapped.url : localPreviewFor(attachment.url) || attachment.url;
|
|
1063
|
+
// The phone's copy may be gone by now; the stored one stands in.
|
|
1064
|
+
const fallbackUrl = url !== attachment.url && isHttpUrl(attachment.url) ? attachment.url : undefined;
|
|
1065
|
+
return {
|
|
1066
|
+
id: attachment.id,
|
|
1067
|
+
url,
|
|
1068
|
+
name: attachment.name,
|
|
1069
|
+
large: isLargeDownload(url, attachment.size),
|
|
1070
|
+
fallbackUrl,
|
|
1071
|
+
};
|
|
1072
|
+
})
|
|
1073
|
+
.filter((photo) => !!photo.url);
|
|
1074
|
+
const index = photos.findIndex((photo) => photo.id === tapped.id);
|
|
1075
|
+
onOpenImage(
|
|
1076
|
+
index < 0
|
|
1077
|
+
? { images: [tapped], index: 0 }
|
|
1078
|
+
: { images: photos.map(({ url, name, large, fallbackUrl }) => ({ url, name, large, fallbackUrl })), index }
|
|
1079
|
+
);
|
|
1080
|
+
};
|
|
1081
|
+
const renderAttachment = (attachment: WidgetAttachment) => (
|
|
1082
|
+
<AttachmentPreview
|
|
1083
|
+
key={attachment.id}
|
|
1084
|
+
loadPriority={sentAt}
|
|
1085
|
+
attachment={attachment}
|
|
1086
|
+
isVisitor={isVisitor}
|
|
1087
|
+
color={color}
|
|
1088
|
+
voiceLabels={labels.voice}
|
|
1089
|
+
openLabel={labels.openAttachment}
|
|
1090
|
+
onOpenImage={openPhotos}
|
|
1091
|
+
/>
|
|
1092
|
+
);
|
|
756
1093
|
|
|
757
1094
|
return (
|
|
758
1095
|
<View style={[styles.messageRow, isVisitor ? styles.messageRowRight : styles.messageRowLeft]}>
|
|
@@ -764,7 +1101,17 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
764
1101
|
rtl && (isVisitor ? styles.bubbleVisitorRTL : styles.bubbleAgentRTL),
|
|
765
1102
|
message.status === 'pending' && styles.bubblePending,
|
|
766
1103
|
]}>
|
|
767
|
-
{
|
|
1104
|
+
{inlineParts ? (
|
|
1105
|
+
<View style={styles.inlineParts}>
|
|
1106
|
+
{inlineParts.parts.map((part, index) =>
|
|
1107
|
+
'text' in part ? (
|
|
1108
|
+
<Markdown key={`text-${index}`} content={part.text} color={textColor} />
|
|
1109
|
+
) : (
|
|
1110
|
+
renderAttachment(part.attachment)
|
|
1111
|
+
)
|
|
1112
|
+
)}
|
|
1113
|
+
</View>
|
|
1114
|
+
) : hasBody ? (
|
|
768
1115
|
isVisitor ? (
|
|
769
1116
|
<Text style={[styles.bubbleText, { color: textColor }]}>{messageText}</Text>
|
|
770
1117
|
) : (
|
|
@@ -772,19 +1119,20 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
772
1119
|
)
|
|
773
1120
|
) : null}
|
|
774
1121
|
|
|
775
|
-
{
|
|
776
|
-
<View
|
|
777
|
-
{
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
openLabel={labels.openAttachment}
|
|
785
|
-
onOpenImage={onOpenImage}
|
|
1122
|
+
{files.length > 0 ? (
|
|
1123
|
+
<View
|
|
1124
|
+
style={[styles.attachments, (hasBody || !!inlineParts) && styles.attachmentsSpaced]}>
|
|
1125
|
+
{album ? (
|
|
1126
|
+
<PhotoAlbum
|
|
1127
|
+
photos={album}
|
|
1128
|
+
loadPriority={sentAt}
|
|
1129
|
+
moreLabel={labels.morePhotos}
|
|
1130
|
+
onOpen={openPhotos}
|
|
786
1131
|
/>
|
|
787
|
-
)
|
|
1132
|
+
) : null}
|
|
1133
|
+
{(album ? files.filter((attachment) => !album.includes(attachment)) : files).map(
|
|
1134
|
+
renderAttachment
|
|
1135
|
+
)}
|
|
788
1136
|
</View>
|
|
789
1137
|
) : null}
|
|
790
1138
|
|
|
@@ -828,6 +1176,7 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
828
1176
|
|
|
829
1177
|
{agentUiBlocks.length === 0 && procedureForm && onSubmitProcedureForm ? (
|
|
830
1178
|
<ProcedureFieldForm
|
|
1179
|
+
messageId={message.id}
|
|
831
1180
|
form={procedureForm}
|
|
832
1181
|
color={color}
|
|
833
1182
|
labels={labels}
|
|
@@ -844,8 +1193,181 @@ function receiptLabel(message: Message, labels: ChatLabels): string | undefined
|
|
|
844
1193
|
return message.status === 'read' ? labels.messageSeen : labels.messageSent;
|
|
845
1194
|
}
|
|
846
1195
|
|
|
1196
|
+
/**
|
|
1197
|
+
* Where a photo shows from, and whether it may load yet. `url` is empty when
|
|
1198
|
+
* the server sent a non-http(s) URL (see resolveUrl). The visitor's own
|
|
1199
|
+
* attachment points at the picked file until the server copy replaces it (or
|
|
1200
|
+
* is empty without one). A photo they sent this session keeps showing from
|
|
1201
|
+
* the phone after that: the stored copy may not load yet. Whichever fails to
|
|
1202
|
+
* load gives way to the next.
|
|
1203
|
+
*/
|
|
1204
|
+
function usePhotoSource(attachment: WidgetAttachment, loadPriority: number) {
|
|
1205
|
+
const [failedUrls, setFailedUrls] = useState<string[]>([]);
|
|
1206
|
+
const [loadedUrl, setLoadedUrl] = useState<string | null>(null);
|
|
1207
|
+
const url = [localPreviewFor(attachment.url), attachment.url].find(
|
|
1208
|
+
(candidate): candidate is string => !!candidate && !failedUrls.includes(candidate)
|
|
1209
|
+
);
|
|
1210
|
+
const isPhoto = isImageAttachment(attachment.contentType) && !!url;
|
|
1211
|
+
// A large download waits its turn; small photos and the phone's own copy don't.
|
|
1212
|
+
const [turn, settled] = useImageTurn(
|
|
1213
|
+
attachment.id || attachment.url,
|
|
1214
|
+
loadPriority,
|
|
1215
|
+
isPhoto && isLargeDownload(url!, attachment.size)
|
|
1216
|
+
);
|
|
1217
|
+
return {
|
|
1218
|
+
url,
|
|
1219
|
+
isPhoto,
|
|
1220
|
+
turn,
|
|
1221
|
+
/** Waiting for its turn or still downloading; the next URL after a failure starts over. */
|
|
1222
|
+
loading: isPhoto && loadedUrl !== url,
|
|
1223
|
+
onLoad: () => {
|
|
1224
|
+
settled();
|
|
1225
|
+
if (url) setLoadedUrl(url);
|
|
1226
|
+
},
|
|
1227
|
+
onError: () => {
|
|
1228
|
+
settled();
|
|
1229
|
+
if (!url) return;
|
|
1230
|
+
setFailedUrls((current) => [...current, url]);
|
|
1231
|
+
// The viewer and the message's other views stop offering it too.
|
|
1232
|
+
if (url !== attachment.url && url === localPreviewFor(attachment.url)) forgetLocalPreview(attachment.url);
|
|
1233
|
+
},
|
|
1234
|
+
};
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Several photos in one message, the way WhatsApp shows them: one square,
|
|
1239
|
+
* two side by side, three as one tall beside two stacked, four in a grid, and
|
|
1240
|
+
* past four the last tile covered with how many more there are.
|
|
1241
|
+
*/
|
|
1242
|
+
function PhotoAlbum({
|
|
1243
|
+
photos,
|
|
1244
|
+
loadPriority,
|
|
1245
|
+
moreLabel,
|
|
1246
|
+
onOpen,
|
|
1247
|
+
}: {
|
|
1248
|
+
photos: WidgetAttachment[];
|
|
1249
|
+
loadPriority: number;
|
|
1250
|
+
/** Keeps `{count}`. */
|
|
1251
|
+
moreLabel: string;
|
|
1252
|
+
onOpen: (image: { id: string; url: string; name: string }) => void;
|
|
1253
|
+
}) {
|
|
1254
|
+
const shown = photos.slice(0, 4);
|
|
1255
|
+
// The covered tile counts too: it's no more visible than the ones after it.
|
|
1256
|
+
const more = photos.length > 4 ? photos.length - 3 : 0;
|
|
1257
|
+
const tile = (attachment: WidgetAttachment, index: number, size: StyleProp<ViewStyle>) => (
|
|
1258
|
+
<PhotoTile
|
|
1259
|
+
key={attachment.id}
|
|
1260
|
+
attachment={attachment}
|
|
1261
|
+
loadPriority={loadPriority}
|
|
1262
|
+
style={size}
|
|
1263
|
+
more={index === 3 ? more : 0}
|
|
1264
|
+
moreLabel={moreLabel}
|
|
1265
|
+
onOpen={onOpen}
|
|
1266
|
+
/>
|
|
1267
|
+
);
|
|
1268
|
+
|
|
1269
|
+
if (shown.length === 2) {
|
|
1270
|
+
return <View style={[styles.album, styles.albumRow]}>{shown.map((photo, i) => tile(photo, i, styles.albumHalf))}</View>;
|
|
1271
|
+
}
|
|
1272
|
+
if (shown.length === 3) {
|
|
1273
|
+
return (
|
|
1274
|
+
<View style={[styles.album, styles.albumRow]}>
|
|
1275
|
+
{tile(shown[0]!, 0, styles.albumHalf)}
|
|
1276
|
+
<View style={styles.albumColumn}>
|
|
1277
|
+
{shown.slice(1).map((photo, i) => tile(photo, i + 1, styles.albumQuarter))}
|
|
1278
|
+
</View>
|
|
1279
|
+
</View>
|
|
1280
|
+
);
|
|
1281
|
+
}
|
|
1282
|
+
return <View style={[styles.album, styles.albumGrid]}>{shown.map((photo, i) => tile(photo, i, styles.albumQuarter))}</View>;
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function PhotoTile({
|
|
1286
|
+
attachment,
|
|
1287
|
+
loadPriority,
|
|
1288
|
+
style,
|
|
1289
|
+
more,
|
|
1290
|
+
moreLabel,
|
|
1291
|
+
onOpen,
|
|
1292
|
+
}: {
|
|
1293
|
+
attachment: WidgetAttachment;
|
|
1294
|
+
loadPriority: number;
|
|
1295
|
+
style: StyleProp<ViewStyle>;
|
|
1296
|
+
/** Photos past this tile, shown over it; 0 for none. */
|
|
1297
|
+
more: number;
|
|
1298
|
+
moreLabel: string;
|
|
1299
|
+
onOpen: (image: { id: string; url: string; name: string }) => void;
|
|
1300
|
+
}) {
|
|
1301
|
+
const photo = usePhotoSource(attachment, loadPriority);
|
|
1302
|
+
// One that can't load still opens, so the viewer can offer the browser.
|
|
1303
|
+
const openUrl = photo.url ?? (isHttpUrl(attachment.url) ? attachment.url : undefined);
|
|
1304
|
+
return (
|
|
1305
|
+
<Pressable
|
|
1306
|
+
onPress={openUrl ? () => onOpen({ id: attachment.id, url: openUrl, name: attachment.name }) : undefined}
|
|
1307
|
+
accessibilityRole="imagebutton"
|
|
1308
|
+
accessibilityLabel={
|
|
1309
|
+
more > 0 ? `${attachment.name}, ${formatLabel(moreLabel, { count: more })}` : attachment.name
|
|
1310
|
+
}
|
|
1311
|
+
style={({ pressed }) => [styles.albumTile, style, pressed && globalStyles.pressed]}>
|
|
1312
|
+
{photo.isPhoto && photo.turn ? (
|
|
1313
|
+
<Image
|
|
1314
|
+
source={{ uri: photo.url }}
|
|
1315
|
+
style={styles.albumImage}
|
|
1316
|
+
resizeMode="cover"
|
|
1317
|
+
// Decoded at the tile's size, like a single photo.
|
|
1318
|
+
resizeMethod="resize"
|
|
1319
|
+
accessibilityIgnoresInvertColors
|
|
1320
|
+
onLoad={photo.onLoad}
|
|
1321
|
+
onError={photo.onError}
|
|
1322
|
+
/>
|
|
1323
|
+
) : null}
|
|
1324
|
+
{!photo.isPhoto ? (
|
|
1325
|
+
// Nothing left to load (an expired link): its type and name, not a blank square.
|
|
1326
|
+
<View style={styles.albumFallback}>
|
|
1327
|
+
<Text style={styles.albumFallbackType}>{fileTypeLabel(attachment.name, attachment.contentType)}</Text>
|
|
1328
|
+
<Text style={styles.albumFallbackName} numberOfLines={2}>
|
|
1329
|
+
{attachment.name}
|
|
1330
|
+
</Text>
|
|
1331
|
+
</View>
|
|
1332
|
+
) : null}
|
|
1333
|
+
{/* The "+N" cover already hides this tile, so it doesn't spin too. */}
|
|
1334
|
+
<PhotoLoader loading={photo.loading && more === 0} />
|
|
1335
|
+
{more > 0 ? (
|
|
1336
|
+
<View style={styles.albumMore}>
|
|
1337
|
+
<Text style={styles.albumMoreText}>+{more}</Text>
|
|
1338
|
+
</View>
|
|
1339
|
+
) : null}
|
|
1340
|
+
</Pressable>
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Spins over a photo until it has loaded. Held back a moment, so a photo that
|
|
1346
|
+
* comes straight from the cache never flashes it.
|
|
1347
|
+
*/
|
|
1348
|
+
function PhotoLoader({ loading }: { loading: boolean }) {
|
|
1349
|
+
const [due, setDue] = useState(false);
|
|
1350
|
+
useEffect(() => {
|
|
1351
|
+
if (!loading) return undefined;
|
|
1352
|
+
const timer = setTimeout(() => setDue(true), PHOTO_LOADER_DELAY_MS);
|
|
1353
|
+
return () => {
|
|
1354
|
+
clearTimeout(timer);
|
|
1355
|
+
setDue(false);
|
|
1356
|
+
};
|
|
1357
|
+
}, [loading]);
|
|
1358
|
+
if (!loading || !due) return null;
|
|
1359
|
+
return (
|
|
1360
|
+
<View style={styles.photoLoader} pointerEvents="none">
|
|
1361
|
+
<View style={styles.photoLoaderBadge}>
|
|
1362
|
+
<ActivityIndicator size="small" color={palette.white} />
|
|
1363
|
+
</View>
|
|
1364
|
+
</View>
|
|
1365
|
+
);
|
|
1366
|
+
}
|
|
1367
|
+
|
|
847
1368
|
function AttachmentPreview({
|
|
848
1369
|
attachment,
|
|
1370
|
+
loadPriority,
|
|
849
1371
|
isVisitor,
|
|
850
1372
|
color,
|
|
851
1373
|
voiceLabels,
|
|
@@ -853,25 +1375,26 @@ function AttachmentPreview({
|
|
|
853
1375
|
onOpenImage,
|
|
854
1376
|
}: {
|
|
855
1377
|
attachment: WidgetAttachment;
|
|
1378
|
+
/** Among large images waiting to download, higher goes first: newer messages. */
|
|
1379
|
+
loadPriority: number;
|
|
856
1380
|
isVisitor: boolean;
|
|
857
1381
|
color: string;
|
|
858
1382
|
voiceLabels: VoiceNoteLabels;
|
|
859
1383
|
/** Keeps `{name}`. */
|
|
860
1384
|
openLabel: string;
|
|
861
|
-
|
|
1385
|
+
/** With the URL this bubble shows, which the viewer opens at. */
|
|
1386
|
+
onOpenImage: (image: { id: string; url: string; name: string }) => void;
|
|
862
1387
|
}) {
|
|
863
|
-
// `url` is empty when the server sent a non-http(s) URL (see resolveUrl).
|
|
864
|
-
// The visitor's own attachment points at the picked file until the server
|
|
865
|
-
// copy replaces it (or is empty without one).
|
|
866
|
-
const [imageFailed, setImageFailed] = useState(false);
|
|
867
1388
|
const [audioFailed, setAudioFailed] = useState(false);
|
|
868
|
-
const
|
|
1389
|
+
const photo = usePhotoSource(attachment, loadPriority);
|
|
1390
|
+
const imageUrl = photo.url;
|
|
1391
|
+
const isImage = photo.isPhoto;
|
|
869
1392
|
const openable = isHttpUrl(attachment.url);
|
|
870
1393
|
// Images open in the in-app viewer, a local photo too since it already
|
|
871
1394
|
// renders here. Other files (PDF, sheets) need the OS, which can only open
|
|
872
1395
|
// the server copy.
|
|
873
1396
|
const open = isImage
|
|
874
|
-
? () => onOpenImage({
|
|
1397
|
+
? () => onOpenImage({ id: attachment.id, url: imageUrl!, name: attachment.name })
|
|
875
1398
|
: openable
|
|
876
1399
|
? () => void openExternalUrl(attachment.url)
|
|
877
1400
|
: undefined;
|
|
@@ -899,15 +1422,26 @@ function AttachmentPreview({
|
|
|
899
1422
|
onPress={open}
|
|
900
1423
|
accessibilityRole="imagebutton"
|
|
901
1424
|
accessibilityLabel={attachment.name}>
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1425
|
+
{photo.turn ? (
|
|
1426
|
+
<Image
|
|
1427
|
+
source={{ uri: imageUrl }}
|
|
1428
|
+
style={styles.attachmentImage}
|
|
1429
|
+
resizeMode="cover"
|
|
1430
|
+
// Android otherwise decodes a server image at full size: two 20 MP
|
|
1431
|
+
// photos fill its shared bitmap budget, and every later image in
|
|
1432
|
+
// the app fails to load. Decoded at the bubble's size instead.
|
|
1433
|
+
resizeMethod="resize"
|
|
1434
|
+
accessibilityIgnoresInvertColors
|
|
1435
|
+
onLoad={photo.onLoad}
|
|
1436
|
+
// An image that can't load (expired link, deleted file) falls back to
|
|
1437
|
+
// the file row below instead of an empty box.
|
|
1438
|
+
onError={photo.onError}
|
|
1439
|
+
/>
|
|
1440
|
+
) : (
|
|
1441
|
+
// Same box as the image, so nothing moves when its turn comes.
|
|
1442
|
+
<View style={styles.attachmentImage} />
|
|
1443
|
+
)}
|
|
1444
|
+
<PhotoLoader loading={photo.loading} />
|
|
911
1445
|
</Pressable>
|
|
912
1446
|
);
|
|
913
1447
|
}
|
|
@@ -926,19 +1460,22 @@ function AttachmentPreview({
|
|
|
926
1460
|
}
|
|
927
1461
|
|
|
928
1462
|
function ProcedureFieldForm({
|
|
1463
|
+
messageId,
|
|
929
1464
|
form,
|
|
930
1465
|
color,
|
|
931
1466
|
labels,
|
|
932
1467
|
onSubmit,
|
|
933
1468
|
}: {
|
|
1469
|
+
/** Keeps what was typed while the row is scrolled far away and unmounted. */
|
|
1470
|
+
messageId: string;
|
|
934
1471
|
form: AgentProcedureForm;
|
|
935
1472
|
color: string;
|
|
936
1473
|
labels: ChatLabels;
|
|
937
1474
|
onSubmit: (body: string) => void;
|
|
938
1475
|
}) {
|
|
939
|
-
const [values, setValues] =
|
|
940
|
-
const [submitted, setSubmitted] =
|
|
941
|
-
const [expanded, setExpanded] =
|
|
1476
|
+
const [values, setValues] = useRetainedState<Record<string, string>>(`${messageId}:values`, {});
|
|
1477
|
+
const [submitted, setSubmitted] = useRetainedState(`${messageId}:submitted`, false);
|
|
1478
|
+
const [expanded, setExpanded] = useRetainedState(`${messageId}:expanded`, false);
|
|
942
1479
|
|
|
943
1480
|
const canSubmit = form.fields.every((field) => !field.required || values[field.name]?.trim());
|
|
944
1481
|
|