@droppii-org/chat-mobile 0.2.51 → 0.2.52
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/lib/module/assets/images/index.js +2 -1
- package/lib/module/assets/images/index.js.map +1 -1
- package/lib/module/assets/images/resource-center-empty-box.png +0 -0
- package/lib/module/components/MediaViewerModal.js +2 -1
- package/lib/module/components/MediaViewerModal.js.map +1 -1
- package/lib/module/components/MergedImageGrid.js +1 -0
- package/lib/module/components/MergedImageGrid.js.map +1 -1
- package/lib/module/config/api-endpoints.js +2 -1
- package/lib/module/config/api-endpoints.js.map +1 -1
- package/lib/module/core/notification/pushToken.js +7 -7
- package/lib/module/core/notification/pushToken.js.map +1 -1
- package/lib/module/hooks/query-keys.js +4 -0
- package/lib/module/hooks/query-keys.js.map +1 -1
- package/lib/module/hooks/resourceCenter/useThreadResources.js +116 -0
- package/lib/module/hooks/resourceCenter/useThreadResources.js.map +1 -0
- package/lib/module/hooks/useImageAttachment.js +0 -2
- package/lib/module/hooks/useImageAttachment.js.map +1 -1
- package/lib/module/hooks/useMediaViewer.js +4 -1
- package/lib/module/hooks/useMediaViewer.js.map +1 -1
- package/lib/module/hooks/useSendAttachment.js +4 -1
- package/lib/module/hooks/useSendAttachment.js.map +1 -1
- package/lib/module/index.js +1 -0
- package/lib/module/index.js.map +1 -1
- package/lib/module/screens/MediaView/index.js +104 -42
- package/lib/module/screens/MediaView/index.js.map +1 -1
- package/lib/module/screens/ResourceCenter/FileResourceRow.js +80 -0
- package/lib/module/screens/ResourceCenter/FileResourceRow.js.map +1 -0
- package/lib/module/screens/ResourceCenter/GridRenderer.js +83 -0
- package/lib/module/screens/ResourceCenter/GridRenderer.js.map +1 -0
- package/lib/module/screens/ResourceCenter/GridThumbnail.js +97 -0
- package/lib/module/screens/ResourceCenter/GridThumbnail.js.map +1 -0
- package/lib/module/screens/ResourceCenter/LinkResourceRow.js +81 -0
- package/lib/module/screens/ResourceCenter/LinkResourceRow.js.map +1 -0
- package/lib/module/screens/ResourceCenter/ListRenderer.js +60 -0
- package/lib/module/screens/ResourceCenter/ListRenderer.js.map +1 -0
- package/lib/module/screens/ResourceCenter/ResourceCenterTabBar.js +75 -0
- package/lib/module/screens/ResourceCenter/ResourceCenterTabBar.js.map +1 -0
- package/lib/module/screens/ResourceCenter/ResourceCenterTabScene.js +122 -0
- package/lib/module/screens/ResourceCenter/ResourceCenterTabScene.js.map +1 -0
- package/lib/module/screens/ResourceCenter/ResourceEmptyState.js +36 -0
- package/lib/module/screens/ResourceCenter/ResourceEmptyState.js.map +1 -0
- package/lib/module/screens/ResourceCenter/index.js +104 -0
- package/lib/module/screens/ResourceCenter/index.js.map +1 -0
- package/lib/module/screens/ResourceCenter/types.js +25 -0
- package/lib/module/screens/ResourceCenter/types.js.map +1 -0
- package/lib/module/services/attachmentOrchestrator.js +19 -4
- package/lib/module/services/attachmentOrchestrator.js.map +1 -1
- package/lib/module/services/resourceCenter.js +102 -0
- package/lib/module/services/resourceCenter.js.map +1 -0
- package/lib/module/translation/resources/i18n.js +8 -1
- package/lib/module/translation/resources/i18n.js.map +1 -1
- package/lib/module/types/attachment.js +2 -0
- package/lib/module/types/chat.js +20 -0
- package/lib/module/types/chat.js.map +1 -1
- package/lib/module/utils/url.js +11 -0
- package/lib/module/utils/url.js.map +1 -1
- package/lib/typescript/src/assets/images/index.d.ts +1 -0
- package/lib/typescript/src/assets/images/index.d.ts.map +1 -1
- package/lib/typescript/src/components/MediaViewerModal.d.ts +3 -1
- package/lib/typescript/src/components/MediaViewerModal.d.ts.map +1 -1
- package/lib/typescript/src/components/MergedImageGrid.d.ts.map +1 -1
- package/lib/typescript/src/config/api-endpoints.d.ts +1 -0
- package/lib/typescript/src/config/api-endpoints.d.ts.map +1 -1
- package/lib/typescript/src/hooks/query-keys.d.ts +4 -0
- package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
- package/lib/typescript/src/hooks/resourceCenter/useThreadResources.d.ts +21 -0
- package/lib/typescript/src/hooks/resourceCenter/useThreadResources.d.ts.map +1 -0
- package/lib/typescript/src/hooks/useImageAttachment.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useMediaViewer.d.ts +5 -1
- package/lib/typescript/src/hooks/useMediaViewer.d.ts.map +1 -1
- package/lib/typescript/src/hooks/useSendAttachment.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +2 -0
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/screens/MediaView/index.d.ts.map +1 -1
- package/lib/typescript/src/screens/ResourceCenter/FileResourceRow.d.ts +7 -0
- package/lib/typescript/src/screens/ResourceCenter/FileResourceRow.d.ts.map +1 -0
- package/lib/typescript/src/screens/ResourceCenter/GridRenderer.d.ts +14 -0
- package/lib/typescript/src/screens/ResourceCenter/GridRenderer.d.ts.map +1 -0
- package/lib/typescript/src/screens/ResourceCenter/GridThumbnail.d.ts +10 -0
- package/lib/typescript/src/screens/ResourceCenter/GridThumbnail.d.ts.map +1 -0
- package/lib/typescript/src/screens/ResourceCenter/LinkResourceRow.d.ts +8 -0
- package/lib/typescript/src/screens/ResourceCenter/LinkResourceRow.d.ts.map +1 -0
- package/lib/typescript/src/screens/ResourceCenter/ListRenderer.d.ts +16 -0
- package/lib/typescript/src/screens/ResourceCenter/ListRenderer.d.ts.map +1 -0
- package/lib/typescript/src/screens/ResourceCenter/ResourceCenterTabBar.d.ts +3 -0
- package/lib/typescript/src/screens/ResourceCenter/ResourceCenterTabBar.d.ts.map +1 -0
- package/lib/typescript/src/screens/ResourceCenter/ResourceCenterTabScene.d.ts +16 -0
- package/lib/typescript/src/screens/ResourceCenter/ResourceCenterTabScene.d.ts.map +1 -0
- package/lib/typescript/src/screens/ResourceCenter/ResourceEmptyState.d.ts +6 -0
- package/lib/typescript/src/screens/ResourceCenter/ResourceEmptyState.d.ts.map +1 -0
- package/lib/typescript/src/screens/ResourceCenter/index.d.ts +4 -0
- package/lib/typescript/src/screens/ResourceCenter/index.d.ts.map +1 -0
- package/lib/typescript/src/screens/ResourceCenter/types.d.ts +28 -0
- package/lib/typescript/src/screens/ResourceCenter/types.d.ts.map +1 -0
- package/lib/typescript/src/services/attachmentOrchestrator.d.ts.map +1 -1
- package/lib/typescript/src/services/resourceCenter.d.ts +9 -0
- package/lib/typescript/src/services/resourceCenter.d.ts.map +1 -0
- package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
- package/lib/typescript/src/types/attachment.d.ts +2 -0
- package/lib/typescript/src/types/attachment.d.ts.map +1 -1
- package/lib/typescript/src/types/chat.d.ts +58 -0
- package/lib/typescript/src/types/chat.d.ts.map +1 -1
- package/lib/typescript/src/utils/url.d.ts +7 -0
- package/lib/typescript/src/utils/url.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/assets/images/index.ts +1 -0
- package/src/assets/images/resource-center-empty-box.png +0 -0
- package/src/components/MediaViewerModal.tsx +8 -3
- package/src/components/MergedImageGrid.tsx +1 -0
- package/src/config/api-endpoints.ts +2 -0
- package/src/core/notification/pushToken.ts +7 -7
- package/src/hooks/query-keys.ts +6 -0
- package/src/hooks/resourceCenter/useThreadResources.ts +124 -0
- package/src/hooks/useImageAttachment.ts +1 -2
- package/src/hooks/useMediaViewer.ts +21 -7
- package/src/hooks/useSendAttachment.ts +4 -1
- package/src/index.tsx +2 -0
- package/src/screens/MediaView/index.tsx +117 -54
- package/src/screens/ResourceCenter/FileResourceRow.tsx +99 -0
- package/src/screens/ResourceCenter/GridRenderer.tsx +104 -0
- package/src/screens/ResourceCenter/GridThumbnail.tsx +98 -0
- package/src/screens/ResourceCenter/LinkResourceRow.tsx +96 -0
- package/src/screens/ResourceCenter/ListRenderer.tsx +81 -0
- package/src/screens/ResourceCenter/ResourceCenterTabBar.tsx +89 -0
- package/src/screens/ResourceCenter/ResourceCenterTabScene.tsx +142 -0
- package/src/screens/ResourceCenter/ResourceEmptyState.tsx +39 -0
- package/src/screens/ResourceCenter/index.tsx +129 -0
- package/src/screens/ResourceCenter/types.ts +56 -0
- package/src/services/attachmentOrchestrator.ts +15 -2
- package/src/services/resourceCenter.ts +137 -0
- package/src/translation/resources/i18n.ts +7 -0
- package/src/types/attachment.ts +3 -0
- package/src/types/chat.ts +73 -0
- package/src/utils/url.ts +11 -0
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
|
2
2
|
import type { ComponentType } from 'react';
|
|
3
3
|
import {
|
|
4
|
+
ActivityIndicator,
|
|
4
5
|
BackHandler,
|
|
5
6
|
Platform,
|
|
6
7
|
StyleSheet,
|
|
7
|
-
FlatList,
|
|
8
|
-
type ListRenderItemInfo,
|
|
9
8
|
type ViewToken,
|
|
10
9
|
StatusBar,
|
|
11
10
|
} from 'react-native';
|
|
11
|
+
import {
|
|
12
|
+
LegendList,
|
|
13
|
+
type LegendListRef,
|
|
14
|
+
type LegendListRenderItemProps,
|
|
15
|
+
} from '@legendapp/list/react-native';
|
|
12
16
|
|
|
13
17
|
import LinearGradient from 'react-native-linear-gradient';
|
|
14
18
|
import Animated, {
|
|
@@ -19,6 +23,9 @@ import Animated, {
|
|
|
19
23
|
} from 'react-native-reanimated';
|
|
20
24
|
import { Pagination } from 'react-native-snap-carousel';
|
|
21
25
|
import PhotoView from 'react-native-photo-view';
|
|
26
|
+
import RNFS from 'react-native-fs';
|
|
27
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
28
|
+
import { saveDocuments } from '@react-native-documents/picker';
|
|
22
29
|
import {
|
|
23
30
|
KColors,
|
|
24
31
|
KContainer,
|
|
@@ -29,6 +36,9 @@ import {
|
|
|
29
36
|
KSpacingValue,
|
|
30
37
|
} from '@droppii/libs';
|
|
31
38
|
import VideoPlayer from './VideoPlayer';
|
|
39
|
+
import { UIUtils } from '../../utils/UIUtils';
|
|
40
|
+
import { trans } from '../../translation';
|
|
41
|
+
import { getMimeType } from '../../utils/imageUtils';
|
|
32
42
|
|
|
33
43
|
interface MediaViewerParams {
|
|
34
44
|
images: any[];
|
|
@@ -49,15 +59,62 @@ const MediaViewerScreen = (props: MediaViewerParams) => {
|
|
|
49
59
|
onDismiss,
|
|
50
60
|
} = props;
|
|
51
61
|
|
|
52
|
-
const
|
|
62
|
+
const insets = useSafeAreaInsets();
|
|
63
|
+
const listRef = useRef<LegendListRef>(null);
|
|
53
64
|
const [index, setIndex] = useState(initIndex);
|
|
54
65
|
const [isScrolling, setIsScrolling] = useState(false);
|
|
66
|
+
const [isDownloading, setIsDownloading] = useState(false);
|
|
55
67
|
|
|
56
68
|
const dismiss = useCallback(() => {
|
|
57
69
|
onDismiss?.();
|
|
58
70
|
}, [onDismiss]);
|
|
59
71
|
|
|
60
|
-
|
|
72
|
+
// Downloads the currently-viewed image/video, mirroring `useFileDownload`'s
|
|
73
|
+
// RNFS-download + Save-As-document pattern (no gallery/CameraRoll dependency
|
|
74
|
+
// exists in this repo — Save As is the only working "download" mechanism here).
|
|
75
|
+
const onDownload = useCallback(async () => {
|
|
76
|
+
const item = images[index];
|
|
77
|
+
if (!item?.url || isDownloading) return;
|
|
78
|
+
|
|
79
|
+
setIsDownloading(true);
|
|
80
|
+
try {
|
|
81
|
+
const urlPath = item.url.split('?')[0];
|
|
82
|
+
const fallbackExt = item.isVideo ? 'mp4' : 'jpg';
|
|
83
|
+
const fileName =
|
|
84
|
+
urlPath.split('/').pop() ||
|
|
85
|
+
`${item.isVideo ? 'video' : 'image'}-${index}.${fallbackExt}`;
|
|
86
|
+
const destPath = `${RNFS.CachesDirectoryPath}/${fileName}`;
|
|
87
|
+
|
|
88
|
+
const exists = await RNFS.exists(destPath);
|
|
89
|
+
if (!exists) {
|
|
90
|
+
const result = await RNFS.downloadFile({
|
|
91
|
+
fromUrl: item.url,
|
|
92
|
+
toFile: destPath,
|
|
93
|
+
}).promise;
|
|
94
|
+
if (result.statusCode < 200 || result.statusCode >= 300) {
|
|
95
|
+
throw new Error(`HTTP ${result.statusCode}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await saveDocuments({
|
|
100
|
+
sourceUris: [`file://${destPath}`],
|
|
101
|
+
mimeType: getMimeType(fileName),
|
|
102
|
+
fileName,
|
|
103
|
+
copy: true,
|
|
104
|
+
});
|
|
105
|
+
UIUtils.toast.open({
|
|
106
|
+
title: trans('chat:file_downloaded_success', { fileName }),
|
|
107
|
+
theme: 'info',
|
|
108
|
+
});
|
|
109
|
+
} catch {
|
|
110
|
+
UIUtils.toast.open({
|
|
111
|
+
title: trans('chat:file_download_failed'),
|
|
112
|
+
theme: 'warning',
|
|
113
|
+
});
|
|
114
|
+
} finally {
|
|
115
|
+
setIsDownloading(false);
|
|
116
|
+
}
|
|
117
|
+
}, [images, index, isDownloading]);
|
|
61
118
|
|
|
62
119
|
useEffect(() => {
|
|
63
120
|
StatusBar.setHidden(true);
|
|
@@ -79,22 +136,6 @@ const MediaViewerScreen = (props: MediaViewerParams) => {
|
|
|
79
136
|
const onMomentumScrollBegin = useCallback(() => setIsScrolling(true), []);
|
|
80
137
|
const onMomentumScrollEnd = useCallback(() => setIsScrolling(false), []);
|
|
81
138
|
|
|
82
|
-
const onScrollToIndexFailed = useCallback(
|
|
83
|
-
(info: any) => {
|
|
84
|
-
const len = images.length;
|
|
85
|
-
const wait = new Promise((resolve) => setTimeout(() => resolve(true), 0));
|
|
86
|
-
wait.then(() => {
|
|
87
|
-
if (info.index > -1 && info.index < len) {
|
|
88
|
-
listRef.current?.scrollToIndex({
|
|
89
|
-
index: info.index,
|
|
90
|
-
animated: false,
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
});
|
|
94
|
-
},
|
|
95
|
-
[images]
|
|
96
|
-
);
|
|
97
|
-
|
|
98
139
|
const onViewableItemsChanged = useRef(
|
|
99
140
|
({ viewableItems }: { viewableItems: ViewToken[] }) => {
|
|
100
141
|
const i = viewableItems[0]?.index;
|
|
@@ -105,7 +146,7 @@ const MediaViewerScreen = (props: MediaViewerParams) => {
|
|
|
105
146
|
const keyEx = useCallback((item: any, i: number) => `${item.url}-${i}`, []);
|
|
106
147
|
|
|
107
148
|
const renderItem = useCallback(
|
|
108
|
-
({ item, index: itemIndex }:
|
|
149
|
+
({ item, index: itemIndex }: LegendListRenderItemProps<any>) => (
|
|
109
150
|
<KContainer.Touchable center activeOpacity={1}>
|
|
110
151
|
<KContainer.VisibleView visible={!!item.isVideo}>
|
|
111
152
|
<VideoPlayer
|
|
@@ -130,9 +171,21 @@ const MediaViewerScreen = (props: MediaViewerParams) => {
|
|
|
130
171
|
);
|
|
131
172
|
|
|
132
173
|
const renderFooter = useMemo(() => {
|
|
133
|
-
|
|
174
|
+
// `pagination` only toggles the dot indicator (noisy on large sets like
|
|
175
|
+
// Resource Center grids) — the current-index counter should always show.
|
|
176
|
+
if (images.length <= 1) return null;
|
|
134
177
|
return (
|
|
135
|
-
<KContainer.View
|
|
178
|
+
<KContainer.View
|
|
179
|
+
style={[
|
|
180
|
+
styles.footer,
|
|
181
|
+
{
|
|
182
|
+
bottom:
|
|
183
|
+
insets.bottom > 0
|
|
184
|
+
? insets.bottom + KSpacingValue['0.5rem']
|
|
185
|
+
: KSpacingValue['2rem'],
|
|
186
|
+
},
|
|
187
|
+
]}
|
|
188
|
+
>
|
|
136
189
|
<KLabel.Text
|
|
137
190
|
typo="TextNmNormal"
|
|
138
191
|
color={KColors.white}
|
|
@@ -140,18 +193,20 @@ const MediaViewerScreen = (props: MediaViewerParams) => {
|
|
|
140
193
|
>
|
|
141
194
|
{index + 1}/{images.length}
|
|
142
195
|
</KLabel.Text>
|
|
143
|
-
<
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
196
|
+
<KContainer.VisibleView visible={pagination !== false}>
|
|
197
|
+
<Pagination
|
|
198
|
+
dotContainerStyle={styles.dotContainerStyle}
|
|
199
|
+
dotStyle={styles.dot}
|
|
200
|
+
inactiveDotStyle={styles.inactiveDot}
|
|
201
|
+
inactiveDotScale={0.6}
|
|
202
|
+
dotsLength={images.length}
|
|
203
|
+
activeDotIndex={index}
|
|
204
|
+
inactiveDotOpacity={0.5}
|
|
205
|
+
/>
|
|
206
|
+
</KContainer.VisibleView>
|
|
152
207
|
</KContainer.View>
|
|
153
208
|
);
|
|
154
|
-
}, [pagination, images.length, index]);
|
|
209
|
+
}, [pagination, images.length, index, insets.bottom]);
|
|
155
210
|
return (
|
|
156
211
|
<Animated.View
|
|
157
212
|
style={styles.wrapper}
|
|
@@ -166,7 +221,7 @@ const MediaViewerScreen = (props: MediaViewerParams) => {
|
|
|
166
221
|
{/* Top gradient overlay */}
|
|
167
222
|
<LinearGradient
|
|
168
223
|
colors={['rgba(0,0,0,0.9)', 'rgba(0,0,0,0)']}
|
|
169
|
-
style={[styles.topOverlay]}
|
|
224
|
+
style={[styles.topOverlay, { paddingTop: insets.top }]}
|
|
170
225
|
pointerEvents="box-none"
|
|
171
226
|
>
|
|
172
227
|
{/* Header first row — matches Figma: [close] [flex-1 spacer] [action-group] */}
|
|
@@ -184,22 +239,33 @@ const MediaViewerScreen = (props: MediaViewerParams) => {
|
|
|
184
239
|
<KContainer.View flex />
|
|
185
240
|
|
|
186
241
|
{/* Download button */}
|
|
187
|
-
{download
|
|
242
|
+
<KContainer.VisibleView visible={!!download}>
|
|
188
243
|
<KContainer.Touchable
|
|
189
244
|
style={styles.headerBtn}
|
|
190
|
-
onPress={onDownload}
|
|
245
|
+
onPress={isDownloading ? undefined : onDownload}
|
|
246
|
+
disabled={isDownloading}
|
|
191
247
|
>
|
|
192
|
-
<
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
248
|
+
<KContainer.VisibleView visible={isDownloading}>
|
|
249
|
+
<ActivityIndicator size="small" color={KColors.white} />
|
|
250
|
+
</KContainer.VisibleView>
|
|
251
|
+
<KContainer.VisibleView visible={!isDownloading}>
|
|
252
|
+
<KImage.VectorIcons
|
|
253
|
+
name="download-o"
|
|
254
|
+
size={24}
|
|
255
|
+
color={KColors.white}
|
|
256
|
+
/>
|
|
257
|
+
</KContainer.VisibleView>
|
|
197
258
|
</KContainer.Touchable>
|
|
198
|
-
|
|
259
|
+
</KContainer.VisibleView>
|
|
199
260
|
</KContainer.View>
|
|
200
261
|
</LinearGradient>
|
|
201
|
-
{/* Media content
|
|
202
|
-
|
|
262
|
+
{/* Media content. `recycleItems` intentionally NOT set — `VideoPlayer`
|
|
263
|
+
wraps a native player instance keyed off mount, not just the `url`
|
|
264
|
+
prop, so reusing a recycled cell across a video/image swap risks
|
|
265
|
+
stale playback state. Dataset here is always small (one message's
|
|
266
|
+
attachments or a Resource Center snapshot), so the perf win isn't
|
|
267
|
+
worth that risk. */}
|
|
268
|
+
<LegendList
|
|
203
269
|
ref={listRef}
|
|
204
270
|
initialScrollIndex={
|
|
205
271
|
initIndex >= images?.length ? images?.length - 1 : initIndex
|
|
@@ -207,17 +273,14 @@ const MediaViewerScreen = (props: MediaViewerParams) => {
|
|
|
207
273
|
data={images}
|
|
208
274
|
renderItem={renderItem}
|
|
209
275
|
keyExtractor={keyEx}
|
|
276
|
+
estimatedItemSize={KDims.width}
|
|
210
277
|
pagingEnabled
|
|
211
278
|
horizontal
|
|
212
279
|
showsHorizontalScrollIndicator={false}
|
|
213
280
|
onViewableItemsChanged={onViewableItemsChanged}
|
|
214
281
|
viewabilityConfig={{ itemVisiblePercentThreshold: 50 }}
|
|
215
|
-
onScrollToIndexFailed={onScrollToIndexFailed}
|
|
216
282
|
onMomentumScrollBegin={onMomentumScrollBegin}
|
|
217
283
|
onMomentumScrollEnd={onMomentumScrollEnd}
|
|
218
|
-
removeClippedSubviews
|
|
219
|
-
maxToRenderPerBatch={2}
|
|
220
|
-
windowSize={3}
|
|
221
284
|
style={styles.flex}
|
|
222
285
|
/>
|
|
223
286
|
|
|
@@ -244,13 +307,14 @@ const styles = StyleSheet.create({
|
|
|
244
307
|
height: KDims.height,
|
|
245
308
|
},
|
|
246
309
|
topOverlay: {
|
|
247
|
-
//
|
|
310
|
+
// `paddingTop` is overridden inline with `useSafeAreaInsets().top` (real
|
|
311
|
+
// notch/status-bar inset, both platforms).
|
|
312
|
+
position: 'absolute',
|
|
248
313
|
top: 0,
|
|
249
314
|
left: 0,
|
|
250
315
|
right: 0,
|
|
251
316
|
zIndex: 100,
|
|
252
317
|
minHeight: 100,
|
|
253
|
-
paddingTop: 30,
|
|
254
318
|
},
|
|
255
319
|
// Figma: Header-First-Row — flex row, padding 8, alignItems center
|
|
256
320
|
headerRow: {
|
|
@@ -303,11 +367,10 @@ const styles = StyleSheet.create({
|
|
|
303
367
|
letterSpacing: 0.12,
|
|
304
368
|
},
|
|
305
369
|
footer: {
|
|
370
|
+
// `bottom` is overridden inline with `useSafeAreaInsets()` (real device
|
|
371
|
+
// inset, both platforms) — `KDims.bottomSafeHeight` only covers iPhone
|
|
372
|
+
// notch/Dynamic Island and misses Android gesture-nav insets.
|
|
306
373
|
position: 'absolute',
|
|
307
|
-
bottom:
|
|
308
|
-
KDims.bottomSafeHeight > 0
|
|
309
|
-
? KDims.bottomSafeHeight + KSpacingValue['0.5rem']
|
|
310
|
-
: KSpacingValue['2rem'],
|
|
311
374
|
alignSelf: 'center',
|
|
312
375
|
alignItems: 'center',
|
|
313
376
|
zIndex: 10,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { memo, useMemo } from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
KContainer,
|
|
5
|
+
KImage,
|
|
6
|
+
KLabel,
|
|
7
|
+
KColors,
|
|
8
|
+
KSpacingValue,
|
|
9
|
+
} from '@droppii/libs';
|
|
10
|
+
import { useFileDownload } from '../../hooks/useFileDownload';
|
|
11
|
+
import {
|
|
12
|
+
getFileIcon,
|
|
13
|
+
getMimeType,
|
|
14
|
+
formatFileSize,
|
|
15
|
+
} from '../../utils/imageUtils';
|
|
16
|
+
import { trans } from '../../translation';
|
|
17
|
+
import type { DThreadResource } from '../../types/chat';
|
|
18
|
+
|
|
19
|
+
interface Props {
|
|
20
|
+
item: DThreadResource;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const FileResourceRow = memo(({ item }: Props) => {
|
|
24
|
+
const fileName = item.fileName || '[File]';
|
|
25
|
+
const url = item.sourceUrl || '';
|
|
26
|
+
const { state, handleView } = useFileDownload(fileName, url);
|
|
27
|
+
|
|
28
|
+
const mimeType = useMemo(
|
|
29
|
+
() => item.mimeType || getMimeType(fileName),
|
|
30
|
+
[item.mimeType, fileName]
|
|
31
|
+
);
|
|
32
|
+
const iconSource = useMemo(() => getFileIcon(mimeType), [mimeType]);
|
|
33
|
+
|
|
34
|
+
const subtitle = useMemo(() => {
|
|
35
|
+
if (state.status === 'downloading') {
|
|
36
|
+
return trans('chat:file_downloading', {
|
|
37
|
+
received: state.receivedMB ?? 0,
|
|
38
|
+
total: state.totalMB ?? 0,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (state.status === 'error') {
|
|
42
|
+
return state.error ?? trans('chat:file_download_failed');
|
|
43
|
+
}
|
|
44
|
+
return formatFileSize(item.fileSize ?? 0);
|
|
45
|
+
}, [state, item.fileSize]);
|
|
46
|
+
|
|
47
|
+
const subtitleColor =
|
|
48
|
+
state.status === 'error'
|
|
49
|
+
? (KColors.palette.danger?.w400 ?? '#FF3B30')
|
|
50
|
+
: KColors.palette.gray.w400;
|
|
51
|
+
|
|
52
|
+
// Figma's File-Item component carries a trailing "Close-Button" (X in a dark
|
|
53
|
+
// circle) layer, but it's `visible: false` in every real row — the rendered
|
|
54
|
+
// design has NO trailing icon at all. Confirmed by product decision: no
|
|
55
|
+
// separate download button — the whole row is tappable (`handleView`, same
|
|
56
|
+
// cache-then-open behavior the in-chat `FileMessage` bubble's eye icon uses).
|
|
57
|
+
return (
|
|
58
|
+
<KContainer.Touchable
|
|
59
|
+
style={styles.row}
|
|
60
|
+
br="3x"
|
|
61
|
+
background={KColors.opacity.grayLighter[20]}
|
|
62
|
+
onPress={handleView}
|
|
63
|
+
>
|
|
64
|
+
<KImage.Base uri={iconSource} style={styles.icon} resizeMode="contain" />
|
|
65
|
+
<KContainer.View flexS={1}>
|
|
66
|
+
<KLabel.Text
|
|
67
|
+
typo="TextMdMedium"
|
|
68
|
+
color={KColors.palette.gray.w900}
|
|
69
|
+
numberOfLines={1}
|
|
70
|
+
ellipsizeMode="middle"
|
|
71
|
+
>
|
|
72
|
+
{fileName}
|
|
73
|
+
</KLabel.Text>
|
|
74
|
+
<KLabel.Text
|
|
75
|
+
typo="TextSmNormal"
|
|
76
|
+
color={subtitleColor}
|
|
77
|
+
numberOfLines={1}
|
|
78
|
+
>
|
|
79
|
+
{subtitle}
|
|
80
|
+
</KLabel.Text>
|
|
81
|
+
</KContainer.View>
|
|
82
|
+
</KContainer.Touchable>
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const styles = StyleSheet.create({
|
|
87
|
+
// Figma File-Item (`14208:88679`): 12px padding all sides, 8px gap, 40x40 icon.
|
|
88
|
+
row: {
|
|
89
|
+
flexDirection: 'row',
|
|
90
|
+
alignItems: 'center',
|
|
91
|
+
gap: KSpacingValue['0.5rem'],
|
|
92
|
+
padding: KSpacingValue['0.75rem'],
|
|
93
|
+
marginBottom: KSpacingValue['0.5rem'],
|
|
94
|
+
},
|
|
95
|
+
icon: {
|
|
96
|
+
width: 40,
|
|
97
|
+
height: 40,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { memo, useCallback, useMemo } from 'react';
|
|
2
|
+
import type { ReactElement } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
ActivityIndicator,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
useWindowDimensions,
|
|
7
|
+
} from 'react-native';
|
|
8
|
+
import { LegendList } from '@legendapp/list/react-native';
|
|
9
|
+
import { KContainer, KSpacingValue, KColors } from '@droppii/libs';
|
|
10
|
+
import { GridThumbnail } from './GridThumbnail';
|
|
11
|
+
import type { DThreadResource } from '../../types/chat';
|
|
12
|
+
|
|
13
|
+
const COLUMNS = 3;
|
|
14
|
+
const GRID_GAP = KSpacingValue['0.5rem']; // 8px
|
|
15
|
+
const CONTAINER_PADDING = KSpacingValue['0.75rem']; // 12px
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
items: DThreadResource[];
|
|
19
|
+
onEndReached: () => void;
|
|
20
|
+
isFetchingNextPage: boolean;
|
|
21
|
+
onPressItem: (index: number) => void;
|
|
22
|
+
onRefresh: () => void;
|
|
23
|
+
isRefetching: boolean;
|
|
24
|
+
emptyComponent: ReactElement;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const GridRenderer = memo(
|
|
28
|
+
({
|
|
29
|
+
items,
|
|
30
|
+
onEndReached,
|
|
31
|
+
isFetchingNextPage,
|
|
32
|
+
onPressItem,
|
|
33
|
+
onRefresh,
|
|
34
|
+
isRefetching,
|
|
35
|
+
emptyComponent,
|
|
36
|
+
}: Props) => {
|
|
37
|
+
// Reactive — do NOT use KDims.width (static snapshot, stale on rotation/resize).
|
|
38
|
+
const { width } = useWindowDimensions();
|
|
39
|
+
const cellSize = useMemo(() => {
|
|
40
|
+
const raw =
|
|
41
|
+
(width - CONTAINER_PADDING * 2 - GRID_GAP * (COLUMNS - 1)) / COLUMNS;
|
|
42
|
+
// Floor against a degenerate width (extremely narrow split-screen, or a
|
|
43
|
+
// not-yet-measured `0` on first paint) — a <= 0 `estimatedItemSize` would
|
|
44
|
+
// feed LegendList's internal layout math a divide-by-zero/negative size.
|
|
45
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 1;
|
|
46
|
+
}, [width]);
|
|
47
|
+
|
|
48
|
+
const renderItem = useCallback(
|
|
49
|
+
({ item, index }: { item: DThreadResource; index: number }) => (
|
|
50
|
+
<GridThumbnail
|
|
51
|
+
item={item}
|
|
52
|
+
index={index}
|
|
53
|
+
size={cellSize}
|
|
54
|
+
onPress={onPressItem}
|
|
55
|
+
/>
|
|
56
|
+
),
|
|
57
|
+
[cellSize, onPressItem]
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const keyExtractor = useCallback((item: DThreadResource) => item.id, []);
|
|
61
|
+
return (
|
|
62
|
+
<LegendList
|
|
63
|
+
data={items}
|
|
64
|
+
numColumns={COLUMNS}
|
|
65
|
+
estimatedItemSize={cellSize}
|
|
66
|
+
renderItem={renderItem}
|
|
67
|
+
keyExtractor={keyExtractor}
|
|
68
|
+
// `GridThumbnail` is stateless/presentational (derives everything from
|
|
69
|
+
// props, no internal `useState`) — safe to recycle cell instances
|
|
70
|
+
// instead of remounting on scroll. Without this, LegendList defaults to
|
|
71
|
+
// `false` and logs its own dev warning recommending exactly this.
|
|
72
|
+
recycleItems
|
|
73
|
+
contentContainerStyle={styles.content}
|
|
74
|
+
columnWrapperStyle={styles.row}
|
|
75
|
+
onEndReached={onEndReached}
|
|
76
|
+
onEndReachedThreshold={0.5}
|
|
77
|
+
onRefresh={onRefresh}
|
|
78
|
+
refreshing={isRefetching}
|
|
79
|
+
ListEmptyComponent={emptyComponent}
|
|
80
|
+
ListFooterComponent={
|
|
81
|
+
<KContainer.VisibleView visible={isFetchingNextPage}>
|
|
82
|
+
<ActivityIndicator
|
|
83
|
+
style={styles.footer}
|
|
84
|
+
color={KColors.palette.primary.w400}
|
|
85
|
+
/>
|
|
86
|
+
</KContainer.VisibleView>
|
|
87
|
+
}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const styles = StyleSheet.create({
|
|
94
|
+
content: {
|
|
95
|
+
padding: CONTAINER_PADDING,
|
|
96
|
+
gap: GRID_GAP,
|
|
97
|
+
},
|
|
98
|
+
row: {
|
|
99
|
+
gap: GRID_GAP,
|
|
100
|
+
},
|
|
101
|
+
footer: {
|
|
102
|
+
paddingVertical: KSpacingValue['1rem'],
|
|
103
|
+
},
|
|
104
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { memo, useCallback, useMemo, useState } from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import { KContainer, KImage, KLabel, KColors } from '@droppii/libs';
|
|
4
|
+
import { formatDuration } from '../../utils/dateTimeUtils';
|
|
5
|
+
import ChatImageDimens from '../../utils/chatImageDimens';
|
|
6
|
+
import { DThreadResourceContentType } from '../../types/chat';
|
|
7
|
+
import type { DThreadResource } from '../../types/chat';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
item: DThreadResource;
|
|
11
|
+
index: number;
|
|
12
|
+
size: number;
|
|
13
|
+
onPress: (index: number) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const GridThumbnail = memo(({ item, index, size, onPress }: Props) => {
|
|
17
|
+
const isVideo = item.contentType === DThreadResourceContentType.VIDEO;
|
|
18
|
+
const showDuration = isVideo && item.duration != null;
|
|
19
|
+
const durationLabel =
|
|
20
|
+
item.duration != null ? formatDuration(item.duration) : '';
|
|
21
|
+
|
|
22
|
+
// First mount of a cell has nothing painted yet — a bare `KImage.Base` on a
|
|
23
|
+
// white screen flashes as an empty gap until the (possibly not-yet-cached)
|
|
24
|
+
// thumbnail decodes. Same skeleton-over-gray-fallback pattern as
|
|
25
|
+
// `MergedImageGrid.tsx`'s chat-bubble grid.
|
|
26
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
27
|
+
const onLoadEnd = useCallback(() => setIsLoading(false), []);
|
|
28
|
+
|
|
29
|
+
// Never request the original full-resolution asset for a grid cell — run
|
|
30
|
+
// whichever URL we end up with (BE's own resized `thumbnailUrl`, or a
|
|
31
|
+
// fallback to full-res `sourceUrl` if BE didn't provide one) through the
|
|
32
|
+
// same CDN resize+webp pipeline `imageMessage.tsx` uses for chat bubbles.
|
|
33
|
+
// `size` is included in deps since it changes on rotation/window resize.
|
|
34
|
+
const thumbnailUri = useMemo(() => {
|
|
35
|
+
// `size` is a sub-pixel float (screen-width / 3 columns) — round it, since
|
|
36
|
+
// the resize dimensions baked into the URL should be whole pixels.
|
|
37
|
+
const roundedSize = Math.round(size);
|
|
38
|
+
return ChatImageDimens.getOptimizedImageUrlWithDimensions(
|
|
39
|
+
item.thumbnailUrl ?? item.sourceUrl,
|
|
40
|
+
roundedSize,
|
|
41
|
+
roundedSize
|
|
42
|
+
);
|
|
43
|
+
}, [item.thumbnailUrl, item.sourceUrl, size]);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<KContainer.Touchable
|
|
47
|
+
style={[styles.cell, { width: size, height: size }]}
|
|
48
|
+
onPress={() => onPress(index)}
|
|
49
|
+
accessibilityLabel={item.fileName || (isVideo ? 'Video' : 'Image')}
|
|
50
|
+
>
|
|
51
|
+
<KImage.Base
|
|
52
|
+
uri={thumbnailUri}
|
|
53
|
+
width={size}
|
|
54
|
+
height={size}
|
|
55
|
+
br="2x"
|
|
56
|
+
transition="fade"
|
|
57
|
+
onLoadEnd={onLoadEnd}
|
|
58
|
+
/>
|
|
59
|
+
<KContainer.VisibleView visible={isLoading}>
|
|
60
|
+
<KContainer.View
|
|
61
|
+
style={[styles.skeleton, { width: size, height: size }]}
|
|
62
|
+
/>
|
|
63
|
+
</KContainer.VisibleView>
|
|
64
|
+
<KContainer.VisibleView visible={showDuration}>
|
|
65
|
+
<KContainer.View style={styles.durationBadge}>
|
|
66
|
+
<KLabel.Text typo="Text2XsNormal" color={KColors.white}>
|
|
67
|
+
{durationLabel}
|
|
68
|
+
</KLabel.Text>
|
|
69
|
+
</KContainer.View>
|
|
70
|
+
</KContainer.VisibleView>
|
|
71
|
+
</KContainer.Touchable>
|
|
72
|
+
);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const styles = StyleSheet.create({
|
|
76
|
+
// Fallback background so a cell is never a stark blank/white gap while its
|
|
77
|
+
// thumbnail is still decoding.
|
|
78
|
+
cell: {
|
|
79
|
+
backgroundColor: KColors.palette.gray.w50,
|
|
80
|
+
overflow: 'hidden',
|
|
81
|
+
},
|
|
82
|
+
skeleton: {
|
|
83
|
+
position: 'absolute',
|
|
84
|
+
top: 0,
|
|
85
|
+
left: 0,
|
|
86
|
+
backgroundColor: KColors.palette.gray.w50,
|
|
87
|
+
borderRadius: 8,
|
|
88
|
+
},
|
|
89
|
+
// Figma: Video-Time — black pill, bottom-right overlay, cornerRadius 12 (KRadius '3x')
|
|
90
|
+
durationBadge: {
|
|
91
|
+
position: 'absolute',
|
|
92
|
+
right: 4,
|
|
93
|
+
bottom: 4,
|
|
94
|
+
borderRadius: 12,
|
|
95
|
+
paddingHorizontal: 4,
|
|
96
|
+
backgroundColor: 'rgba(0,0,0,1)',
|
|
97
|
+
},
|
|
98
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { memo } from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
KContainer,
|
|
5
|
+
KImage,
|
|
6
|
+
KLabel,
|
|
7
|
+
KColors,
|
|
8
|
+
KSpacingValue,
|
|
9
|
+
} from '@droppii/libs';
|
|
10
|
+
import type { DThreadResource } from '../../types/chat';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
item: DThreadResource;
|
|
14
|
+
onPressUrl?: (url: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const LinkResourceRow = memo(({ item, onPressUrl }: Props) => (
|
|
18
|
+
<KContainer.Touchable
|
|
19
|
+
style={styles.row}
|
|
20
|
+
br="3x"
|
|
21
|
+
background={KColors.opacity.grayLighter[20]}
|
|
22
|
+
onPress={() => item.linkUrl && onPressUrl?.(item.linkUrl)}
|
|
23
|
+
>
|
|
24
|
+
<KContainer.View style={styles.thumb}>
|
|
25
|
+
<KContainer.VisibleView visible={!!item.linkImageUrl}>
|
|
26
|
+
<KImage.Base
|
|
27
|
+
uri={item.linkImageUrl ?? ''}
|
|
28
|
+
width={76}
|
|
29
|
+
height={76}
|
|
30
|
+
br="2x"
|
|
31
|
+
resizeMode="cover"
|
|
32
|
+
/>
|
|
33
|
+
</KContainer.VisibleView>
|
|
34
|
+
{/* Figma "Link-No-Img" fallback: gray.w200 background, cornerRadius 12,
|
|
35
|
+
white generic link icon — NOT the same styling as the image variant.
|
|
36
|
+
`KContainer.VisibleView` only takes `visible`/`children` (no `style`
|
|
37
|
+
passthrough — confirmed in its source), so the fallback box needs its
|
|
38
|
+
own `KContainer.View` wrapper inside it. */}
|
|
39
|
+
<KContainer.VisibleView visible={!item.linkImageUrl}>
|
|
40
|
+
<KContainer.View style={styles.thumbFallback}>
|
|
41
|
+
<KImage.VectorIcons name="link-alt-o" color={KColors.white} />
|
|
42
|
+
</KContainer.View>
|
|
43
|
+
</KContainer.VisibleView>
|
|
44
|
+
</KContainer.View>
|
|
45
|
+
<KContainer.View flexS={1}>
|
|
46
|
+
<KContainer.VisibleView visible={!!item.linkDomain}>
|
|
47
|
+
<KLabel.Text
|
|
48
|
+
// ⚠️ U7: Figma domain-line color (#5C6366) has no exact @droppii/libs
|
|
49
|
+
// token match — using closest system token (gray.w500) pending Designer confirm.
|
|
50
|
+
typo="TextNmNormal"
|
|
51
|
+
color={KColors.palette.gray.w500}
|
|
52
|
+
numberOfLines={1}
|
|
53
|
+
>
|
|
54
|
+
{item.linkDomain}
|
|
55
|
+
</KLabel.Text>
|
|
56
|
+
</KContainer.VisibleView>
|
|
57
|
+
<KContainer.VisibleView visible={!!item.linkTitle}>
|
|
58
|
+
<KLabel.Text
|
|
59
|
+
typo="TextMdMedium"
|
|
60
|
+
color={KColors.palette.gray.w900}
|
|
61
|
+
numberOfLines={2}
|
|
62
|
+
>
|
|
63
|
+
{item.linkTitle}
|
|
64
|
+
</KLabel.Text>
|
|
65
|
+
</KContainer.VisibleView>
|
|
66
|
+
{/* U8 re-resolved via pixel check against `14208:88701`: the description
|
|
67
|
+
text layer exists with real content in every sample row, but is
|
|
68
|
+
`visible: false` in ALL of them — the shipped design never shows it,
|
|
69
|
+
even though BE provides `linkDescription`. Dropped per pixel-perfect
|
|
70
|
+
decision (not rendered), despite the field still being populated. */}
|
|
71
|
+
</KContainer.View>
|
|
72
|
+
</KContainer.Touchable>
|
|
73
|
+
));
|
|
74
|
+
|
|
75
|
+
const styles = StyleSheet.create({
|
|
76
|
+
row: {
|
|
77
|
+
flexDirection: 'row',
|
|
78
|
+
alignItems: 'center',
|
|
79
|
+
gap: KSpacingValue['0.5rem'],
|
|
80
|
+
padding: KSpacingValue['0.75rem'],
|
|
81
|
+
marginBottom: KSpacingValue['0.5rem'],
|
|
82
|
+
},
|
|
83
|
+
// Figma Link-Item: thumbnail slot is 76x76 (not 56x56).
|
|
84
|
+
thumb: {
|
|
85
|
+
width: 76,
|
|
86
|
+
height: 76,
|
|
87
|
+
},
|
|
88
|
+
thumbFallback: {
|
|
89
|
+
width: 76,
|
|
90
|
+
height: 76,
|
|
91
|
+
borderRadius: 12,
|
|
92
|
+
justifyContent: 'center',
|
|
93
|
+
alignItems: 'center',
|
|
94
|
+
backgroundColor: KColors.palette.gray.w200,
|
|
95
|
+
},
|
|
96
|
+
});
|