@repzo/widget-react-native 1.3.1 → 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 +137 -5
- package/README.md +64 -12
- package/lib/commonjs/api/client.js +96 -23
- 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/locales.js +12 -3
- package/lib/commonjs/locales.js.map +1 -1
- package/lib/commonjs/state/widget-store.js +277 -30
- 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 +637 -164
- 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 +96 -23
- 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/locales.js +12 -3
- package/lib/module/locales.js.map +1 -1
- package/lib/module/state/widget-store.js +279 -32
- 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 +641 -168
- 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 +12 -4
- 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/locales.d.ts.map +1 -1
- package/lib/typescript/commonjs/state/widget-store.d.ts +40 -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 +41 -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 +12 -4
- 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/locales.d.ts.map +1 -1
- package/lib/typescript/module/state/widget-store.d.ts +40 -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 +41 -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 +116 -34
- 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/locales.ts +12 -3
- package/src/state/widget-store.ts +322 -50
- package/src/storage.ts +2 -0
- package/src/types.ts +41 -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 +721 -197
- 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
|
@@ -4,16 +4,17 @@
|
|
|
4
4
|
* Chat thread — message list, typing indicator and composer.
|
|
5
5
|
* Port of `client/src/widget/components/ChatView.tsx`.
|
|
6
6
|
*
|
|
7
|
-
* Mobile differences:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Mobile differences: the thread is an inverted list, so it opens on the
|
|
8
|
+
* newest message and older pages load as the visitor scrolls up, and the
|
|
9
|
+
* attach button only shows when the host app has a picker to open (see
|
|
10
10
|
* `attachments/pickers.ts`).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import {
|
|
14
|
-
import { Animated, Easing, Image, Platform, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
|
13
|
+
import { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
14
|
+
import { ActivityIndicator, Animated, Easing, FlatList, Image, Platform, Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
|
15
15
|
import { formatAgentUiActionMessage, getAgentUiBlocks, getProcedureForm } from "../../../agent-ui-blocks.js";
|
|
16
|
-
import {
|
|
16
|
+
import { forgetLocalPreview, localPreviewFor } from "../../../attachments/local-previews.js";
|
|
17
|
+
import { MAX_ATTACHMENTS, isImageAttachment, uploadsOnPick } from "../../../attachments/pickers.js";
|
|
17
18
|
import { formatLabel } from "../../../i18n.js";
|
|
18
19
|
import { AgentUiBlocks } from "../../components/AgentUiBlocks.js";
|
|
19
20
|
import { AttachmentChips } from "../../components/AttachmentChips.js";
|
|
@@ -27,19 +28,73 @@ import { AlertIcon, ArrowUpIcon, ChatOutlineIcon, ChevronIcon, ClockIcon, PlusIc
|
|
|
27
28
|
import { TeamAvatars } from "../../components/TeamAvatars.js";
|
|
28
29
|
import { Markdown } from "../../components/Markdown.js";
|
|
29
30
|
import { useReduceMotion } from "../../hooks/use-reduce-motion.js";
|
|
31
|
+
import { useRetainedState } from "../../hooks/use-retained-state.js";
|
|
30
32
|
import { useTopInset } from "../../hooks/use-top-inset.js";
|
|
31
33
|
import { globalStyles } from "../../styles/global.js";
|
|
32
34
|
import { contrastingTextColor, mixHex, palette, spacing, withAlpha } from "../../styles/theme.js";
|
|
33
35
|
import { canRetryMessage, formatClockTime, formatDayLabel, isSameDay } from "../../utils/conversations.js";
|
|
34
36
|
import { stripHtml } from "../../utils/html.js";
|
|
37
|
+
import { ImageLoadQueue } from "../../utils/image-load-queue.js";
|
|
38
|
+
import { splitInlineAttachments } from "../../utils/inline-attachments.js";
|
|
35
39
|
import { isHttpUrl, openExternalUrl } from "../../utils/safe-url.js";
|
|
36
40
|
import { isPlayableAudio } from "../../../audio/voice.js";
|
|
37
41
|
import { VoiceNote } from "../../components/VoiceNote.js";
|
|
38
|
-
import { FileCard } from "../../components/FileCard.js";
|
|
42
|
+
import { FileCard, fileTypeLabel } from "../../components/FileCard.js";
|
|
39
43
|
import { MessagesSkeleton } from "../../components/MessagesSkeleton.js";
|
|
40
44
|
import { backDirection, isRTL } from "../../utils/rtl.js";
|
|
41
45
|
import { styles } from "./ChatView.styles.js";
|
|
46
|
+
|
|
47
|
+
/** A message with what its place in the thread decides about it. */
|
|
42
48
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
49
|
+
const rowKey = row => row.message.id;
|
|
50
|
+
|
|
51
|
+
/** How far above the newest message the visitor scrolls before "jump to latest" shows. */
|
|
52
|
+
const JUMP_TO_LATEST_OFFSET = 240;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Workstation's refusal of a message that names an expired upload. Only this
|
|
56
|
+
* one puts the files up again: a deleted conversation or a rejected message
|
|
57
|
+
* would refuse the fresh uploads too.
|
|
58
|
+
*/
|
|
59
|
+
const UPLOAD_EXPIRED_STATUS = 410;
|
|
60
|
+
/**
|
|
61
|
+
* An upload this close to expiring goes up again with the message instead of
|
|
62
|
+
* being named by its token, which could lapse before the message arrives.
|
|
63
|
+
*/
|
|
64
|
+
const UPLOAD_EXPIRY_MARGIN_MS = 10 * 60 * 1000;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Photos above this take turns to download, two at a time; smaller ones load
|
|
68
|
+
* at once. Two 50 MB photos stay well inside Android's download pool (see
|
|
69
|
+
* `ImageLoadQueue`), and ordinary photos never wait.
|
|
70
|
+
*/
|
|
71
|
+
const LARGE_IMAGE_BYTES = 10_000_000;
|
|
72
|
+
const LARGE_IMAGE_LOADS_AT_ONCE = 2;
|
|
73
|
+
/** A photo that loads quicker than this never shows its loader. */
|
|
74
|
+
const PHOTO_LOADER_DELAY_MS = 200;
|
|
75
|
+
|
|
76
|
+
/** A photo download that waits its turn; the phone's own copy never does. */
|
|
77
|
+
const isLargeDownload = (url, size) => isHttpUrl(url) && (size ?? 0) > LARGE_IMAGE_BYTES;
|
|
78
|
+
const ImageQueueContext = /*#__PURE__*/createContext(null);
|
|
79
|
+
const subscribeNowhere = () => () => {};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Whether an image may load yet, and the callback that tells the queue it
|
|
83
|
+
* has (or failed). Images the queue doesn't handle load right away.
|
|
84
|
+
*/
|
|
85
|
+
function useImageTurn(key, priority, queued) {
|
|
86
|
+
const queue = useContext(ImageQueueContext);
|
|
87
|
+
const active = !!queue && queued;
|
|
88
|
+
useEffect(() => active ? queue.add(key, priority) : undefined, [active, queue, key, priority]);
|
|
89
|
+
const turn = useSyncExternalStore(queue ? queue.subscribe : subscribeNowhere, () => active ? queue.canLoad(key) : true);
|
|
90
|
+
const settled = useCallback(() => queue?.finish(key), [queue, key]);
|
|
91
|
+
return [turn, settled];
|
|
92
|
+
}
|
|
93
|
+
function ThreadGap() {
|
|
94
|
+
return /*#__PURE__*/_jsx(View, {
|
|
95
|
+
style: styles.threadGap
|
|
96
|
+
});
|
|
97
|
+
}
|
|
43
98
|
export function ChatView({
|
|
44
99
|
messages,
|
|
45
100
|
loading,
|
|
@@ -59,11 +114,13 @@ export function ChatView({
|
|
|
59
114
|
labels,
|
|
60
115
|
attachmentSources = [],
|
|
61
116
|
onPickAttachments,
|
|
117
|
+
onUploadAttachment,
|
|
62
118
|
onSendMessage,
|
|
63
119
|
onRetryMessage,
|
|
64
120
|
onIdentifyVisitor,
|
|
65
121
|
onRateConversation,
|
|
66
122
|
defaultLocale,
|
|
123
|
+
photoAlbums = true,
|
|
67
124
|
onLoadMore,
|
|
68
125
|
onTyping,
|
|
69
126
|
onBack,
|
|
@@ -73,30 +130,46 @@ export function ChatView({
|
|
|
73
130
|
const [composerFocused, setComposerFocused] = useState(false);
|
|
74
131
|
const [drafts, setDrafts] = useState([]);
|
|
75
132
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
76
|
-
const [
|
|
133
|
+
const [viewerGallery, setViewerGallery] = useState(null);
|
|
77
134
|
const [picking, setPicking] = useState(false);
|
|
78
135
|
// Only attachment sends hold the composer; text sends stay fire-and-forget.
|
|
79
136
|
const [sending, setSending] = useState(false);
|
|
80
137
|
const [notice, setNotice] = useState(null);
|
|
81
138
|
const reduceMotion = useReduceMotion();
|
|
82
|
-
const
|
|
139
|
+
const listRef = useRef(null);
|
|
140
|
+
// One per thread: the screen remounts for each conversation.
|
|
141
|
+
const [largeImageQueue] = useState(() => new ImageLoadQueue(LARGE_IMAGE_LOADS_AT_ONCE));
|
|
83
142
|
const typingStopTimer = useRef(null);
|
|
84
143
|
const noticeTimer = useRef(null);
|
|
85
144
|
const pickerTimer = useRef(null);
|
|
86
145
|
const mounted = useRef(false);
|
|
146
|
+
// Uploads in flight, by draft id. A removed chip cancels its own; a retry
|
|
147
|
+
// replaces it, and the replaced attempt's result is dropped.
|
|
148
|
+
const uploads = useRef(new Map());
|
|
149
|
+
// Files already put up again after a refused send; never twice, so a
|
|
150
|
+
// refusal uploads can't fix costs one round, not one per tap on Send.
|
|
151
|
+
const reuploaded = useRef(new Set());
|
|
152
|
+
// Set as the visitor sends: the list scrolls down once their bubble is in
|
|
153
|
+
// it. Scrolled up, scrolling first let the list hold its place as the new
|
|
154
|
+
// row arrived, which could cut the scroll short.
|
|
155
|
+
const followSend = useRef(false);
|
|
87
156
|
useEffect(() => {
|
|
88
157
|
if (prefillText) setInput(prefillText);
|
|
89
158
|
}, [prefillText]);
|
|
90
159
|
|
|
91
160
|
// The provider keys this screen by the store's thread generation. Pending
|
|
92
|
-
// picks and sends belong to this mount, even before a draft gets a
|
|
161
|
+
// picks, uploads and sends belong to this mount, even before a draft gets a
|
|
162
|
+
// server ID.
|
|
93
163
|
useEffect(() => {
|
|
94
164
|
mounted.current = true;
|
|
165
|
+
const inFlight = uploads.current;
|
|
95
166
|
return () => {
|
|
96
167
|
mounted.current = false;
|
|
97
168
|
if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
|
|
98
169
|
if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
|
99
170
|
if (pickerTimer.current) clearTimeout(pickerTimer.current);
|
|
171
|
+
inFlight.forEach(upload => upload.cancel());
|
|
172
|
+
inFlight.clear();
|
|
100
173
|
};
|
|
101
174
|
}, []);
|
|
102
175
|
const showNotice = useCallback(message => {
|
|
@@ -104,50 +177,66 @@ export function ChatView({
|
|
|
104
177
|
setNotice(message);
|
|
105
178
|
if (message) noticeTimer.current = setTimeout(() => setNotice(null), 5000);
|
|
106
179
|
}, []);
|
|
180
|
+
|
|
181
|
+
// The list is inverted, so offset 0 is the newest message: the thread opens
|
|
182
|
+
// there without scrolling, stays there as the keyboard rises or replies
|
|
183
|
+
// arrive, and older pages land above it without moving the reader.
|
|
107
184
|
const scrollToBottom = useCallback((animated = true) => {
|
|
108
|
-
|
|
185
|
+
listRef.current?.scrollToOffset({
|
|
186
|
+
offset: 0,
|
|
109
187
|
animated
|
|
110
188
|
});
|
|
111
189
|
}, []);
|
|
112
190
|
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
// handler below covers later growth (loads, images, incoming messages).
|
|
116
|
-
useEffect(() => {
|
|
117
|
-
const frame = requestAnimationFrame(() => scrollToBottom(false));
|
|
118
|
-
return () => cancelAnimationFrame(frame);
|
|
119
|
-
}, [scrollToBottom]);
|
|
120
|
-
|
|
121
|
-
// "Load earlier" PREPENDS history; skipping one auto-scroll keeps the reader
|
|
122
|
-
// anchored where they were instead of yanking them back to the bottom.
|
|
123
|
-
const skipNextAutoScroll = useRef(false);
|
|
191
|
+
// Fires as the visitor nears the oldest message; the row up there is the
|
|
192
|
+
// fallback when a page fails to load.
|
|
124
193
|
const handleLoadEarlier = useCallback(() => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}, [onLoadMore]);
|
|
128
|
-
const handleContentSizeChange = useCallback(() => {
|
|
129
|
-
if (skipNextAutoScroll.current) {
|
|
130
|
-
skipNextAutoScroll.current = false;
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
scrollToBottom(false);
|
|
134
|
-
}, [scrollToBottom]);
|
|
135
|
-
|
|
136
|
-
// The keyboard shortens the thread instead of scrolling it, so whatever was
|
|
137
|
-
// at the bottom — the newest message, an agent form — would slide in behind
|
|
138
|
-
// the composer. Re-pin on every shrink, unanimated, so the list tracks the
|
|
139
|
-
// keyboard as it rises rather than jumping once it lands.
|
|
140
|
-
const viewportHeight = useRef(0);
|
|
141
|
-
const handleViewportLayout = useCallback(event => {
|
|
142
|
-
const {
|
|
143
|
-
height
|
|
144
|
-
} = event.nativeEvent.layout;
|
|
145
|
-
const shrank = viewportHeight.current - height > 1;
|
|
146
|
-
viewportHeight.current = height;
|
|
147
|
-
if (shrank) scrollToBottom(false);
|
|
148
|
-
}, [scrollToBottom]);
|
|
194
|
+
if (hasMoreMessages && !loading) onLoadMore();
|
|
195
|
+
}, [hasMoreMessages, loading, onLoadMore]);
|
|
149
196
|
const showAttach = attachmentSources.length > 0 && !!onPickAttachments;
|
|
150
|
-
|
|
197
|
+
// Send waits until every picked file is up, so the message goes out at once.
|
|
198
|
+
const uploadsPending = drafts.some(draft => draft.uploadStatus === 'uploading' || draft.uploadStatus === 'failed');
|
|
199
|
+
const canSend = !sending && !uploadsPending && (input.trim().length > 0 || drafts.length > 0);
|
|
200
|
+
const updateDraft = useCallback((id, patch) => setDrafts(current => current.map(draft => draft.id === id ? {
|
|
201
|
+
...draft,
|
|
202
|
+
...patch
|
|
203
|
+
} : draft)), []);
|
|
204
|
+
|
|
205
|
+
/** Puts a picked local file up while the visitor writes; inline files need nothing. */
|
|
206
|
+
const startUpload = draft => {
|
|
207
|
+
if (!onUploadAttachment || !draft.fileUri) return;
|
|
208
|
+
uploads.current.get(draft.id)?.cancel();
|
|
209
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
210
|
+
const attempt = {
|
|
211
|
+
cancel: () => controller?.abort()
|
|
212
|
+
};
|
|
213
|
+
uploads.current.set(draft.id, attempt);
|
|
214
|
+
updateDraft(draft.id, {
|
|
215
|
+
uploadStatus: 'uploading',
|
|
216
|
+
uploadToken: undefined,
|
|
217
|
+
uploadExpiresAt: undefined
|
|
218
|
+
});
|
|
219
|
+
onUploadAttachment(draft, controller?.signal).then(({
|
|
220
|
+
uploadToken,
|
|
221
|
+
expiresAt
|
|
222
|
+
}) => {
|
|
223
|
+
if (!mounted.current || uploads.current.get(draft.id) !== attempt) return;
|
|
224
|
+
uploads.current.delete(draft.id);
|
|
225
|
+
updateDraft(draft.id, {
|
|
226
|
+
uploadStatus: 'uploaded',
|
|
227
|
+
uploadToken,
|
|
228
|
+
uploadExpiresAt: expiresAt
|
|
229
|
+
});
|
|
230
|
+
}, error => {
|
|
231
|
+
if (!mounted.current || uploads.current.get(draft.id) !== attempt) return;
|
|
232
|
+
console.warn('[repzo-widget] attachment upload failed', error);
|
|
233
|
+
uploads.current.delete(draft.id);
|
|
234
|
+
updateDraft(draft.id, {
|
|
235
|
+
uploadStatus: 'failed'
|
|
236
|
+
});
|
|
237
|
+
showNotice(labels.attach.uploadFailedNotice);
|
|
238
|
+
});
|
|
239
|
+
};
|
|
151
240
|
const problemLabel = problem => {
|
|
152
241
|
switch (problem) {
|
|
153
242
|
case 'tooMany':
|
|
@@ -172,6 +261,8 @@ export function ChatView({
|
|
|
172
261
|
if (!mounted.current) return;
|
|
173
262
|
if (result.added.length > 0) {
|
|
174
263
|
setDrafts(current => [...current, ...result.added].slice(0, MAX_ATTACHMENTS));
|
|
264
|
+
// Opted out, the files go up when the visitor taps Send instead.
|
|
265
|
+
if (uploadsOnPick()) result.added.forEach(startUpload);
|
|
175
266
|
}
|
|
176
267
|
showNotice(problemLabel(result.problem));
|
|
177
268
|
} finally {
|
|
@@ -211,19 +302,24 @@ export function ChatView({
|
|
|
211
302
|
setInput('');
|
|
212
303
|
if (typingStopTimer.current) clearTimeout(typingStopTimer.current);
|
|
213
304
|
onTyping(false);
|
|
214
|
-
|
|
305
|
+
followSend.current = true;
|
|
215
306
|
if (drafts.length === 0) {
|
|
216
307
|
// The failed bubble already surfaces a failure to the visitor.
|
|
217
308
|
onSendMessage(trimmed).catch(() => {});
|
|
218
309
|
return;
|
|
219
310
|
}
|
|
220
311
|
const sent = drafts;
|
|
312
|
+
// Named by its token while that lasts; otherwise the file goes up again
|
|
313
|
+
// as the message is sent.
|
|
314
|
+
const sendBy = Date.now() + UPLOAD_EXPIRY_MARGIN_MS;
|
|
315
|
+
const stillUploaded = draft => !!draft.uploadToken && (draft.uploadExpiresAt ?? Infinity) > sendBy;
|
|
221
316
|
setSending(true);
|
|
222
317
|
showNotice(null);
|
|
223
318
|
onSendMessage(trimmed, sent.map(draft => ({
|
|
224
319
|
name: draft.name,
|
|
225
320
|
url: draft.dataUrl,
|
|
226
321
|
fileUri: draft.fileUri,
|
|
322
|
+
uploadToken: stillUploaded(draft) ? draft.uploadToken : undefined,
|
|
227
323
|
contentType: draft.contentType,
|
|
228
324
|
size: draft.size,
|
|
229
325
|
previewUrl: draft.previewUri
|
|
@@ -239,6 +335,15 @@ export function ChatView({
|
|
|
239
335
|
setInput(current => current || trimmed);
|
|
240
336
|
const status = error?.status;
|
|
241
337
|
showNotice(status === 413 ? labels.attach.tooLargeTogether : labels.failedToSend);
|
|
338
|
+
// An upload expired before this phone's clock said it would: put the
|
|
339
|
+
// files sent by token up again, once. After a lost connection the
|
|
340
|
+
// uploads still stand for the next try.
|
|
341
|
+
if (status === UPLOAD_EXPIRED_STATUS) {
|
|
342
|
+
sent.filter(draft => stillUploaded(draft) && !reuploaded.current.has(draft.id)).forEach(draft => {
|
|
343
|
+
reuploaded.current.add(draft.id);
|
|
344
|
+
startUpload(draft);
|
|
345
|
+
});
|
|
346
|
+
}
|
|
242
347
|
}).finally(() => {
|
|
243
348
|
if (mounted.current) setSending(false);
|
|
244
349
|
});
|
|
@@ -258,17 +363,17 @@ export function ChatView({
|
|
|
258
363
|
phone: stringPayload(action.payload.phone)
|
|
259
364
|
});
|
|
260
365
|
}
|
|
366
|
+
followSend.current = true;
|
|
261
367
|
onSendMessage(formatAgentUiActionMessage(action), undefined, action).catch(() => {});
|
|
262
|
-
|
|
263
|
-
}, [onIdentifyVisitor, onSendMessage, scrollToBottom]);
|
|
368
|
+
}, [onIdentifyVisitor, onSendMessage]);
|
|
264
369
|
const handleSubmitProcedureForm = useCallback(body => void onSendMessage(body).catch(() => {}), [onSendMessage]);
|
|
265
370
|
|
|
266
371
|
// The retried bubble moves to the end of the thread; follow it there.
|
|
267
372
|
// A second failure shows on the bubble again.
|
|
268
373
|
const handleRetryMessage = useCallback(messageId => {
|
|
374
|
+
followSend.current = true;
|
|
269
375
|
onRetryMessage?.(messageId).catch(() => {});
|
|
270
|
-
|
|
271
|
-
}, [onRetryMessage, scrollToBottom]);
|
|
376
|
+
}, [onRetryMessage]);
|
|
272
377
|
|
|
273
378
|
// Recomputed only when the list changes — never on a keystroke.
|
|
274
379
|
const thread = useMemo(() => deriveThread(messages), [messages]);
|
|
@@ -288,6 +393,143 @@ export function ChatView({
|
|
|
288
393
|
const headerControl = {
|
|
289
394
|
backgroundColor: withAlpha(headerTextColor, 0.16)
|
|
290
395
|
};
|
|
396
|
+
|
|
397
|
+
// Newest first, for the inverted list; each row still compares itself with
|
|
398
|
+
// the message sent before it.
|
|
399
|
+
const rows = useMemo(() => messages.map((message, index) => {
|
|
400
|
+
const previous = index > 0 ? messages[index - 1] : undefined;
|
|
401
|
+
return {
|
|
402
|
+
message,
|
|
403
|
+
startsDay: !previous || !isSameDay(message.createdAt, previous.createdAt),
|
|
404
|
+
// Name only at the top of a run, the way every messenger does it.
|
|
405
|
+
startsRun: !previous || previous.direction === 'inbound' || previous.messageType === 'system' || previous.senderName !== message.senderName
|
|
406
|
+
};
|
|
407
|
+
}).reverse(), [messages]);
|
|
408
|
+
useEffect(() => {
|
|
409
|
+
if (!followSend.current) return;
|
|
410
|
+
followSend.current = false;
|
|
411
|
+
scrollToBottom();
|
|
412
|
+
}, [rows, scrollToBottom]);
|
|
413
|
+
|
|
414
|
+
// The thread's message ids when the visitor scrolled away from the newest,
|
|
415
|
+
// null while it's in view. Teammate replies newer than all of them are
|
|
416
|
+
// counted on the button. A set, not the newest id alone: that may be the
|
|
417
|
+
// visitor's own bubble, whose temporary id changes once the server has it.
|
|
418
|
+
const [awayFrom, setAwayFrom] = useState(null);
|
|
419
|
+
const rowIds = useRef([]);
|
|
420
|
+
useEffect(() => {
|
|
421
|
+
rowIds.current = rows.map(row => row.message.id);
|
|
422
|
+
}, [rows]);
|
|
423
|
+
const handleScroll = useCallback(event => {
|
|
424
|
+
// Inverted: the offset is the distance up from the newest message.
|
|
425
|
+
const away = event.nativeEvent.contentOffset.y > JUMP_TO_LATEST_OFFSET;
|
|
426
|
+
setAwayFrom(current => away ? current ?? new Set(rowIds.current) : null);
|
|
427
|
+
}, []);
|
|
428
|
+
const unseenReplies = useMemo(() => {
|
|
429
|
+
if (!awayFrom) return 0;
|
|
430
|
+
let count = 0;
|
|
431
|
+
for (const {
|
|
432
|
+
message
|
|
433
|
+
} of rows) {
|
|
434
|
+
if (awayFrom.has(message.id)) break;
|
|
435
|
+
if (message.direction !== 'inbound' && message.messageType !== 'system') count += 1;
|
|
436
|
+
}
|
|
437
|
+
return count;
|
|
438
|
+
}, [awayFrom, rows]);
|
|
439
|
+
const handleJumpToLatest = useCallback(() => scrollToBottom(), [scrollToBottom]);
|
|
440
|
+
|
|
441
|
+
// Android only: it detaches rows scrolled out of view, where a large photo
|
|
442
|
+
// still downloading would never finish and keep its turn. Keyed as in usePhotoSource.
|
|
443
|
+
const handleViewableItemsChanged = useCallback(({
|
|
444
|
+
viewableItems
|
|
445
|
+
}) => largeImageQueue.setOnScreen(viewableItems.flatMap(({
|
|
446
|
+
item
|
|
447
|
+
}) => (item.message.attachments ?? []).map(attachment => attachment.id || attachment.url))), [largeImageQueue]);
|
|
448
|
+
const renderRow = useCallback(({
|
|
449
|
+
item: {
|
|
450
|
+
message,
|
|
451
|
+
startsDay,
|
|
452
|
+
startsRun
|
|
453
|
+
}
|
|
454
|
+
}) => {
|
|
455
|
+
const isAgentMessage = message.direction !== 'inbound' && message.messageType !== 'system';
|
|
456
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
457
|
+
style: styles.threadRow,
|
|
458
|
+
children: [startsDay ? /*#__PURE__*/_jsxs(View, {
|
|
459
|
+
style: styles.daySeparator,
|
|
460
|
+
children: [/*#__PURE__*/_jsx(View, {
|
|
461
|
+
style: styles.dayLine
|
|
462
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
463
|
+
style: styles.dayLabel,
|
|
464
|
+
children: formatDayLabel(message.createdAt, locale, labels)
|
|
465
|
+
}), /*#__PURE__*/_jsx(View, {
|
|
466
|
+
style: styles.dayLine
|
|
467
|
+
})]
|
|
468
|
+
}) : null, /*#__PURE__*/_jsx(WidgetErrorBoundary, {
|
|
469
|
+
fallback: null,
|
|
470
|
+
children: /*#__PURE__*/_jsx(MessageBubble, {
|
|
471
|
+
message: message,
|
|
472
|
+
color: color,
|
|
473
|
+
labels: labels,
|
|
474
|
+
onOpenImage: setViewerGallery,
|
|
475
|
+
onRateConversation: onRateConversation,
|
|
476
|
+
locales: locales,
|
|
477
|
+
senderLabel: isAgentMessage && (startsRun || startsDay) ? message.senderName || agentName || labels.team : null,
|
|
478
|
+
timeLabel: formatClockTime(message.createdAt, locale),
|
|
479
|
+
receiptLabel: message.id === thread.lastVisitorMessageId ? receiptLabel(message, labels) : undefined,
|
|
480
|
+
onSubmitProcedureForm: message.id === thread.procedureFormMessageId ? handleSubmitProcedureForm : undefined,
|
|
481
|
+
onAgentUiAction: thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined,
|
|
482
|
+
onRetry: onRetryMessage && canRetryMessage(message) ? handleRetryMessage : undefined,
|
|
483
|
+
photoAlbums: photoAlbums
|
|
484
|
+
})
|
|
485
|
+
})]
|
|
486
|
+
});
|
|
487
|
+
}, [agentName, color, handleAgentUiAction, handleRetryMessage, handleSubmitProcedureForm, labels, locale, locales, onRateConversation, onRetryMessage, photoAlbums, thread]);
|
|
488
|
+
const loadEarlierRow = hasMoreMessages ? /*#__PURE__*/_jsx(Pressable, {
|
|
489
|
+
onPress: handleLoadEarlier,
|
|
490
|
+
accessibilityRole: "button",
|
|
491
|
+
style: styles.loadMore,
|
|
492
|
+
children: /*#__PURE__*/_jsx(Text, {
|
|
493
|
+
style: [styles.loadMoreLabel, {
|
|
494
|
+
color
|
|
495
|
+
}],
|
|
496
|
+
children: loading ? labels.loading : labels.loadEarlier
|
|
497
|
+
})
|
|
498
|
+
}) : null;
|
|
499
|
+
|
|
500
|
+
// Under the newest message.
|
|
501
|
+
const threadTail = awaitingTeammate || agentTyping ? /*#__PURE__*/_jsxs(View, {
|
|
502
|
+
style: styles.threadTail,
|
|
503
|
+
children: [awaitingTeammate ? /*#__PURE__*/_jsxs(View, {
|
|
504
|
+
style: styles.waitingRow,
|
|
505
|
+
children: [/*#__PURE__*/_jsx(TeamAvatars, {
|
|
506
|
+
avatars: teamAvatars,
|
|
507
|
+
size: 20,
|
|
508
|
+
color: color
|
|
509
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
510
|
+
style: styles.waitingText,
|
|
511
|
+
children: labels.waitingForTeammate
|
|
512
|
+
})]
|
|
513
|
+
}) : null, agentTyping ? /*#__PURE__*/_jsxs(View, {
|
|
514
|
+
style: styles.typingRow,
|
|
515
|
+
children: [/*#__PURE__*/_jsxs(View, {
|
|
516
|
+
style: styles.typingBubble,
|
|
517
|
+
children: [/*#__PURE__*/_jsx(TypingDot, {
|
|
518
|
+
index: 0,
|
|
519
|
+
reduceMotion: reduceMotion
|
|
520
|
+
}), /*#__PURE__*/_jsx(TypingDot, {
|
|
521
|
+
index: 1,
|
|
522
|
+
reduceMotion: reduceMotion
|
|
523
|
+
}), /*#__PURE__*/_jsx(TypingDot, {
|
|
524
|
+
index: 2,
|
|
525
|
+
reduceMotion: reduceMotion
|
|
526
|
+
})]
|
|
527
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
528
|
+
style: styles.typingLabel,
|
|
529
|
+
children: labels.thinking
|
|
530
|
+
})]
|
|
531
|
+
}) : null]
|
|
532
|
+
}) : null;
|
|
291
533
|
return /*#__PURE__*/_jsxs(View, {
|
|
292
534
|
style: globalStyles.screen,
|
|
293
535
|
children: [/*#__PURE__*/_jsxs(GradientBackground, {
|
|
@@ -392,29 +634,76 @@ export function ChatView({
|
|
|
392
634
|
color: headerTextColor
|
|
393
635
|
})
|
|
394
636
|
})]
|
|
395
|
-
}), /*#__PURE__*/_jsxs(
|
|
396
|
-
ref: scrollRef,
|
|
637
|
+
}), messages.length > 0 ? /*#__PURE__*/_jsxs(View, {
|
|
397
638
|
style: globalStyles.fill,
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
639
|
+
children: [/*#__PURE__*/_jsx(ImageQueueContext.Provider, {
|
|
640
|
+
value: largeImageQueue,
|
|
641
|
+
children: /*#__PURE__*/_jsx(FlatList, {
|
|
642
|
+
ref: listRef,
|
|
643
|
+
data: rows,
|
|
644
|
+
inverted: true,
|
|
645
|
+
keyExtractor: rowKey,
|
|
646
|
+
renderItem: renderRow,
|
|
647
|
+
style: globalStyles.fill,
|
|
648
|
+
contentContainerStyle: styles.threadContent,
|
|
649
|
+
ItemSeparatorComponent: ThreadGap
|
|
650
|
+
// Inverted, the header sits under the newest message and the footer
|
|
651
|
+
// above the oldest.
|
|
652
|
+
,
|
|
653
|
+
ListHeaderComponent: threadTail,
|
|
654
|
+
ListHeaderComponentStyle: styles.threadTailInList,
|
|
655
|
+
ListFooterComponent: loadEarlierRow,
|
|
656
|
+
onEndReached: handleLoadEarlier,
|
|
657
|
+
onEndReachedThreshold: 0.5,
|
|
658
|
+
onScroll: handleScroll,
|
|
659
|
+
scrollEventThrottle: 100
|
|
660
|
+
// A reply arriving below doesn't move what a visitor reads further
|
|
661
|
+
// up; near the newest message the list follows it into view.
|
|
662
|
+
,
|
|
663
|
+
maintainVisibleContentPosition: {
|
|
664
|
+
minIndexForVisible: 0,
|
|
665
|
+
autoscrollToTopThreshold: JUMP_TO_LATEST_OFFSET
|
|
666
|
+
},
|
|
667
|
+
onViewableItemsChanged: Platform.OS === 'android' ? handleViewableItemsChanged : undefined,
|
|
668
|
+
keyboardShouldPersistTaps: "handled"
|
|
669
|
+
// Interactive dismissal drags the keyboard down under the finger while
|
|
670
|
+
// the composer stays where the last keyboard event left it, which opens
|
|
671
|
+
// a gap under the thread; on-drag hands us a normal hide event instead.
|
|
672
|
+
,
|
|
673
|
+
keyboardDismissMode: "on-drag"
|
|
674
|
+
})
|
|
675
|
+
}), awayFrom ? /*#__PURE__*/_jsxs(Pressable, {
|
|
676
|
+
onPress: handleJumpToLatest,
|
|
409
677
|
accessibilityRole: "button",
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
678
|
+
accessibilityLabel: unseenReplies ? formatLabel(labels.jumpToLatestNew, {
|
|
679
|
+
count: unseenReplies
|
|
680
|
+
}) : labels.jumpToLatest,
|
|
681
|
+
hitSlop: 8,
|
|
682
|
+
style: ({
|
|
683
|
+
pressed
|
|
684
|
+
}) => [styles.jumpToLatest, pressed && globalStyles.pressed],
|
|
685
|
+
children: [/*#__PURE__*/_jsx(ChevronIcon, {
|
|
686
|
+
size: 18,
|
|
687
|
+
direction: "down",
|
|
688
|
+
color: palette.text
|
|
689
|
+
}), unseenReplies ? /*#__PURE__*/_jsx(View, {
|
|
690
|
+
style: [styles.jumpBadge, {
|
|
691
|
+
backgroundColor: color
|
|
414
692
|
}],
|
|
415
|
-
children:
|
|
416
|
-
|
|
417
|
-
|
|
693
|
+
children: /*#__PURE__*/_jsx(Text, {
|
|
694
|
+
style: [styles.jumpBadgeText, {
|
|
695
|
+
color: contrastingTextColor(color)
|
|
696
|
+
}],
|
|
697
|
+
children: unseenReplies > 9 ? '9+' : unseenReplies
|
|
698
|
+
})
|
|
699
|
+
}) : null]
|
|
700
|
+
}) : null]
|
|
701
|
+
}) : /*#__PURE__*/_jsxs(ScrollView, {
|
|
702
|
+
style: globalStyles.fill,
|
|
703
|
+
contentContainerStyle: [styles.messagesContent, isEmptyThread && styles.messagesContentEmpty, isLoadingThread && styles.messagesContentLoading],
|
|
704
|
+
keyboardShouldPersistTaps: "handled",
|
|
705
|
+
keyboardDismissMode: "on-drag",
|
|
706
|
+
children: [loadEarlierRow, isLoadingThread ? /*#__PURE__*/_jsx(MessagesSkeleton, {
|
|
418
707
|
color: color,
|
|
419
708
|
label: labels.loading
|
|
420
709
|
}) : null, isEmptyThread ? /*#__PURE__*/_jsxs(View, {
|
|
@@ -431,70 +720,7 @@ export function ChatView({
|
|
|
431
720
|
style: styles.emptyText,
|
|
432
721
|
children: labels.emptyConversation
|
|
433
722
|
})]
|
|
434
|
-
}) : null,
|
|
435
|
-
const previous = index > 0 ? messages[index - 1] : undefined;
|
|
436
|
-
const startsDay = !previous || !isSameDay(message.createdAt, previous.createdAt);
|
|
437
|
-
const isAgentMessage = message.direction !== 'inbound' && message.messageType !== 'system';
|
|
438
|
-
// Name only at the top of a run, the way every messenger does it.
|
|
439
|
-
const startsRun = !previous || previous.direction === 'inbound' || previous.messageType === 'system' || previous.senderName !== message.senderName;
|
|
440
|
-
return /*#__PURE__*/_jsxs(Fragment, {
|
|
441
|
-
children: [startsDay ? /*#__PURE__*/_jsxs(View, {
|
|
442
|
-
style: styles.daySeparator,
|
|
443
|
-
children: [/*#__PURE__*/_jsx(View, {
|
|
444
|
-
style: styles.dayLine
|
|
445
|
-
}), /*#__PURE__*/_jsx(Text, {
|
|
446
|
-
style: styles.dayLabel,
|
|
447
|
-
children: formatDayLabel(message.createdAt, locale, labels)
|
|
448
|
-
}), /*#__PURE__*/_jsx(View, {
|
|
449
|
-
style: styles.dayLine
|
|
450
|
-
})]
|
|
451
|
-
}) : null, /*#__PURE__*/_jsx(WidgetErrorBoundary, {
|
|
452
|
-
fallback: null,
|
|
453
|
-
children: /*#__PURE__*/_jsx(MessageBubble, {
|
|
454
|
-
message: message,
|
|
455
|
-
color: color,
|
|
456
|
-
labels: labels,
|
|
457
|
-
onOpenImage: setViewerImage,
|
|
458
|
-
onRateConversation: onRateConversation,
|
|
459
|
-
locales: locales,
|
|
460
|
-
senderLabel: isAgentMessage && (startsRun || startsDay) ? message.senderName || agentName || labels.team : null,
|
|
461
|
-
timeLabel: formatClockTime(message.createdAt, locale),
|
|
462
|
-
receiptLabel: message.id === thread.lastVisitorMessageId ? receiptLabel(message, labels) : undefined,
|
|
463
|
-
onSubmitProcedureForm: message.id === thread.procedureFormMessageId ? handleSubmitProcedureForm : undefined,
|
|
464
|
-
onAgentUiAction: thread.agentBlocksShown.has(message.id) ? handleAgentUiAction : undefined,
|
|
465
|
-
onRetry: onRetryMessage && canRetryMessage(message) ? handleRetryMessage : undefined
|
|
466
|
-
})
|
|
467
|
-
})]
|
|
468
|
-
}, message.id);
|
|
469
|
-
}), awaitingTeammate ? /*#__PURE__*/_jsxs(View, {
|
|
470
|
-
style: styles.waitingRow,
|
|
471
|
-
children: [/*#__PURE__*/_jsx(TeamAvatars, {
|
|
472
|
-
avatars: teamAvatars,
|
|
473
|
-
size: 20,
|
|
474
|
-
color: color
|
|
475
|
-
}), /*#__PURE__*/_jsx(Text, {
|
|
476
|
-
style: styles.waitingText,
|
|
477
|
-
children: labels.waitingForTeammate
|
|
478
|
-
})]
|
|
479
|
-
}) : null, agentTyping ? /*#__PURE__*/_jsxs(View, {
|
|
480
|
-
style: styles.typingRow,
|
|
481
|
-
children: [/*#__PURE__*/_jsxs(View, {
|
|
482
|
-
style: styles.typingBubble,
|
|
483
|
-
children: [/*#__PURE__*/_jsx(TypingDot, {
|
|
484
|
-
index: 0,
|
|
485
|
-
reduceMotion: reduceMotion
|
|
486
|
-
}), /*#__PURE__*/_jsx(TypingDot, {
|
|
487
|
-
index: 1,
|
|
488
|
-
reduceMotion: reduceMotion
|
|
489
|
-
}), /*#__PURE__*/_jsx(TypingDot, {
|
|
490
|
-
index: 2,
|
|
491
|
-
reduceMotion: reduceMotion
|
|
492
|
-
})]
|
|
493
|
-
}), /*#__PURE__*/_jsx(Text, {
|
|
494
|
-
style: styles.typingLabel,
|
|
495
|
-
children: labels.thinking
|
|
496
|
-
})]
|
|
497
|
-
}) : null]
|
|
723
|
+
}) : null, threadTail]
|
|
498
724
|
}), offlineMessage ? /*#__PURE__*/_jsx(View, {
|
|
499
725
|
style: styles.offlineBanner,
|
|
500
726
|
children: /*#__PURE__*/_jsx(Text, {
|
|
@@ -511,10 +737,18 @@ export function ChatView({
|
|
|
511
737
|
}) : null, drafts.length > 0 ? /*#__PURE__*/_jsx(AttachmentChips, {
|
|
512
738
|
attachments: drafts,
|
|
513
739
|
disabled: sending,
|
|
514
|
-
|
|
740
|
+
labels: labels.attach,
|
|
515
741
|
onRemove: id => {
|
|
742
|
+
uploads.current.get(id)?.cancel();
|
|
743
|
+
uploads.current.delete(id);
|
|
516
744
|
setDrafts(current => current.filter(draft => draft.id !== id));
|
|
517
745
|
showNotice(null);
|
|
746
|
+
},
|
|
747
|
+
onRetry: id => {
|
|
748
|
+
const draft = drafts.find(item => item.id === id);
|
|
749
|
+
if (!draft) return;
|
|
750
|
+
showNotice(null);
|
|
751
|
+
startUpload(draft);
|
|
518
752
|
}
|
|
519
753
|
}) : null, /*#__PURE__*/_jsxs(View, {
|
|
520
754
|
style: [styles.composer, showAttach && styles.composerWithAttach, composerFocused && styles.composerFocused, composerFocused && {
|
|
@@ -552,6 +786,7 @@ export function ChatView({
|
|
|
552
786
|
onPress: handleSend,
|
|
553
787
|
disabled: !canSend,
|
|
554
788
|
accessibilityRole: "button",
|
|
789
|
+
accessibilityLabel: labels.send,
|
|
555
790
|
accessibilityState: {
|
|
556
791
|
disabled: !canSend
|
|
557
792
|
},
|
|
@@ -574,11 +809,11 @@ export function ChatView({
|
|
|
574
809
|
onSelect: handleMenuSelect,
|
|
575
810
|
onClose: () => setMenuOpen(false)
|
|
576
811
|
}) : null, /*#__PURE__*/_jsx(ImageViewer, {
|
|
577
|
-
|
|
812
|
+
gallery: viewerGallery,
|
|
578
813
|
labels: labels.viewer,
|
|
579
|
-
onClose: () =>
|
|
814
|
+
onClose: () => setViewerGallery(null),
|
|
580
815
|
onOpenExternally: url => {
|
|
581
|
-
|
|
816
|
+
setViewerGallery(null);
|
|
582
817
|
void openExternalUrl(url);
|
|
583
818
|
}
|
|
584
819
|
})]
|
|
@@ -601,7 +836,8 @@ const MessageBubble = /*#__PURE__*/memo(function MessageBubble({
|
|
|
601
836
|
onOpenImage,
|
|
602
837
|
onRateConversation,
|
|
603
838
|
locales,
|
|
604
|
-
onRetry
|
|
839
|
+
onRetry,
|
|
840
|
+
photoAlbums
|
|
605
841
|
}) {
|
|
606
842
|
const joinedName = message.metadata?.event === 'agent_joined' ? message.metadata?.agentName : null;
|
|
607
843
|
if (message.messageType === 'system' && typeof joinedName === 'string' && joinedName) {
|
|
@@ -664,6 +900,56 @@ const MessageBubble = /*#__PURE__*/memo(function MessageBubble({
|
|
|
664
900
|
const procedureForm = !isVisitor ? getProcedureForm(message.metadata) : null;
|
|
665
901
|
const agentUiBlocks = !isVisitor ? getAgentUiBlocks(message.metadata) : [];
|
|
666
902
|
const textColor = isVisitor ? palette.white : palette.ink;
|
|
903
|
+
// A reply with images between its paragraphs shows them where the teammate put them.
|
|
904
|
+
const inlineParts = isVisitor ? null : splitInlineAttachments(message.body, message.attachments);
|
|
905
|
+
const files = inlineParts ? inlineParts.files : message.attachments ?? [];
|
|
906
|
+
// Two or more photos share one album; any other files follow it.
|
|
907
|
+
const photos = files.filter(attachment => isImageAttachment(attachment.contentType));
|
|
908
|
+
const album = photoAlbums && photos.length > 1 ? photos : null;
|
|
909
|
+
const sentAt = Date.parse(message.createdAt) || 0;
|
|
910
|
+
// A tap on one of the message's photos opens them all, at that one, in the
|
|
911
|
+
// order the bubble shows them.
|
|
912
|
+
const openPhotos = tapped => {
|
|
913
|
+
const photos = [...(inlineParts?.parts.flatMap(part => 'attachment' in part ? [part.attachment] : []) ?? []), ...files].filter(attachment => isImageAttachment(attachment.contentType)).map(attachment => {
|
|
914
|
+
const url = attachment.id === tapped.id ? tapped.url : localPreviewFor(attachment.url) || attachment.url;
|
|
915
|
+
// The phone's copy may be gone by now; the stored one stands in.
|
|
916
|
+
const fallbackUrl = url !== attachment.url && isHttpUrl(attachment.url) ? attachment.url : undefined;
|
|
917
|
+
return {
|
|
918
|
+
id: attachment.id,
|
|
919
|
+
url,
|
|
920
|
+
name: attachment.name,
|
|
921
|
+
large: isLargeDownload(url, attachment.size),
|
|
922
|
+
fallbackUrl
|
|
923
|
+
};
|
|
924
|
+
}).filter(photo => !!photo.url);
|
|
925
|
+
const index = photos.findIndex(photo => photo.id === tapped.id);
|
|
926
|
+
onOpenImage(index < 0 ? {
|
|
927
|
+
images: [tapped],
|
|
928
|
+
index: 0
|
|
929
|
+
} : {
|
|
930
|
+
images: photos.map(({
|
|
931
|
+
url,
|
|
932
|
+
name,
|
|
933
|
+
large,
|
|
934
|
+
fallbackUrl
|
|
935
|
+
}) => ({
|
|
936
|
+
url,
|
|
937
|
+
name,
|
|
938
|
+
large,
|
|
939
|
+
fallbackUrl
|
|
940
|
+
})),
|
|
941
|
+
index
|
|
942
|
+
});
|
|
943
|
+
};
|
|
944
|
+
const renderAttachment = attachment => /*#__PURE__*/_jsx(AttachmentPreview, {
|
|
945
|
+
loadPriority: sentAt,
|
|
946
|
+
attachment: attachment,
|
|
947
|
+
isVisitor: isVisitor,
|
|
948
|
+
color: color,
|
|
949
|
+
voiceLabels: labels.voice,
|
|
950
|
+
openLabel: labels.openAttachment,
|
|
951
|
+
onOpenImage: openPhotos
|
|
952
|
+
}, attachment.id);
|
|
667
953
|
return /*#__PURE__*/_jsxs(View, {
|
|
668
954
|
style: [styles.messageRow, isVisitor ? styles.messageRowRight : styles.messageRowLeft],
|
|
669
955
|
children: [senderLabel ? /*#__PURE__*/_jsx(Text, {
|
|
@@ -673,7 +959,13 @@ const MessageBubble = /*#__PURE__*/memo(function MessageBubble({
|
|
|
673
959
|
style: [styles.bubble, isVisitor ? [styles.bubbleVisitor, {
|
|
674
960
|
backgroundColor: color
|
|
675
961
|
}] : styles.bubbleAgent, rtl && (isVisitor ? styles.bubbleVisitorRTL : styles.bubbleAgentRTL), message.status === 'pending' && styles.bubblePending],
|
|
676
|
-
children: [
|
|
962
|
+
children: [inlineParts ? /*#__PURE__*/_jsx(View, {
|
|
963
|
+
style: styles.inlineParts,
|
|
964
|
+
children: inlineParts.parts.map((part, index) => 'text' in part ? /*#__PURE__*/_jsx(Markdown, {
|
|
965
|
+
content: part.text,
|
|
966
|
+
color: textColor
|
|
967
|
+
}, `text-${index}`) : renderAttachment(part.attachment))
|
|
968
|
+
}) : hasBody ? isVisitor ? /*#__PURE__*/_jsx(Text, {
|
|
677
969
|
style: [styles.bubbleText, {
|
|
678
970
|
color: textColor
|
|
679
971
|
}],
|
|
@@ -681,16 +973,14 @@ const MessageBubble = /*#__PURE__*/memo(function MessageBubble({
|
|
|
681
973
|
}) : /*#__PURE__*/_jsx(Markdown, {
|
|
682
974
|
content: messageText,
|
|
683
975
|
color: textColor
|
|
684
|
-
}) : null,
|
|
685
|
-
style: [styles.attachments, hasBody && styles.attachmentsSpaced],
|
|
686
|
-
children:
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
onOpenImage: onOpenImage
|
|
693
|
-
}, attachment.id))
|
|
976
|
+
}) : null, files.length > 0 ? /*#__PURE__*/_jsxs(View, {
|
|
977
|
+
style: [styles.attachments, (hasBody || !!inlineParts) && styles.attachmentsSpaced],
|
|
978
|
+
children: [album ? /*#__PURE__*/_jsx(PhotoAlbum, {
|
|
979
|
+
photos: album,
|
|
980
|
+
loadPriority: sentAt,
|
|
981
|
+
moreLabel: labels.morePhotos,
|
|
982
|
+
onOpen: openPhotos
|
|
983
|
+
}) : null, (album ? files.filter(attachment => !album.includes(attachment)) : files).map(renderAttachment)]
|
|
694
984
|
}) : null, timeLabel ? /*#__PURE__*/_jsx(Text, {
|
|
695
985
|
style: [styles.messageTime, {
|
|
696
986
|
color: isVisitor ? withAlpha(palette.white, 0.7) : palette.textSubtle
|
|
@@ -721,6 +1011,7 @@ const MessageBubble = /*#__PURE__*/memo(function MessageBubble({
|
|
|
721
1011
|
labels: labels.blocks,
|
|
722
1012
|
onAction: onAgentUiAction
|
|
723
1013
|
}) : null, agentUiBlocks.length === 0 && procedureForm && onSubmitProcedureForm ? /*#__PURE__*/_jsx(ProcedureFieldForm, {
|
|
1014
|
+
messageId: message.id,
|
|
724
1015
|
form: procedureForm,
|
|
725
1016
|
color: color,
|
|
726
1017
|
labels: labels,
|
|
@@ -734,26 +1025,194 @@ function receiptLabel(message, labels) {
|
|
|
734
1025
|
if (message.deletedAt || message.status === 'pending' || message.status === 'failed') return undefined;
|
|
735
1026
|
return message.status === 'read' ? labels.messageSeen : labels.messageSent;
|
|
736
1027
|
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* Where a photo shows from, and whether it may load yet. `url` is empty when
|
|
1031
|
+
* the server sent a non-http(s) URL (see resolveUrl). The visitor's own
|
|
1032
|
+
* attachment points at the picked file until the server copy replaces it (or
|
|
1033
|
+
* is empty without one). A photo they sent this session keeps showing from
|
|
1034
|
+
* the phone after that: the stored copy may not load yet. Whichever fails to
|
|
1035
|
+
* load gives way to the next.
|
|
1036
|
+
*/
|
|
1037
|
+
function usePhotoSource(attachment, loadPriority) {
|
|
1038
|
+
const [failedUrls, setFailedUrls] = useState([]);
|
|
1039
|
+
const [loadedUrl, setLoadedUrl] = useState(null);
|
|
1040
|
+
const url = [localPreviewFor(attachment.url), attachment.url].find(candidate => !!candidate && !failedUrls.includes(candidate));
|
|
1041
|
+
const isPhoto = isImageAttachment(attachment.contentType) && !!url;
|
|
1042
|
+
// A large download waits its turn; small photos and the phone's own copy don't.
|
|
1043
|
+
const [turn, settled] = useImageTurn(attachment.id || attachment.url, loadPriority, isPhoto && isLargeDownload(url, attachment.size));
|
|
1044
|
+
return {
|
|
1045
|
+
url,
|
|
1046
|
+
isPhoto,
|
|
1047
|
+
turn,
|
|
1048
|
+
/** Waiting for its turn or still downloading; the next URL after a failure starts over. */
|
|
1049
|
+
loading: isPhoto && loadedUrl !== url,
|
|
1050
|
+
onLoad: () => {
|
|
1051
|
+
settled();
|
|
1052
|
+
if (url) setLoadedUrl(url);
|
|
1053
|
+
},
|
|
1054
|
+
onError: () => {
|
|
1055
|
+
settled();
|
|
1056
|
+
if (!url) return;
|
|
1057
|
+
setFailedUrls(current => [...current, url]);
|
|
1058
|
+
// The viewer and the message's other views stop offering it too.
|
|
1059
|
+
if (url !== attachment.url && url === localPreviewFor(attachment.url)) forgetLocalPreview(attachment.url);
|
|
1060
|
+
}
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Several photos in one message, the way WhatsApp shows them: one square,
|
|
1066
|
+
* two side by side, three as one tall beside two stacked, four in a grid, and
|
|
1067
|
+
* past four the last tile covered with how many more there are.
|
|
1068
|
+
*/
|
|
1069
|
+
function PhotoAlbum({
|
|
1070
|
+
photos,
|
|
1071
|
+
loadPriority,
|
|
1072
|
+
moreLabel,
|
|
1073
|
+
onOpen
|
|
1074
|
+
}) {
|
|
1075
|
+
const shown = photos.slice(0, 4);
|
|
1076
|
+
// The covered tile counts too: it's no more visible than the ones after it.
|
|
1077
|
+
const more = photos.length > 4 ? photos.length - 3 : 0;
|
|
1078
|
+
const tile = (attachment, index, size) => /*#__PURE__*/_jsx(PhotoTile, {
|
|
1079
|
+
attachment: attachment,
|
|
1080
|
+
loadPriority: loadPriority,
|
|
1081
|
+
style: size,
|
|
1082
|
+
more: index === 3 ? more : 0,
|
|
1083
|
+
moreLabel: moreLabel,
|
|
1084
|
+
onOpen: onOpen
|
|
1085
|
+
}, attachment.id);
|
|
1086
|
+
if (shown.length === 2) {
|
|
1087
|
+
return /*#__PURE__*/_jsx(View, {
|
|
1088
|
+
style: [styles.album, styles.albumRow],
|
|
1089
|
+
children: shown.map((photo, i) => tile(photo, i, styles.albumHalf))
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
if (shown.length === 3) {
|
|
1093
|
+
return /*#__PURE__*/_jsxs(View, {
|
|
1094
|
+
style: [styles.album, styles.albumRow],
|
|
1095
|
+
children: [tile(shown[0], 0, styles.albumHalf), /*#__PURE__*/_jsx(View, {
|
|
1096
|
+
style: styles.albumColumn,
|
|
1097
|
+
children: shown.slice(1).map((photo, i) => tile(photo, i + 1, styles.albumQuarter))
|
|
1098
|
+
})]
|
|
1099
|
+
});
|
|
1100
|
+
}
|
|
1101
|
+
return /*#__PURE__*/_jsx(View, {
|
|
1102
|
+
style: [styles.album, styles.albumGrid],
|
|
1103
|
+
children: shown.map((photo, i) => tile(photo, i, styles.albumQuarter))
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
function PhotoTile({
|
|
1107
|
+
attachment,
|
|
1108
|
+
loadPriority,
|
|
1109
|
+
style,
|
|
1110
|
+
more,
|
|
1111
|
+
moreLabel,
|
|
1112
|
+
onOpen
|
|
1113
|
+
}) {
|
|
1114
|
+
const photo = usePhotoSource(attachment, loadPriority);
|
|
1115
|
+
// One that can't load still opens, so the viewer can offer the browser.
|
|
1116
|
+
const openUrl = photo.url ?? (isHttpUrl(attachment.url) ? attachment.url : undefined);
|
|
1117
|
+
return /*#__PURE__*/_jsxs(Pressable, {
|
|
1118
|
+
onPress: openUrl ? () => onOpen({
|
|
1119
|
+
id: attachment.id,
|
|
1120
|
+
url: openUrl,
|
|
1121
|
+
name: attachment.name
|
|
1122
|
+
}) : undefined,
|
|
1123
|
+
accessibilityRole: "imagebutton",
|
|
1124
|
+
accessibilityLabel: more > 0 ? `${attachment.name}, ${formatLabel(moreLabel, {
|
|
1125
|
+
count: more
|
|
1126
|
+
})}` : attachment.name,
|
|
1127
|
+
style: ({
|
|
1128
|
+
pressed
|
|
1129
|
+
}) => [styles.albumTile, style, pressed && globalStyles.pressed],
|
|
1130
|
+
children: [photo.isPhoto && photo.turn ? /*#__PURE__*/_jsx(Image, {
|
|
1131
|
+
source: {
|
|
1132
|
+
uri: photo.url
|
|
1133
|
+
},
|
|
1134
|
+
style: styles.albumImage,
|
|
1135
|
+
resizeMode: "cover"
|
|
1136
|
+
// Decoded at the tile's size, like a single photo.
|
|
1137
|
+
,
|
|
1138
|
+
resizeMethod: "resize",
|
|
1139
|
+
accessibilityIgnoresInvertColors: true,
|
|
1140
|
+
onLoad: photo.onLoad,
|
|
1141
|
+
onError: photo.onError
|
|
1142
|
+
}) : null, !photo.isPhoto ?
|
|
1143
|
+
/*#__PURE__*/
|
|
1144
|
+
// Nothing left to load (an expired link): its type and name, not a blank square.
|
|
1145
|
+
_jsxs(View, {
|
|
1146
|
+
style: styles.albumFallback,
|
|
1147
|
+
children: [/*#__PURE__*/_jsx(Text, {
|
|
1148
|
+
style: styles.albumFallbackType,
|
|
1149
|
+
children: fileTypeLabel(attachment.name, attachment.contentType)
|
|
1150
|
+
}), /*#__PURE__*/_jsx(Text, {
|
|
1151
|
+
style: styles.albumFallbackName,
|
|
1152
|
+
numberOfLines: 2,
|
|
1153
|
+
children: attachment.name
|
|
1154
|
+
})]
|
|
1155
|
+
}) : null, /*#__PURE__*/_jsx(PhotoLoader, {
|
|
1156
|
+
loading: photo.loading && more === 0
|
|
1157
|
+
}), more > 0 ? /*#__PURE__*/_jsx(View, {
|
|
1158
|
+
style: styles.albumMore,
|
|
1159
|
+
children: /*#__PURE__*/_jsxs(Text, {
|
|
1160
|
+
style: styles.albumMoreText,
|
|
1161
|
+
children: ["+", more]
|
|
1162
|
+
})
|
|
1163
|
+
}) : null]
|
|
1164
|
+
});
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* Spins over a photo until it has loaded. Held back a moment, so a photo that
|
|
1169
|
+
* comes straight from the cache never flashes it.
|
|
1170
|
+
*/
|
|
1171
|
+
function PhotoLoader({
|
|
1172
|
+
loading
|
|
1173
|
+
}) {
|
|
1174
|
+
const [due, setDue] = useState(false);
|
|
1175
|
+
useEffect(() => {
|
|
1176
|
+
if (!loading) return undefined;
|
|
1177
|
+
const timer = setTimeout(() => setDue(true), PHOTO_LOADER_DELAY_MS);
|
|
1178
|
+
return () => {
|
|
1179
|
+
clearTimeout(timer);
|
|
1180
|
+
setDue(false);
|
|
1181
|
+
};
|
|
1182
|
+
}, [loading]);
|
|
1183
|
+
if (!loading || !due) return null;
|
|
1184
|
+
return /*#__PURE__*/_jsx(View, {
|
|
1185
|
+
style: styles.photoLoader,
|
|
1186
|
+
pointerEvents: "none",
|
|
1187
|
+
children: /*#__PURE__*/_jsx(View, {
|
|
1188
|
+
style: styles.photoLoaderBadge,
|
|
1189
|
+
children: /*#__PURE__*/_jsx(ActivityIndicator, {
|
|
1190
|
+
size: "small",
|
|
1191
|
+
color: palette.white
|
|
1192
|
+
})
|
|
1193
|
+
})
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
737
1196
|
function AttachmentPreview({
|
|
738
1197
|
attachment,
|
|
1198
|
+
loadPriority,
|
|
739
1199
|
isVisitor,
|
|
740
1200
|
color,
|
|
741
1201
|
voiceLabels,
|
|
742
1202
|
openLabel,
|
|
743
1203
|
onOpenImage
|
|
744
1204
|
}) {
|
|
745
|
-
// `url` is empty when the server sent a non-http(s) URL (see resolveUrl).
|
|
746
|
-
// The visitor's own attachment points at the picked file until the server
|
|
747
|
-
// copy replaces it (or is empty without one).
|
|
748
|
-
const [imageFailed, setImageFailed] = useState(false);
|
|
749
1205
|
const [audioFailed, setAudioFailed] = useState(false);
|
|
750
|
-
const
|
|
1206
|
+
const photo = usePhotoSource(attachment, loadPriority);
|
|
1207
|
+
const imageUrl = photo.url;
|
|
1208
|
+
const isImage = photo.isPhoto;
|
|
751
1209
|
const openable = isHttpUrl(attachment.url);
|
|
752
1210
|
// Images open in the in-app viewer, a local photo too since it already
|
|
753
1211
|
// renders here. Other files (PDF, sheets) need the OS, which can only open
|
|
754
1212
|
// the server copy.
|
|
755
1213
|
const open = isImage ? () => onOpenImage({
|
|
756
|
-
|
|
1214
|
+
id: attachment.id,
|
|
1215
|
+
url: imageUrl,
|
|
757
1216
|
name: attachment.name
|
|
758
1217
|
}) : openable ? () => void openExternalUrl(attachment.url) : undefined;
|
|
759
1218
|
|
|
@@ -772,22 +1231,35 @@ function AttachmentPreview({
|
|
|
772
1231
|
});
|
|
773
1232
|
}
|
|
774
1233
|
if (isImage) {
|
|
775
|
-
return /*#__PURE__*/
|
|
1234
|
+
return /*#__PURE__*/_jsxs(Pressable, {
|
|
776
1235
|
onPress: open,
|
|
777
1236
|
accessibilityRole: "imagebutton",
|
|
778
1237
|
accessibilityLabel: attachment.name,
|
|
779
|
-
children: /*#__PURE__*/_jsx(Image, {
|
|
1238
|
+
children: [photo.turn ? /*#__PURE__*/_jsx(Image, {
|
|
780
1239
|
source: {
|
|
781
|
-
uri:
|
|
1240
|
+
uri: imageUrl
|
|
782
1241
|
},
|
|
783
1242
|
style: styles.attachmentImage,
|
|
784
|
-
resizeMode: "cover"
|
|
785
|
-
|
|
1243
|
+
resizeMode: "cover"
|
|
1244
|
+
// Android otherwise decodes a server image at full size: two 20 MP
|
|
1245
|
+
// photos fill its shared bitmap budget, and every later image in
|
|
1246
|
+
// the app fails to load. Decoded at the bubble's size instead.
|
|
1247
|
+
,
|
|
1248
|
+
resizeMethod: "resize",
|
|
1249
|
+
accessibilityIgnoresInvertColors: true,
|
|
1250
|
+
onLoad: photo.onLoad
|
|
786
1251
|
// An image that can't load (expired link, deleted file) falls back to
|
|
787
1252
|
// the file row below instead of an empty box.
|
|
788
1253
|
,
|
|
789
|
-
onError:
|
|
790
|
-
})
|
|
1254
|
+
onError: photo.onError
|
|
1255
|
+
}) :
|
|
1256
|
+
/*#__PURE__*/
|
|
1257
|
+
// Same box as the image, so nothing moves when its turn comes.
|
|
1258
|
+
_jsx(View, {
|
|
1259
|
+
style: styles.attachmentImage
|
|
1260
|
+
}), /*#__PURE__*/_jsx(PhotoLoader, {
|
|
1261
|
+
loading: photo.loading
|
|
1262
|
+
})]
|
|
791
1263
|
});
|
|
792
1264
|
}
|
|
793
1265
|
return /*#__PURE__*/_jsx(FileCard, {
|
|
@@ -801,14 +1273,15 @@ function AttachmentPreview({
|
|
|
801
1273
|
});
|
|
802
1274
|
}
|
|
803
1275
|
function ProcedureFieldForm({
|
|
1276
|
+
messageId,
|
|
804
1277
|
form,
|
|
805
1278
|
color,
|
|
806
1279
|
labels,
|
|
807
1280
|
onSubmit
|
|
808
1281
|
}) {
|
|
809
|
-
const [values, setValues] =
|
|
810
|
-
const [submitted, setSubmitted] =
|
|
811
|
-
const [expanded, setExpanded] =
|
|
1282
|
+
const [values, setValues] = useRetainedState(`${messageId}:values`, {});
|
|
1283
|
+
const [submitted, setSubmitted] = useRetainedState(`${messageId}:submitted`, false);
|
|
1284
|
+
const [expanded, setExpanded] = useRetainedState(`${messageId}:expanded`, false);
|
|
812
1285
|
const canSubmit = form.fields.every(field => !field.required || values[field.name]?.trim());
|
|
813
1286
|
const handleSubmit = () => {
|
|
814
1287
|
if (!canSubmit || submitted) return;
|