@repzo/widget-react-native 1.3.0 → 1.4.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 +179 -0
- package/README.md +72 -16
- package/lib/commonjs/api/client.js +113 -0
- 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 +76 -28
- package/lib/commonjs/attachments/pickers.js.map +1 -1
- package/lib/commonjs/i18n.js +24 -3
- package/lib/commonjs/i18n.js.map +1 -1
- package/lib/commonjs/locales.js +13 -3
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +486 -63
- 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 +40 -16
- 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 +231 -25
- 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/components/icons.js +22 -16
- package/lib/commonjs/ui/components/icons.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 +666 -171
- package/lib/commonjs/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/commonjs/ui/screens/chat/ChatView.styles.js +150 -3
- 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/styles/theme.js +1 -0
- package/lib/commonjs/ui/styles/theme.js.map +1 -1
- package/lib/commonjs/ui/utils/conversations.js +17 -1
- package/lib/commonjs/ui/utils/conversations.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 +113 -0
- 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 +74 -27
- package/lib/module/attachments/pickers.js.map +1 -1
- package/lib/module/i18n.js +24 -3
- package/lib/module/i18n.js.map +1 -1
- package/lib/module/locales.js +13 -3
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +488 -65
- 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 +40 -16
- 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 +233 -27
- 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/components/icons.js +21 -15
- package/lib/module/ui/components/icons.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 +672 -177
- package/lib/module/ui/screens/chat/ChatView.js.map +1 -1
- package/lib/module/ui/screens/chat/ChatView.styles.js +152 -4
- 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/styles/theme.js +1 -0
- package/lib/module/ui/styles/theme.js.map +1 -1
- package/lib/module/ui/utils/conversations.js +15 -1
- package/lib/module/ui/utils/conversations.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 +35 -1
- 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 +35 -10
- package/lib/typescript/commonjs/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/commonjs/i18n.d.ts +13 -3
- package/lib/typescript/commonjs/i18n.d.ts.map +1 -1
- package/lib/typescript/commonjs/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +67 -4
- 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 +58 -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/components/icons.d.ts +2 -1
- package/lib/typescript/commonjs/ui/components/icons.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 +154 -2
- 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 +30 -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/styles/theme.d.ts +2 -0
- package/lib/typescript/commonjs/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/commonjs/ui/utils/conversations.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 +35 -1
- 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 +35 -10
- package/lib/typescript/module/attachments/pickers.d.ts.map +1 -1
- package/lib/typescript/module/i18n.d.ts +13 -3
- package/lib/typescript/module/i18n.d.ts.map +1 -1
- package/lib/typescript/module/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +67 -4
- 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 +58 -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/components/icons.d.ts +2 -1
- package/lib/typescript/module/ui/components/icons.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 +154 -2
- 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 +30 -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/styles/theme.d.ts +2 -0
- package/lib/typescript/module/ui/styles/theme.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/conversations.d.ts +10 -2
- package/lib/typescript/module/ui/utils/conversations.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 +148 -2
- package/src/attachments/file-size.ts +42 -0
- package/src/attachments/local-previews.ts +32 -0
- package/src/attachments/pickers.ts +90 -30
- package/src/i18n.ts +24 -3
- package/src/locales.ts +13 -3
- package/src/state/widget-store.ts +519 -70
- package/src/storage.ts +2 -0
- package/src/types.ts +58 -0
- package/src/ui/WidgetProvider.tsx +50 -12
- package/src/ui/components/AttachmentChips.tsx +100 -29
- package/src/ui/components/ConversationRow.tsx +90 -9
- package/src/ui/components/ImageViewer.tsx +250 -28
- package/src/ui/components/RatingCard.tsx +5 -2
- package/src/ui/components/ZoomableImage.tsx +58 -5
- package/src/ui/components/icons.tsx +10 -12
- package/src/ui/hooks/use-retained-state.ts +35 -0
- package/src/ui/screens/chat/ChatView.styles.ts +151 -3
- package/src/ui/screens/chat/ChatView.tsx +760 -213
- package/src/ui/screens/chat/ChatView.types.ts +30 -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/styles/theme.ts +6 -0
- package/src/ui/utils/conversations.ts +16 -2
- 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,28 +66,93 @@ 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';
|
|
52
|
-
import { ArrowUpIcon, ChatOutlineIcon, ChevronIcon, ClockIcon,
|
|
73
|
+
import { AlertIcon, ArrowUpIcon, ChatOutlineIcon, ChevronIcon, ClockIcon, PlusIcon } from '../../components/icons';
|
|
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
|
-
import { formatClockTime, formatDayLabel, isSameDay } from '../../utils/conversations';
|
|
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,10 +172,13 @@ export function ChatView({
|
|
|
86
172
|
labels,
|
|
87
173
|
attachmentSources = [],
|
|
88
174
|
onPickAttachments,
|
|
175
|
+
onUploadAttachment,
|
|
89
176
|
onSendMessage,
|
|
177
|
+
onRetryMessage,
|
|
90
178
|
onIdentifyVisitor,
|
|
91
179
|
onRateConversation,
|
|
92
180
|
defaultLocale,
|
|
181
|
+
photoAlbums = true,
|
|
93
182
|
onLoadMore,
|
|
94
183
|
onTyping,
|
|
95
184
|
onBack,
|
|
@@ -99,31 +188,47 @@ export function ChatView({
|
|
|
99
188
|
const [composerFocused, setComposerFocused] = useState(false);
|
|
100
189
|
const [drafts, setDrafts] = useState<DraftAttachment[]>([]);
|
|
101
190
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
102
|
-
const [
|
|
191
|
+
const [viewerGallery, setViewerGallery] = useState<ViewerGallery | null>(null);
|
|
103
192
|
const [picking, setPicking] = useState(false);
|
|
104
193
|
// Only attachment sends hold the composer; text sends stay fire-and-forget.
|
|
105
194
|
const [sending, setSending] = useState(false);
|
|
106
195
|
const [notice, setNotice] = useState<string | null>(null);
|
|
107
196
|
const reduceMotion = useReduceMotion();
|
|
108
|
-
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));
|
|
109
200
|
const typingStopTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
110
201
|
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
111
202
|
const pickerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
112
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);
|
|
113
214
|
|
|
114
215
|
useEffect(() => {
|
|
115
216
|
if (prefillText) setInput(prefillText);
|
|
116
217
|
}, [prefillText]);
|
|
117
218
|
|
|
118
219
|
// The provider keys this screen by the store's thread generation. Pending
|
|
119
|
-
// picks and sends belong to this mount, even before a draft gets a
|
|
220
|
+
// picks, uploads and sends belong to this mount, even before a draft gets a
|
|
221
|
+
// server ID.
|
|
120
222
|
useEffect(() => {
|
|
121
223
|
mounted.current = true;
|
|
224
|
+
const inFlight = uploads.current;
|
|
122
225
|
return () => {
|
|
123
226
|
mounted.current = false;
|
|
124
227
|
if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
|
|
125
228
|
if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
|
126
229
|
if (pickerTimer.current) clearTimeout(pickerTimer.current);
|
|
230
|
+
inFlight.forEach((upload) => upload.cancel());
|
|
231
|
+
inFlight.clear();
|
|
127
232
|
};
|
|
128
233
|
}, []);
|
|
129
234
|
|
|
@@ -133,51 +238,57 @@ export function ChatView({
|
|
|
133
238
|
if (message) noticeTimer.current = setTimeout(() => setNotice(null), 5000);
|
|
134
239
|
}, []);
|
|
135
240
|
|
|
241
|
+
// The list is inverted, so offset 0 is the newest message: the thread opens
|
|
242
|
+
// there without scrolling, stays there as the keyboard rises or replies
|
|
243
|
+
// arrive, and older pages land above it without moving the reader.
|
|
136
244
|
const scrollToBottom = useCallback((animated = true) => {
|
|
137
|
-
|
|
245
|
+
listRef.current?.scrollToOffset({ offset: 0, animated });
|
|
138
246
|
}, []);
|
|
139
247
|
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
// handler below covers later growth (loads, images, incoming messages).
|
|
143
|
-
useEffect(() => {
|
|
144
|
-
const frame = requestAnimationFrame(() => scrollToBottom(false));
|
|
145
|
-
return () => cancelAnimationFrame(frame);
|
|
146
|
-
}, [scrollToBottom]);
|
|
147
|
-
|
|
148
|
-
// "Load earlier" PREPENDS history; skipping one auto-scroll keeps the reader
|
|
149
|
-
// anchored where they were instead of yanking them back to the bottom.
|
|
150
|
-
const skipNextAutoScroll = useRef(false);
|
|
248
|
+
// Fires as the visitor nears the oldest message; the row up there is the
|
|
249
|
+
// fallback when a page fails to load.
|
|
151
250
|
const handleLoadEarlier = useCallback(() => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}, [onLoadMore]);
|
|
251
|
+
if (hasMoreMessages && !loading) onLoadMore();
|
|
252
|
+
}, [hasMoreMessages, loading, onLoadMore]);
|
|
155
253
|
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const handleViewportLayout = useCallback(
|
|
170
|
-
(event: LayoutChangeEvent) => {
|
|
171
|
-
const { height } = event.nativeEvent.layout;
|
|
172
|
-
const shrank = viewportHeight.current - height > 1;
|
|
173
|
-
viewportHeight.current = height;
|
|
174
|
-
if (shrank) scrollToBottom(false);
|
|
175
|
-
},
|
|
176
|
-
[scrollToBottom]
|
|
254
|
+
const showAttach = attachmentSources.length > 0 && !!onPickAttachments;
|
|
255
|
+
// Send waits until every picked file is up, so the message goes out at once.
|
|
256
|
+
const uploadsPending = drafts.some(
|
|
257
|
+
(draft) => draft.uploadStatus === 'uploading' || draft.uploadStatus === 'failed'
|
|
258
|
+
);
|
|
259
|
+
const canSend = !sending && !uploadsPending && (input.trim().length > 0 || drafts.length > 0);
|
|
260
|
+
|
|
261
|
+
const updateDraft = useCallback(
|
|
262
|
+
(id: string, patch: Partial<DraftAttachment>) =>
|
|
263
|
+
setDrafts((current) =>
|
|
264
|
+
current.map((draft) => (draft.id === id ? { ...draft, ...patch } : draft))
|
|
265
|
+
),
|
|
266
|
+
[]
|
|
177
267
|
);
|
|
178
268
|
|
|
179
|
-
|
|
180
|
-
const
|
|
269
|
+
/** Puts a picked local file up while the visitor writes; inline files need nothing. */
|
|
270
|
+
const startUpload = (draft: DraftAttachment) => {
|
|
271
|
+
if (!onUploadAttachment || !draft.fileUri) return;
|
|
272
|
+
uploads.current.get(draft.id)?.cancel();
|
|
273
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
274
|
+
const attempt = { cancel: () => controller?.abort() };
|
|
275
|
+
uploads.current.set(draft.id, attempt);
|
|
276
|
+
updateDraft(draft.id, { uploadStatus: 'uploading', uploadToken: undefined, uploadExpiresAt: undefined });
|
|
277
|
+
onUploadAttachment(draft, controller?.signal).then(
|
|
278
|
+
({ uploadToken, expiresAt }) => {
|
|
279
|
+
if (!mounted.current || uploads.current.get(draft.id) !== attempt) return;
|
|
280
|
+
uploads.current.delete(draft.id);
|
|
281
|
+
updateDraft(draft.id, { uploadStatus: 'uploaded', uploadToken, uploadExpiresAt: expiresAt });
|
|
282
|
+
},
|
|
283
|
+
(error: unknown) => {
|
|
284
|
+
if (!mounted.current || uploads.current.get(draft.id) !== attempt) return;
|
|
285
|
+
console.warn('[repzo-widget] attachment upload failed', error);
|
|
286
|
+
uploads.current.delete(draft.id);
|
|
287
|
+
updateDraft(draft.id, { uploadStatus: 'failed' });
|
|
288
|
+
showNotice(labels.attach.uploadFailedNotice);
|
|
289
|
+
}
|
|
290
|
+
);
|
|
291
|
+
};
|
|
181
292
|
|
|
182
293
|
const problemLabel = (problem: AttachmentProblem | null): string | null => {
|
|
183
294
|
switch (problem) {
|
|
@@ -204,6 +315,8 @@ export function ChatView({
|
|
|
204
315
|
if (!mounted.current) return;
|
|
205
316
|
if (result.added.length > 0) {
|
|
206
317
|
setDrafts((current) => [...current, ...result.added].slice(0, MAX_ATTACHMENTS));
|
|
318
|
+
// Opted out, the files go up when the visitor taps Send instead.
|
|
319
|
+
if (uploadsOnPick()) result.added.forEach(startUpload);
|
|
207
320
|
}
|
|
208
321
|
showNotice(problemLabel(result.problem));
|
|
209
322
|
} finally {
|
|
@@ -252,7 +365,7 @@ export function ChatView({
|
|
|
252
365
|
setInput('');
|
|
253
366
|
if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
|
|
254
367
|
onTyping(false);
|
|
255
|
-
|
|
368
|
+
followSend.current = true;
|
|
256
369
|
|
|
257
370
|
if (drafts.length === 0) {
|
|
258
371
|
// The failed bubble already surfaces a failure to the visitor.
|
|
@@ -261,6 +374,11 @@ export function ChatView({
|
|
|
261
374
|
}
|
|
262
375
|
|
|
263
376
|
const sent = drafts;
|
|
377
|
+
// Named by its token while that lasts; otherwise the file goes up again
|
|
378
|
+
// as the message is sent.
|
|
379
|
+
const sendBy = Date.now() + UPLOAD_EXPIRY_MARGIN_MS;
|
|
380
|
+
const stillUploaded = (draft: DraftAttachment) =>
|
|
381
|
+
!!draft.uploadToken && (draft.uploadExpiresAt ?? Infinity) > sendBy;
|
|
264
382
|
setSending(true);
|
|
265
383
|
showNotice(null);
|
|
266
384
|
onSendMessage(
|
|
@@ -268,6 +386,8 @@ export function ChatView({
|
|
|
268
386
|
sent.map((draft) => ({
|
|
269
387
|
name: draft.name,
|
|
270
388
|
url: draft.dataUrl,
|
|
389
|
+
fileUri: draft.fileUri,
|
|
390
|
+
uploadToken: stillUploaded(draft) ? draft.uploadToken : undefined,
|
|
271
391
|
contentType: draft.contentType,
|
|
272
392
|
size: draft.size,
|
|
273
393
|
previewUrl: draft.previewUri,
|
|
@@ -286,6 +406,17 @@ export function ChatView({
|
|
|
286
406
|
setInput((current) => current || trimmed);
|
|
287
407
|
const status = (error as { status?: unknown } | null)?.status;
|
|
288
408
|
showNotice(status === 413 ? labels.attach.tooLargeTogether : labels.failedToSend);
|
|
409
|
+
// An upload expired before this phone's clock said it would: put the
|
|
410
|
+
// files sent by token up again, once. After a lost connection the
|
|
411
|
+
// uploads still stand for the next try.
|
|
412
|
+
if (status === UPLOAD_EXPIRED_STATUS) {
|
|
413
|
+
sent
|
|
414
|
+
.filter((draft) => stillUploaded(draft) && !reuploaded.current.has(draft.id))
|
|
415
|
+
.forEach((draft) => {
|
|
416
|
+
reuploaded.current.add(draft.id);
|
|
417
|
+
startUpload(draft);
|
|
418
|
+
});
|
|
419
|
+
}
|
|
289
420
|
})
|
|
290
421
|
.finally(() => {
|
|
291
422
|
if (mounted.current) setSending(false);
|
|
@@ -314,10 +445,10 @@ export function ChatView({
|
|
|
314
445
|
phone: stringPayload(action.payload.phone),
|
|
315
446
|
});
|
|
316
447
|
}
|
|
448
|
+
followSend.current = true;
|
|
317
449
|
onSendMessage(formatAgentUiActionMessage(action), undefined, action).catch(() => {});
|
|
318
|
-
scrollToBottom();
|
|
319
450
|
},
|
|
320
|
-
[onIdentifyVisitor, onSendMessage
|
|
451
|
+
[onIdentifyVisitor, onSendMessage]
|
|
321
452
|
);
|
|
322
453
|
|
|
323
454
|
const handleSubmitProcedureForm = useCallback(
|
|
@@ -325,6 +456,16 @@ export function ChatView({
|
|
|
325
456
|
[onSendMessage]
|
|
326
457
|
);
|
|
327
458
|
|
|
459
|
+
// The retried bubble moves to the end of the thread; follow it there.
|
|
460
|
+
// A second failure shows on the bubble again.
|
|
461
|
+
const handleRetryMessage = useCallback(
|
|
462
|
+
(messageId: string) => {
|
|
463
|
+
followSend.current = true;
|
|
464
|
+
onRetryMessage?.(messageId).catch(() => {});
|
|
465
|
+
},
|
|
466
|
+
[onRetryMessage]
|
|
467
|
+
);
|
|
468
|
+
|
|
328
469
|
// Recomputed only when the list changes — never on a keystroke.
|
|
329
470
|
const thread = useMemo(() => deriveThread(messages), [messages]);
|
|
330
471
|
const locales = useMemo(() => [locale, defaultLocale], [locale, defaultLocale]);
|
|
@@ -344,6 +485,169 @@ export function ChatView({
|
|
|
344
485
|
const headerGradient = [color, mixHex(color, '#000000', 0.16)];
|
|
345
486
|
const headerControl = { backgroundColor: withAlpha(headerTextColor, 0.16) };
|
|
346
487
|
|
|
488
|
+
// Newest first, for the inverted list; each row still compares itself with
|
|
489
|
+
// the message sent before it.
|
|
490
|
+
const rows = useMemo<ThreadRow[]>(
|
|
491
|
+
() =>
|
|
492
|
+
messages
|
|
493
|
+
.map((message, index) => {
|
|
494
|
+
const previous = index > 0 ? messages[index - 1] : undefined;
|
|
495
|
+
return {
|
|
496
|
+
message,
|
|
497
|
+
startsDay: !previous || !isSameDay(message.createdAt, previous.createdAt),
|
|
498
|
+
// Name only at the top of a run, the way every messenger does it.
|
|
499
|
+
startsRun:
|
|
500
|
+
!previous ||
|
|
501
|
+
previous.direction === 'inbound' ||
|
|
502
|
+
previous.messageType === 'system' ||
|
|
503
|
+
previous.senderName !== message.senderName,
|
|
504
|
+
};
|
|
505
|
+
})
|
|
506
|
+
.reverse(),
|
|
507
|
+
[messages]
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
useEffect(() => {
|
|
511
|
+
if (!followSend.current) return;
|
|
512
|
+
followSend.current = false;
|
|
513
|
+
scrollToBottom();
|
|
514
|
+
}, [rows, scrollToBottom]);
|
|
515
|
+
|
|
516
|
+
// The thread's message ids when the visitor scrolled away from the newest,
|
|
517
|
+
// null while it's in view. Teammate replies newer than all of them are
|
|
518
|
+
// counted on the button. A set, not the newest id alone: that may be the
|
|
519
|
+
// visitor's own bubble, whose temporary id changes once the server has it.
|
|
520
|
+
const [awayFrom, setAwayFrom] = useState<ReadonlySet<string> | null>(null);
|
|
521
|
+
const rowIds = useRef<string[]>([]);
|
|
522
|
+
useEffect(() => {
|
|
523
|
+
rowIds.current = rows.map((row) => row.message.id);
|
|
524
|
+
}, [rows]);
|
|
525
|
+
|
|
526
|
+
const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
527
|
+
// Inverted: the offset is the distance up from the newest message.
|
|
528
|
+
const away = event.nativeEvent.contentOffset.y > JUMP_TO_LATEST_OFFSET;
|
|
529
|
+
setAwayFrom((current) => (away ? (current ?? new Set(rowIds.current)) : null));
|
|
530
|
+
}, []);
|
|
531
|
+
|
|
532
|
+
const unseenReplies = useMemo(() => {
|
|
533
|
+
if (!awayFrom) return 0;
|
|
534
|
+
let count = 0;
|
|
535
|
+
for (const { message } of rows) {
|
|
536
|
+
if (awayFrom.has(message.id)) break;
|
|
537
|
+
if (message.direction !== 'inbound' && message.messageType !== 'system') count += 1;
|
|
538
|
+
}
|
|
539
|
+
return count;
|
|
540
|
+
}, [awayFrom, rows]);
|
|
541
|
+
|
|
542
|
+
const handleJumpToLatest = useCallback(() => scrollToBottom(), [scrollToBottom]);
|
|
543
|
+
|
|
544
|
+
// Android only: it detaches rows scrolled out of view, where a large photo
|
|
545
|
+
// still downloading would never finish and keep its turn. Keyed as in usePhotoSource.
|
|
546
|
+
const handleViewableItemsChanged = useCallback(
|
|
547
|
+
({ viewableItems }: { viewableItems: Array<ViewToken<ThreadRow>> }) =>
|
|
548
|
+
largeImageQueue.setOnScreen(
|
|
549
|
+
viewableItems.flatMap(({ item }) =>
|
|
550
|
+
(item.message.attachments ?? []).map((attachment) => attachment.id || attachment.url)
|
|
551
|
+
)
|
|
552
|
+
),
|
|
553
|
+
[largeImageQueue]
|
|
554
|
+
);
|
|
555
|
+
|
|
556
|
+
const renderRow = useCallback<ListRenderItem<ThreadRow>>(
|
|
557
|
+
({ item: { message, startsDay, startsRun } }) => {
|
|
558
|
+
const isAgentMessage = message.direction !== 'inbound' && message.messageType !== 'system';
|
|
559
|
+
return (
|
|
560
|
+
<View style={styles.threadRow}>
|
|
561
|
+
{startsDay ? (
|
|
562
|
+
<View style={styles.daySeparator}>
|
|
563
|
+
<View style={styles.dayLine} />
|
|
564
|
+
<Text style={styles.dayLabel}>
|
|
565
|
+
{formatDayLabel(message.createdAt, locale, labels)}
|
|
566
|
+
</Text>
|
|
567
|
+
<View style={styles.dayLine} />
|
|
568
|
+
</View>
|
|
569
|
+
) : null}
|
|
570
|
+
{/* One malformed message (agent blocks, markdown) drops out of
|
|
571
|
+
the thread instead of blanking the whole chat. */}
|
|
572
|
+
<WidgetErrorBoundary fallback={null}>
|
|
573
|
+
<MessageBubble
|
|
574
|
+
message={message}
|
|
575
|
+
color={color}
|
|
576
|
+
labels={labels}
|
|
577
|
+
onOpenImage={setViewerGallery}
|
|
578
|
+
onRateConversation={onRateConversation}
|
|
579
|
+
locales={locales}
|
|
580
|
+
senderLabel={
|
|
581
|
+
isAgentMessage && (startsRun || startsDay)
|
|
582
|
+
? message.senderName || agentName || labels.team
|
|
583
|
+
: null
|
|
584
|
+
}
|
|
585
|
+
timeLabel={formatClockTime(message.createdAt, locale)}
|
|
586
|
+
receiptLabel={
|
|
587
|
+
message.id === thread.lastVisitorMessageId
|
|
588
|
+
? receiptLabel(message, labels)
|
|
589
|
+
: undefined
|
|
590
|
+
}
|
|
591
|
+
onSubmitProcedureForm={
|
|
592
|
+
message.id === thread.procedureFormMessageId ? handleSubmitProcedureForm : undefined
|
|
593
|
+
}
|
|
594
|
+
onAgentUiAction={
|
|
595
|
+
thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined
|
|
596
|
+
}
|
|
597
|
+
onRetry={onRetryMessage && canRetryMessage(message) ? handleRetryMessage : undefined}
|
|
598
|
+
photoAlbums={photoAlbums}
|
|
599
|
+
/>
|
|
600
|
+
</WidgetErrorBoundary>
|
|
601
|
+
</View>
|
|
602
|
+
);
|
|
603
|
+
},
|
|
604
|
+
[
|
|
605
|
+
agentName,
|
|
606
|
+
color,
|
|
607
|
+
handleAgentUiAction,
|
|
608
|
+
handleRetryMessage,
|
|
609
|
+
handleSubmitProcedureForm,
|
|
610
|
+
labels,
|
|
611
|
+
locale,
|
|
612
|
+
locales,
|
|
613
|
+
onRateConversation,
|
|
614
|
+
onRetryMessage,
|
|
615
|
+
photoAlbums,
|
|
616
|
+
thread,
|
|
617
|
+
]
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
const loadEarlierRow = hasMoreMessages ? (
|
|
621
|
+
<Pressable onPress={handleLoadEarlier} accessibilityRole="button" style={styles.loadMore}>
|
|
622
|
+
<Text style={[styles.loadMoreLabel, { color }]}>
|
|
623
|
+
{loading ? labels.loading : labels.loadEarlier}
|
|
624
|
+
</Text>
|
|
625
|
+
</Pressable>
|
|
626
|
+
) : null;
|
|
627
|
+
|
|
628
|
+
// Under the newest message.
|
|
629
|
+
const threadTail =
|
|
630
|
+
awaitingTeammate || agentTyping ? (
|
|
631
|
+
<View style={styles.threadTail}>
|
|
632
|
+
{awaitingTeammate ? (
|
|
633
|
+
<View style={styles.waitingRow}>
|
|
634
|
+
<TeamAvatars avatars={teamAvatars} size={20} color={color} />
|
|
635
|
+
<Text style={styles.waitingText}>{labels.waitingForTeammate}</Text>
|
|
636
|
+
</View>
|
|
637
|
+
) : null}
|
|
638
|
+
{agentTyping ? (
|
|
639
|
+
<View style={styles.typingRow}>
|
|
640
|
+
<View style={styles.typingBubble}>
|
|
641
|
+
<TypingDot index={0} reduceMotion={reduceMotion} />
|
|
642
|
+
<TypingDot index={1} reduceMotion={reduceMotion} />
|
|
643
|
+
<TypingDot index={2} reduceMotion={reduceMotion} />
|
|
644
|
+
</View>
|
|
645
|
+
<Text style={styles.typingLabel}>{labels.thinking}</Text>
|
|
646
|
+
</View>
|
|
647
|
+
) : null}
|
|
648
|
+
</View>
|
|
649
|
+
) : null;
|
|
650
|
+
|
|
347
651
|
return (
|
|
348
652
|
<View style={globalStyles.screen}>
|
|
349
653
|
{/* One bar instead of a brand band above a separate back row: the band
|
|
@@ -435,116 +739,89 @@ export function ChatView({
|
|
|
435
739
|
</Pressable>
|
|
436
740
|
</GradientBackground>
|
|
437
741
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
<Text style={styles.dayLabel}>
|
|
490
|
-
{formatDayLabel(message.createdAt, locale, labels)}
|
|
742
|
+
{messages.length > 0 ? (
|
|
743
|
+
<View style={globalStyles.fill}>
|
|
744
|
+
<ImageQueueContext.Provider value={largeImageQueue}>
|
|
745
|
+
<FlatList
|
|
746
|
+
ref={listRef}
|
|
747
|
+
data={rows}
|
|
748
|
+
inverted
|
|
749
|
+
keyExtractor={rowKey}
|
|
750
|
+
renderItem={renderRow}
|
|
751
|
+
style={globalStyles.fill}
|
|
752
|
+
contentContainerStyle={styles.threadContent}
|
|
753
|
+
ItemSeparatorComponent={ThreadGap}
|
|
754
|
+
// Inverted, the header sits under the newest message and the footer
|
|
755
|
+
// above the oldest.
|
|
756
|
+
ListHeaderComponent={threadTail}
|
|
757
|
+
ListHeaderComponentStyle={styles.threadTailInList}
|
|
758
|
+
ListFooterComponent={loadEarlierRow}
|
|
759
|
+
onEndReached={handleLoadEarlier}
|
|
760
|
+
onEndReachedThreshold={0.5}
|
|
761
|
+
onScroll={handleScroll}
|
|
762
|
+
scrollEventThrottle={100}
|
|
763
|
+
// A reply arriving below doesn't move what a visitor reads further
|
|
764
|
+
// up; near the newest message the list follows it into view.
|
|
765
|
+
maintainVisibleContentPosition={{
|
|
766
|
+
minIndexForVisible: 0,
|
|
767
|
+
autoscrollToTopThreshold: JUMP_TO_LATEST_OFFSET,
|
|
768
|
+
}}
|
|
769
|
+
onViewableItemsChanged={Platform.OS === 'android' ? handleViewableItemsChanged : undefined}
|
|
770
|
+
keyboardShouldPersistTaps="handled"
|
|
771
|
+
// Interactive dismissal drags the keyboard down under the finger while
|
|
772
|
+
// the composer stays where the last keyboard event left it, which opens
|
|
773
|
+
// a gap under the thread; on-drag hands us a normal hide event instead.
|
|
774
|
+
keyboardDismissMode="on-drag"
|
|
775
|
+
/>
|
|
776
|
+
</ImageQueueContext.Provider>
|
|
777
|
+
{awayFrom ? (
|
|
778
|
+
<Pressable
|
|
779
|
+
onPress={handleJumpToLatest}
|
|
780
|
+
accessibilityRole="button"
|
|
781
|
+
accessibilityLabel={
|
|
782
|
+
unseenReplies
|
|
783
|
+
? formatLabel(labels.jumpToLatestNew, { count: unseenReplies })
|
|
784
|
+
: labels.jumpToLatest
|
|
785
|
+
}
|
|
786
|
+
hitSlop={8}
|
|
787
|
+
style={({ pressed }) => [styles.jumpToLatest, pressed && globalStyles.pressed]}>
|
|
788
|
+
<ChevronIcon size={18} direction="down" color={palette.text} />
|
|
789
|
+
{unseenReplies ? (
|
|
790
|
+
<View style={[styles.jumpBadge, { backgroundColor: color }]}>
|
|
791
|
+
<Text style={[styles.jumpBadgeText, { color: contrastingTextColor(color) }]}>
|
|
792
|
+
{unseenReplies > 9 ? '9+' : unseenReplies}
|
|
491
793
|
</Text>
|
|
492
|
-
<View style={styles.dayLine} />
|
|
493
794
|
</View>
|
|
494
795
|
) : null}
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
? handleSubmitProcedureForm
|
|
519
|
-
: undefined
|
|
520
|
-
}
|
|
521
|
-
onAgentUiAction={
|
|
522
|
-
thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined
|
|
523
|
-
}
|
|
524
|
-
/>
|
|
525
|
-
</WidgetErrorBoundary>
|
|
526
|
-
</Fragment>
|
|
527
|
-
);
|
|
528
|
-
})}
|
|
529
|
-
|
|
530
|
-
{awaitingTeammate ? (
|
|
531
|
-
<View style={styles.waitingRow}>
|
|
532
|
-
<TeamAvatars avatars={teamAvatars} size={20} color={color} />
|
|
533
|
-
<Text style={styles.waitingText}>{labels.waitingForTeammate}</Text>
|
|
534
|
-
</View>
|
|
535
|
-
) : null}
|
|
536
|
-
|
|
537
|
-
{agentTyping ? (
|
|
538
|
-
<View style={styles.typingRow}>
|
|
539
|
-
<View style={styles.typingBubble}>
|
|
540
|
-
<TypingDot index={0} reduceMotion={reduceMotion} />
|
|
541
|
-
<TypingDot index={1} reduceMotion={reduceMotion} />
|
|
542
|
-
<TypingDot index={2} reduceMotion={reduceMotion} />
|
|
796
|
+
</Pressable>
|
|
797
|
+
) : null}
|
|
798
|
+
</View>
|
|
799
|
+
) : (
|
|
800
|
+
<ScrollView
|
|
801
|
+
style={globalStyles.fill}
|
|
802
|
+
contentContainerStyle={[
|
|
803
|
+
styles.messagesContent,
|
|
804
|
+
isEmptyThread && styles.messagesContentEmpty,
|
|
805
|
+
isLoadingThread && styles.messagesContentLoading,
|
|
806
|
+
]}
|
|
807
|
+
keyboardShouldPersistTaps="handled"
|
|
808
|
+
keyboardDismissMode="on-drag">
|
|
809
|
+
{loadEarlierRow}
|
|
810
|
+
|
|
811
|
+
{isLoadingThread ? <MessagesSkeleton color={color} label={labels.loading} /> : null}
|
|
812
|
+
|
|
813
|
+
{isEmptyThread ? (
|
|
814
|
+
<View style={styles.emptyState}>
|
|
815
|
+
<View style={[styles.emptyIcon, { backgroundColor: withAlpha(color, 0.08) }]}>
|
|
816
|
+
<ChatOutlineIcon size={28} color={color} />
|
|
817
|
+
</View>
|
|
818
|
+
<Text style={styles.emptyText}>{labels.emptyConversation}</Text>
|
|
543
819
|
</View>
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
820
|
+
) : null}
|
|
821
|
+
|
|
822
|
+
{threadTail}
|
|
823
|
+
</ScrollView>
|
|
824
|
+
)}
|
|
548
825
|
|
|
549
826
|
{offlineMessage ? (
|
|
550
827
|
<View style={styles.offlineBanner}>
|
|
@@ -565,11 +842,19 @@ export function ChatView({
|
|
|
565
842
|
<AttachmentChips
|
|
566
843
|
attachments={drafts}
|
|
567
844
|
disabled={sending}
|
|
568
|
-
|
|
845
|
+
labels={labels.attach}
|
|
569
846
|
onRemove={(id) => {
|
|
847
|
+
uploads.current.get(id)?.cancel();
|
|
848
|
+
uploads.current.delete(id);
|
|
570
849
|
setDrafts((current) => current.filter((draft) => draft.id !== id));
|
|
571
850
|
showNotice(null);
|
|
572
851
|
}}
|
|
852
|
+
onRetry={(id) => {
|
|
853
|
+
const draft = drafts.find((item) => item.id === id);
|
|
854
|
+
if (!draft) return;
|
|
855
|
+
showNotice(null);
|
|
856
|
+
startUpload(draft);
|
|
857
|
+
}}
|
|
573
858
|
/>
|
|
574
859
|
) : null}
|
|
575
860
|
<View
|
|
@@ -592,7 +877,7 @@ export function ChatView({
|
|
|
592
877
|
styles.attachButton,
|
|
593
878
|
(pressed || sending || picking) && globalStyles.pressed,
|
|
594
879
|
]}>
|
|
595
|
-
<
|
|
880
|
+
<PlusIcon size={20} color={palette.textMuted} />
|
|
596
881
|
</Pressable>
|
|
597
882
|
) : null}
|
|
598
883
|
<TextInput
|
|
@@ -610,6 +895,7 @@ export function ChatView({
|
|
|
610
895
|
onPress={handleSend}
|
|
611
896
|
disabled={!canSend}
|
|
612
897
|
accessibilityRole="button"
|
|
898
|
+
accessibilityLabel={labels.send}
|
|
613
899
|
accessibilityState={{ disabled: !canSend }}
|
|
614
900
|
hitSlop={8}
|
|
615
901
|
style={({ pressed }) => [
|
|
@@ -635,11 +921,11 @@ export function ChatView({
|
|
|
635
921
|
/>
|
|
636
922
|
) : null}
|
|
637
923
|
<ImageViewer
|
|
638
|
-
|
|
924
|
+
gallery={viewerGallery}
|
|
639
925
|
labels={labels.viewer}
|
|
640
|
-
onClose={() =>
|
|
926
|
+
onClose={() => setViewerGallery(null)}
|
|
641
927
|
onOpenExternally={(url) => {
|
|
642
|
-
|
|
928
|
+
setViewerGallery(null);
|
|
643
929
|
void openExternalUrl(url);
|
|
644
930
|
}}
|
|
645
931
|
/>
|
|
@@ -663,6 +949,8 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
663
949
|
onOpenImage,
|
|
664
950
|
onRateConversation,
|
|
665
951
|
locales,
|
|
952
|
+
onRetry,
|
|
953
|
+
photoAlbums,
|
|
666
954
|
}: {
|
|
667
955
|
message: Message;
|
|
668
956
|
color: string;
|
|
@@ -672,9 +960,13 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
672
960
|
receiptLabel?: string;
|
|
673
961
|
onSubmitProcedureForm?: (body: string) => void;
|
|
674
962
|
onAgentUiAction?: (action: AgentUiAction) => void | Promise<void>;
|
|
675
|
-
onOpenImage: (
|
|
963
|
+
onOpenImage: (gallery: ViewerGallery) => void;
|
|
676
964
|
onRateConversation?: ChatViewProps['onRateConversation'];
|
|
677
965
|
locales: Array<string | null | undefined>;
|
|
966
|
+
/** Re-sends this message when it failed. */
|
|
967
|
+
onRetry?: (messageId: string) => void;
|
|
968
|
+
/** Off: each photo shows on its own, full width. */
|
|
969
|
+
photoAlbums: boolean;
|
|
678
970
|
}) {
|
|
679
971
|
const joinedName = message.metadata?.event === 'agent_joined' ? message.metadata?.agentName : null;
|
|
680
972
|
if (message.messageType === 'system' && typeof joinedName === 'string' && joinedName) {
|
|
@@ -737,6 +1029,54 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
737
1029
|
const procedureForm = !isVisitor ? getProcedureForm(message.metadata) : null;
|
|
738
1030
|
const agentUiBlocks = !isVisitor ? getAgentUiBlocks(message.metadata) : [];
|
|
739
1031
|
const textColor = isVisitor ? palette.white : palette.ink;
|
|
1032
|
+
// A reply with images between its paragraphs shows them where the teammate put them.
|
|
1033
|
+
const inlineParts = isVisitor ? null : splitInlineAttachments(message.body, message.attachments);
|
|
1034
|
+
const files = inlineParts ? inlineParts.files : (message.attachments ?? []);
|
|
1035
|
+
// Two or more photos share one album; any other files follow it.
|
|
1036
|
+
const photos = files.filter((attachment) => isImageAttachment(attachment.contentType));
|
|
1037
|
+
const album = photoAlbums && photos.length > 1 ? photos : null;
|
|
1038
|
+
const sentAt = Date.parse(message.createdAt) || 0;
|
|
1039
|
+
// A tap on one of the message's photos opens them all, at that one, in the
|
|
1040
|
+
// order the bubble shows them.
|
|
1041
|
+
const openPhotos = (tapped: { id: string; url: string; name: string }) => {
|
|
1042
|
+
const photos = [
|
|
1043
|
+
...(inlineParts?.parts.flatMap((part) => ('attachment' in part ? [part.attachment] : [])) ?? []),
|
|
1044
|
+
...files,
|
|
1045
|
+
]
|
|
1046
|
+
.filter((attachment) => isImageAttachment(attachment.contentType))
|
|
1047
|
+
.map((attachment) => {
|
|
1048
|
+
const url =
|
|
1049
|
+
attachment.id === tapped.id ? tapped.url : localPreviewFor(attachment.url) || attachment.url;
|
|
1050
|
+
// The phone's copy may be gone by now; the stored one stands in.
|
|
1051
|
+
const fallbackUrl = url !== attachment.url && isHttpUrl(attachment.url) ? attachment.url : undefined;
|
|
1052
|
+
return {
|
|
1053
|
+
id: attachment.id,
|
|
1054
|
+
url,
|
|
1055
|
+
name: attachment.name,
|
|
1056
|
+
large: isLargeDownload(url, attachment.size),
|
|
1057
|
+
fallbackUrl,
|
|
1058
|
+
};
|
|
1059
|
+
})
|
|
1060
|
+
.filter((photo) => !!photo.url);
|
|
1061
|
+
const index = photos.findIndex((photo) => photo.id === tapped.id);
|
|
1062
|
+
onOpenImage(
|
|
1063
|
+
index < 0
|
|
1064
|
+
? { images: [tapped], index: 0 }
|
|
1065
|
+
: { images: photos.map(({ url, name, large, fallbackUrl }) => ({ url, name, large, fallbackUrl })), index }
|
|
1066
|
+
);
|
|
1067
|
+
};
|
|
1068
|
+
const renderAttachment = (attachment: WidgetAttachment) => (
|
|
1069
|
+
<AttachmentPreview
|
|
1070
|
+
key={attachment.id}
|
|
1071
|
+
loadPriority={sentAt}
|
|
1072
|
+
attachment={attachment}
|
|
1073
|
+
isVisitor={isVisitor}
|
|
1074
|
+
color={color}
|
|
1075
|
+
voiceLabels={labels.voice}
|
|
1076
|
+
openLabel={labels.openAttachment}
|
|
1077
|
+
onOpenImage={openPhotos}
|
|
1078
|
+
/>
|
|
1079
|
+
);
|
|
740
1080
|
|
|
741
1081
|
return (
|
|
742
1082
|
<View style={[styles.messageRow, isVisitor ? styles.messageRowRight : styles.messageRowLeft]}>
|
|
@@ -748,7 +1088,17 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
748
1088
|
rtl && (isVisitor ? styles.bubbleVisitorRTL : styles.bubbleAgentRTL),
|
|
749
1089
|
message.status === 'pending' && styles.bubblePending,
|
|
750
1090
|
]}>
|
|
751
|
-
{
|
|
1091
|
+
{inlineParts ? (
|
|
1092
|
+
<View style={styles.inlineParts}>
|
|
1093
|
+
{inlineParts.parts.map((part, index) =>
|
|
1094
|
+
'text' in part ? (
|
|
1095
|
+
<Markdown key={`text-${index}`} content={part.text} color={textColor} />
|
|
1096
|
+
) : (
|
|
1097
|
+
renderAttachment(part.attachment)
|
|
1098
|
+
)
|
|
1099
|
+
)}
|
|
1100
|
+
</View>
|
|
1101
|
+
) : hasBody ? (
|
|
752
1102
|
isVisitor ? (
|
|
753
1103
|
<Text style={[styles.bubbleText, { color: textColor }]}>{messageText}</Text>
|
|
754
1104
|
) : (
|
|
@@ -756,32 +1106,23 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
756
1106
|
)
|
|
757
1107
|
) : null}
|
|
758
1108
|
|
|
759
|
-
{
|
|
760
|
-
<View
|
|
761
|
-
{
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
openLabel={labels.openAttachment}
|
|
769
|
-
onOpenImage={onOpenImage}
|
|
1109
|
+
{files.length > 0 ? (
|
|
1110
|
+
<View
|
|
1111
|
+
style={[styles.attachments, (hasBody || !!inlineParts) && styles.attachmentsSpaced]}>
|
|
1112
|
+
{album ? (
|
|
1113
|
+
<PhotoAlbum
|
|
1114
|
+
photos={album}
|
|
1115
|
+
loadPriority={sentAt}
|
|
1116
|
+
moreLabel={labels.morePhotos}
|
|
1117
|
+
onOpen={openPhotos}
|
|
770
1118
|
/>
|
|
771
|
-
)
|
|
1119
|
+
) : null}
|
|
1120
|
+
{(album ? files.filter((attachment) => !album.includes(attachment)) : files).map(
|
|
1121
|
+
renderAttachment
|
|
1122
|
+
)}
|
|
772
1123
|
</View>
|
|
773
1124
|
) : null}
|
|
774
1125
|
|
|
775
|
-
{message.status === 'failed' ? (
|
|
776
|
-
<Text
|
|
777
|
-
style={[
|
|
778
|
-
styles.failedText,
|
|
779
|
-
{ color: isVisitor ? withAlpha(palette.white, 0.7) : palette.danger },
|
|
780
|
-
]}>
|
|
781
|
-
{labels.failedToSend}
|
|
782
|
-
</Text>
|
|
783
|
-
) : null}
|
|
784
|
-
|
|
785
1126
|
{timeLabel ? (
|
|
786
1127
|
<Text
|
|
787
1128
|
style={[
|
|
@@ -795,6 +1136,22 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
795
1136
|
|
|
796
1137
|
{receiptLabel ? <Text style={styles.receiptText}>{receiptLabel}</Text> : null}
|
|
797
1138
|
|
|
1139
|
+
{/* Under the bubble, where Sent/Seen would be: in the danger color it
|
|
1140
|
+
reads on any brand color, and the whole line is the retry target. */}
|
|
1141
|
+
{message.status === 'failed' ? (
|
|
1142
|
+
<Pressable
|
|
1143
|
+
onPress={onRetry ? () => onRetry(message.id) : undefined}
|
|
1144
|
+
disabled={!onRetry}
|
|
1145
|
+
accessibilityRole={onRetry ? 'button' : 'text'}
|
|
1146
|
+
hitSlop={8}
|
|
1147
|
+
style={({ pressed }) => [styles.failedRow, pressed && globalStyles.pressed]}>
|
|
1148
|
+
<AlertIcon size={12} color={palette.danger} />
|
|
1149
|
+
<Text style={styles.failedText}>
|
|
1150
|
+
{onRetry ? `${labels.failedToSend} · ${labels.tapToRetry}` : labels.failedToSend}
|
|
1151
|
+
</Text>
|
|
1152
|
+
</Pressable>
|
|
1153
|
+
) : null}
|
|
1154
|
+
|
|
798
1155
|
{agentUiBlocks.length > 0 && onAgentUiAction ? (
|
|
799
1156
|
<AgentUiBlocks
|
|
800
1157
|
blocks={agentUiBlocks}
|
|
@@ -806,6 +1163,7 @@ const MessageBubble = memo(function MessageBubble({
|
|
|
806
1163
|
|
|
807
1164
|
{agentUiBlocks.length === 0 && procedureForm && onSubmitProcedureForm ? (
|
|
808
1165
|
<ProcedureFieldForm
|
|
1166
|
+
messageId={message.id}
|
|
809
1167
|
form={procedureForm}
|
|
810
1168
|
color={color}
|
|
811
1169
|
labels={labels}
|
|
@@ -822,8 +1180,181 @@ function receiptLabel(message: Message, labels: ChatLabels): string | undefined
|
|
|
822
1180
|
return message.status === 'read' ? labels.messageSeen : labels.messageSent;
|
|
823
1181
|
}
|
|
824
1182
|
|
|
1183
|
+
/**
|
|
1184
|
+
* Where a photo shows from, and whether it may load yet. `url` is empty when
|
|
1185
|
+
* the server sent a non-http(s) URL (see resolveUrl). The visitor's own
|
|
1186
|
+
* attachment points at the picked file until the server copy replaces it (or
|
|
1187
|
+
* is empty without one). A photo they sent this session keeps showing from
|
|
1188
|
+
* the phone after that: the stored copy may not load yet. Whichever fails to
|
|
1189
|
+
* load gives way to the next.
|
|
1190
|
+
*/
|
|
1191
|
+
function usePhotoSource(attachment: WidgetAttachment, loadPriority: number) {
|
|
1192
|
+
const [failedUrls, setFailedUrls] = useState<string[]>([]);
|
|
1193
|
+
const [loadedUrl, setLoadedUrl] = useState<string | null>(null);
|
|
1194
|
+
const url = [localPreviewFor(attachment.url), attachment.url].find(
|
|
1195
|
+
(candidate): candidate is string => !!candidate && !failedUrls.includes(candidate)
|
|
1196
|
+
);
|
|
1197
|
+
const isPhoto = isImageAttachment(attachment.contentType) && !!url;
|
|
1198
|
+
// A large download waits its turn; small photos and the phone's own copy don't.
|
|
1199
|
+
const [turn, settled] = useImageTurn(
|
|
1200
|
+
attachment.id || attachment.url,
|
|
1201
|
+
loadPriority,
|
|
1202
|
+
isPhoto && isLargeDownload(url!, attachment.size)
|
|
1203
|
+
);
|
|
1204
|
+
return {
|
|
1205
|
+
url,
|
|
1206
|
+
isPhoto,
|
|
1207
|
+
turn,
|
|
1208
|
+
/** Waiting for its turn or still downloading; the next URL after a failure starts over. */
|
|
1209
|
+
loading: isPhoto && loadedUrl !== url,
|
|
1210
|
+
onLoad: () => {
|
|
1211
|
+
settled();
|
|
1212
|
+
if (url) setLoadedUrl(url);
|
|
1213
|
+
},
|
|
1214
|
+
onError: () => {
|
|
1215
|
+
settled();
|
|
1216
|
+
if (!url) return;
|
|
1217
|
+
setFailedUrls((current) => [...current, url]);
|
|
1218
|
+
// The viewer and the message's other views stop offering it too.
|
|
1219
|
+
if (url !== attachment.url && url === localPreviewFor(attachment.url)) forgetLocalPreview(attachment.url);
|
|
1220
|
+
},
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Several photos in one message, the way WhatsApp shows them: one square,
|
|
1226
|
+
* two side by side, three as one tall beside two stacked, four in a grid, and
|
|
1227
|
+
* past four the last tile covered with how many more there are.
|
|
1228
|
+
*/
|
|
1229
|
+
function PhotoAlbum({
|
|
1230
|
+
photos,
|
|
1231
|
+
loadPriority,
|
|
1232
|
+
moreLabel,
|
|
1233
|
+
onOpen,
|
|
1234
|
+
}: {
|
|
1235
|
+
photos: WidgetAttachment[];
|
|
1236
|
+
loadPriority: number;
|
|
1237
|
+
/** Keeps `{count}`. */
|
|
1238
|
+
moreLabel: string;
|
|
1239
|
+
onOpen: (image: { id: string; url: string; name: string }) => void;
|
|
1240
|
+
}) {
|
|
1241
|
+
const shown = photos.slice(0, 4);
|
|
1242
|
+
// The covered tile counts too: it's no more visible than the ones after it.
|
|
1243
|
+
const more = photos.length > 4 ? photos.length - 3 : 0;
|
|
1244
|
+
const tile = (attachment: WidgetAttachment, index: number, size: StyleProp<ViewStyle>) => (
|
|
1245
|
+
<PhotoTile
|
|
1246
|
+
key={attachment.id}
|
|
1247
|
+
attachment={attachment}
|
|
1248
|
+
loadPriority={loadPriority}
|
|
1249
|
+
style={size}
|
|
1250
|
+
more={index === 3 ? more : 0}
|
|
1251
|
+
moreLabel={moreLabel}
|
|
1252
|
+
onOpen={onOpen}
|
|
1253
|
+
/>
|
|
1254
|
+
);
|
|
1255
|
+
|
|
1256
|
+
if (shown.length === 2) {
|
|
1257
|
+
return <View style={[styles.album, styles.albumRow]}>{shown.map((photo, i) => tile(photo, i, styles.albumHalf))}</View>;
|
|
1258
|
+
}
|
|
1259
|
+
if (shown.length === 3) {
|
|
1260
|
+
return (
|
|
1261
|
+
<View style={[styles.album, styles.albumRow]}>
|
|
1262
|
+
{tile(shown[0]!, 0, styles.albumHalf)}
|
|
1263
|
+
<View style={styles.albumColumn}>
|
|
1264
|
+
{shown.slice(1).map((photo, i) => tile(photo, i + 1, styles.albumQuarter))}
|
|
1265
|
+
</View>
|
|
1266
|
+
</View>
|
|
1267
|
+
);
|
|
1268
|
+
}
|
|
1269
|
+
return <View style={[styles.album, styles.albumGrid]}>{shown.map((photo, i) => tile(photo, i, styles.albumQuarter))}</View>;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function PhotoTile({
|
|
1273
|
+
attachment,
|
|
1274
|
+
loadPriority,
|
|
1275
|
+
style,
|
|
1276
|
+
more,
|
|
1277
|
+
moreLabel,
|
|
1278
|
+
onOpen,
|
|
1279
|
+
}: {
|
|
1280
|
+
attachment: WidgetAttachment;
|
|
1281
|
+
loadPriority: number;
|
|
1282
|
+
style: StyleProp<ViewStyle>;
|
|
1283
|
+
/** Photos past this tile, shown over it; 0 for none. */
|
|
1284
|
+
more: number;
|
|
1285
|
+
moreLabel: string;
|
|
1286
|
+
onOpen: (image: { id: string; url: string; name: string }) => void;
|
|
1287
|
+
}) {
|
|
1288
|
+
const photo = usePhotoSource(attachment, loadPriority);
|
|
1289
|
+
// One that can't load still opens, so the viewer can offer the browser.
|
|
1290
|
+
const openUrl = photo.url ?? (isHttpUrl(attachment.url) ? attachment.url : undefined);
|
|
1291
|
+
return (
|
|
1292
|
+
<Pressable
|
|
1293
|
+
onPress={openUrl ? () => onOpen({ id: attachment.id, url: openUrl, name: attachment.name }) : undefined}
|
|
1294
|
+
accessibilityRole="imagebutton"
|
|
1295
|
+
accessibilityLabel={
|
|
1296
|
+
more > 0 ? `${attachment.name}, ${formatLabel(moreLabel, { count: more })}` : attachment.name
|
|
1297
|
+
}
|
|
1298
|
+
style={({ pressed }) => [styles.albumTile, style, pressed && globalStyles.pressed]}>
|
|
1299
|
+
{photo.isPhoto && photo.turn ? (
|
|
1300
|
+
<Image
|
|
1301
|
+
source={{ uri: photo.url }}
|
|
1302
|
+
style={styles.albumImage}
|
|
1303
|
+
resizeMode="cover"
|
|
1304
|
+
// Decoded at the tile's size, like a single photo.
|
|
1305
|
+
resizeMethod="resize"
|
|
1306
|
+
accessibilityIgnoresInvertColors
|
|
1307
|
+
onLoad={photo.onLoad}
|
|
1308
|
+
onError={photo.onError}
|
|
1309
|
+
/>
|
|
1310
|
+
) : null}
|
|
1311
|
+
{!photo.isPhoto ? (
|
|
1312
|
+
// Nothing left to load (an expired link): its type and name, not a blank square.
|
|
1313
|
+
<View style={styles.albumFallback}>
|
|
1314
|
+
<Text style={styles.albumFallbackType}>{fileTypeLabel(attachment.name, attachment.contentType)}</Text>
|
|
1315
|
+
<Text style={styles.albumFallbackName} numberOfLines={2}>
|
|
1316
|
+
{attachment.name}
|
|
1317
|
+
</Text>
|
|
1318
|
+
</View>
|
|
1319
|
+
) : null}
|
|
1320
|
+
{/* The "+N" cover already hides this tile, so it doesn't spin too. */}
|
|
1321
|
+
<PhotoLoader loading={photo.loading && more === 0} />
|
|
1322
|
+
{more > 0 ? (
|
|
1323
|
+
<View style={styles.albumMore}>
|
|
1324
|
+
<Text style={styles.albumMoreText}>+{more}</Text>
|
|
1325
|
+
</View>
|
|
1326
|
+
) : null}
|
|
1327
|
+
</Pressable>
|
|
1328
|
+
);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* Spins over a photo until it has loaded. Held back a moment, so a photo that
|
|
1333
|
+
* comes straight from the cache never flashes it.
|
|
1334
|
+
*/
|
|
1335
|
+
function PhotoLoader({ loading }: { loading: boolean }) {
|
|
1336
|
+
const [due, setDue] = useState(false);
|
|
1337
|
+
useEffect(() => {
|
|
1338
|
+
if (!loading) return undefined;
|
|
1339
|
+
const timer = setTimeout(() => setDue(true), PHOTO_LOADER_DELAY_MS);
|
|
1340
|
+
return () => {
|
|
1341
|
+
clearTimeout(timer);
|
|
1342
|
+
setDue(false);
|
|
1343
|
+
};
|
|
1344
|
+
}, [loading]);
|
|
1345
|
+
if (!loading || !due) return null;
|
|
1346
|
+
return (
|
|
1347
|
+
<View style={styles.photoLoader} pointerEvents="none">
|
|
1348
|
+
<View style={styles.photoLoaderBadge}>
|
|
1349
|
+
<ActivityIndicator size="small" color={palette.white} />
|
|
1350
|
+
</View>
|
|
1351
|
+
</View>
|
|
1352
|
+
);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
825
1355
|
function AttachmentPreview({
|
|
826
1356
|
attachment,
|
|
1357
|
+
loadPriority,
|
|
827
1358
|
isVisitor,
|
|
828
1359
|
color,
|
|
829
1360
|
voiceLabels,
|
|
@@ -831,26 +1362,29 @@ function AttachmentPreview({
|
|
|
831
1362
|
onOpenImage,
|
|
832
1363
|
}: {
|
|
833
1364
|
attachment: WidgetAttachment;
|
|
1365
|
+
/** Among large images waiting to download, higher goes first: newer messages. */
|
|
1366
|
+
loadPriority: number;
|
|
834
1367
|
isVisitor: boolean;
|
|
835
1368
|
color: string;
|
|
836
1369
|
voiceLabels: VoiceNoteLabels;
|
|
837
1370
|
/** Keeps `{name}`. */
|
|
838
1371
|
openLabel: string;
|
|
839
|
-
|
|
1372
|
+
/** With the URL this bubble shows, which the viewer opens at. */
|
|
1373
|
+
onOpenImage: (image: { id: string; url: string; name: string }) => void;
|
|
840
1374
|
}) {
|
|
841
|
-
// `url` is empty when the server sent a non-http(s) URL (see resolveUrl).
|
|
842
|
-
// An unsent attachment shows its local file (or data URL): it renders, but
|
|
843
|
-
// there is nothing to open until the server copy arrives.
|
|
844
|
-
const [imageFailed, setImageFailed] = useState(false);
|
|
845
1375
|
const [audioFailed, setAudioFailed] = useState(false);
|
|
846
|
-
const
|
|
1376
|
+
const photo = usePhotoSource(attachment, loadPriority);
|
|
1377
|
+
const imageUrl = photo.url;
|
|
1378
|
+
const isImage = photo.isPhoto;
|
|
847
1379
|
const openable = isHttpUrl(attachment.url);
|
|
848
|
-
// Images open in the in-app viewer
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1380
|
+
// Images open in the in-app viewer, a local photo too since it already
|
|
1381
|
+
// renders here. Other files (PDF, sheets) need the OS, which can only open
|
|
1382
|
+
// the server copy.
|
|
1383
|
+
const open = isImage
|
|
1384
|
+
? () => onOpenImage({ id: attachment.id, url: imageUrl!, name: attachment.name })
|
|
1385
|
+
: openable
|
|
1386
|
+
? () => void openExternalUrl(attachment.url)
|
|
1387
|
+
: undefined;
|
|
854
1388
|
|
|
855
1389
|
// Voice notes from the team (and any other audio) play in place, like the
|
|
856
1390
|
// web Messenger — when the app has expo-audio; otherwise the file row below.
|
|
@@ -873,18 +1407,28 @@ function AttachmentPreview({
|
|
|
873
1407
|
return (
|
|
874
1408
|
<Pressable
|
|
875
1409
|
onPress={open}
|
|
876
|
-
disabled={!openable}
|
|
877
1410
|
accessibilityRole="imagebutton"
|
|
878
1411
|
accessibilityLabel={attachment.name}>
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
1412
|
+
{photo.turn ? (
|
|
1413
|
+
<Image
|
|
1414
|
+
source={{ uri: imageUrl }}
|
|
1415
|
+
style={styles.attachmentImage}
|
|
1416
|
+
resizeMode="cover"
|
|
1417
|
+
// Android otherwise decodes a server image at full size: two 20 MP
|
|
1418
|
+
// photos fill its shared bitmap budget, and every later image in
|
|
1419
|
+
// the app fails to load. Decoded at the bubble's size instead.
|
|
1420
|
+
resizeMethod="resize"
|
|
1421
|
+
accessibilityIgnoresInvertColors
|
|
1422
|
+
onLoad={photo.onLoad}
|
|
1423
|
+
// An image that can't load (expired link, deleted file) falls back to
|
|
1424
|
+
// the file row below instead of an empty box.
|
|
1425
|
+
onError={photo.onError}
|
|
1426
|
+
/>
|
|
1427
|
+
) : (
|
|
1428
|
+
// Same box as the image, so nothing moves when its turn comes.
|
|
1429
|
+
<View style={styles.attachmentImage} />
|
|
1430
|
+
)}
|
|
1431
|
+
<PhotoLoader loading={photo.loading} />
|
|
888
1432
|
</Pressable>
|
|
889
1433
|
);
|
|
890
1434
|
}
|
|
@@ -903,19 +1447,22 @@ function AttachmentPreview({
|
|
|
903
1447
|
}
|
|
904
1448
|
|
|
905
1449
|
function ProcedureFieldForm({
|
|
1450
|
+
messageId,
|
|
906
1451
|
form,
|
|
907
1452
|
color,
|
|
908
1453
|
labels,
|
|
909
1454
|
onSubmit,
|
|
910
1455
|
}: {
|
|
1456
|
+
/** Keeps what was typed while the row is scrolled far away and unmounted. */
|
|
1457
|
+
messageId: string;
|
|
911
1458
|
form: AgentProcedureForm;
|
|
912
1459
|
color: string;
|
|
913
1460
|
labels: ChatLabels;
|
|
914
1461
|
onSubmit: (body: string) => void;
|
|
915
1462
|
}) {
|
|
916
|
-
const [values, setValues] =
|
|
917
|
-
const [submitted, setSubmitted] =
|
|
918
|
-
const [expanded, setExpanded] =
|
|
1463
|
+
const [values, setValues] = useRetainedState<Record<string, string>>(`${messageId}:values`, {});
|
|
1464
|
+
const [submitted, setSubmitted] = useRetainedState(`${messageId}:submitted`, false);
|
|
1465
|
+
const [expanded, setExpanded] = useRetainedState(`${messageId}:expanded`, false);
|
|
919
1466
|
|
|
920
1467
|
const canSubmit = form.fields.every((field) => !field.required || values[field.name]?.trim());
|
|
921
1468
|
|