@droppii-org/chat-mobile 0.2.51 → 0.2.53
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/context/ChatDetailContext.js +2 -1
- package/lib/module/context/ChatDetailContext.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/flows/useFlow.js +10 -0
- package/lib/module/hooks/flows/useFlow.js.map +1 -1
- package/lib/module/hooks/query-keys.js +5 -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/screens/chat-detail/BotMenuSheet.js +73 -0
- package/lib/module/screens/chat-detail/BotMenuSheet.js.map +1 -0
- package/lib/module/screens/chat-detail/ChatComposer.js +78 -3
- package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
- package/lib/module/screens/chat-detail/components/ChatMessageInput.js +11 -6
- package/lib/module/screens/chat-detail/components/ChatMessageInput.js.map +1 -1
- package/lib/module/services/attachmentOrchestrator.js +19 -4
- package/lib/module/services/attachmentOrchestrator.js.map +1 -1
- package/lib/module/services/endpoints.js +1 -0
- package/lib/module/services/endpoints.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 +13 -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/context/ChatDetailContext.d.ts.map +1 -1
- package/lib/typescript/src/hooks/flows/useFlow.d.ts +1 -0
- package/lib/typescript/src/hooks/flows/useFlow.d.ts.map +1 -1
- package/lib/typescript/src/hooks/query-keys.d.ts +5 -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/screens/chat-detail/BotMenuSheet.d.ts +7 -0
- package/lib/typescript/src/screens/chat-detail/BotMenuSheet.d.ts.map +1 -0
- package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
- package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts +2 -1
- package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts.map +1 -1
- package/lib/typescript/src/services/attachmentOrchestrator.d.ts.map +1 -1
- package/lib/typescript/src/services/endpoints.d.ts +1 -0
- package/lib/typescript/src/services/endpoints.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 +60 -1
- 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/context/ChatDetailContext.tsx +1 -0
- package/src/core/notification/pushToken.ts +7 -7
- package/src/hooks/flows/useFlow.ts +14 -0
- package/src/hooks/query-keys.ts +10 -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/screens/chat-detail/BotMenuSheet.tsx +94 -0
- package/src/screens/chat-detail/ChatComposer.tsx +100 -3
- package/src/screens/chat-detail/components/ChatMessageInput.tsx +11 -7
- package/src/services/attachmentOrchestrator.ts +15 -2
- package/src/services/endpoints.ts +2 -0
- package/src/services/resourceCenter.ts +137 -0
- package/src/translation/resources/i18n.ts +13 -0
- package/src/types/attachment.ts +3 -0
- package/src/types/chat.ts +75 -0
- package/src/utils/url.ts +11 -0
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { memo, useCallback } from 'react';
|
|
2
|
+
import type { ReactElement } from 'react';
|
|
3
|
+
import { ActivityIndicator, StyleSheet } from 'react-native';
|
|
4
|
+
import { LegendList } from '@legendapp/list/react-native';
|
|
5
|
+
import { KContainer, KColors, KSpacingValue } from '@droppii/libs';
|
|
6
|
+
import { FileResourceRow } from './FileResourceRow';
|
|
7
|
+
import { LinkResourceRow } from './LinkResourceRow';
|
|
8
|
+
import { DThreadResourceContentType } from '../../types/chat';
|
|
9
|
+
import type { DThreadResource } from '../../types/chat';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
items: DThreadResource[];
|
|
13
|
+
contentType: DThreadResourceContentType;
|
|
14
|
+
onEndReached: () => void;
|
|
15
|
+
isFetchingNextPage: boolean;
|
|
16
|
+
onPressUrl?: (url: string) => void;
|
|
17
|
+
onRefresh: () => void;
|
|
18
|
+
isRefetching: boolean;
|
|
19
|
+
emptyComponent: ReactElement;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ROW_ESTIMATE = 80;
|
|
23
|
+
|
|
24
|
+
export const ListRenderer = memo(
|
|
25
|
+
({
|
|
26
|
+
items,
|
|
27
|
+
contentType,
|
|
28
|
+
onEndReached,
|
|
29
|
+
isFetchingNextPage,
|
|
30
|
+
onPressUrl,
|
|
31
|
+
onRefresh,
|
|
32
|
+
isRefetching,
|
|
33
|
+
emptyComponent,
|
|
34
|
+
}: Props) => {
|
|
35
|
+
const isLink = contentType === DThreadResourceContentType.URL_TEXT;
|
|
36
|
+
|
|
37
|
+
const renderItem = useCallback(
|
|
38
|
+
({ item }: { item: DThreadResource }) =>
|
|
39
|
+
isLink ? (
|
|
40
|
+
<LinkResourceRow item={item} onPressUrl={onPressUrl} />
|
|
41
|
+
) : (
|
|
42
|
+
<FileResourceRow item={item} />
|
|
43
|
+
),
|
|
44
|
+
[isLink, onPressUrl]
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const keyExtractor = useCallback((item: DThreadResource) => item.id, []);
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<LegendList
|
|
51
|
+
data={items}
|
|
52
|
+
renderItem={renderItem}
|
|
53
|
+
keyExtractor={keyExtractor}
|
|
54
|
+
estimatedItemSize={ROW_ESTIMATE}
|
|
55
|
+
contentContainerStyle={styles.content}
|
|
56
|
+
onEndReached={onEndReached}
|
|
57
|
+
onEndReachedThreshold={0.5}
|
|
58
|
+
onRefresh={onRefresh}
|
|
59
|
+
refreshing={isRefetching}
|
|
60
|
+
ListEmptyComponent={emptyComponent}
|
|
61
|
+
ListFooterComponent={
|
|
62
|
+
<KContainer.VisibleView visible={isFetchingNextPage}>
|
|
63
|
+
<ActivityIndicator
|
|
64
|
+
style={styles.footer}
|
|
65
|
+
color={KColors.palette.primary.w400}
|
|
66
|
+
/>
|
|
67
|
+
</KContainer.VisibleView>
|
|
68
|
+
}
|
|
69
|
+
/>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const styles = StyleSheet.create({
|
|
75
|
+
content: {
|
|
76
|
+
padding: KSpacingValue['0.75rem'],
|
|
77
|
+
},
|
|
78
|
+
footer: {
|
|
79
|
+
paddingVertical: KSpacingValue['1rem'],
|
|
80
|
+
},
|
|
81
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import {
|
|
4
|
+
TabBar,
|
|
5
|
+
type Route,
|
|
6
|
+
type TabBarProps,
|
|
7
|
+
type TabDescriptor,
|
|
8
|
+
} from 'react-native-tab-view';
|
|
9
|
+
import { KLabel, KColors, KSpacingValue } from '@droppii/libs';
|
|
10
|
+
|
|
11
|
+
// Stable module-level reference — must NOT be defined inside the component body
|
|
12
|
+
// (or inside the `useMemo` below), or React treats it as a new type every render.
|
|
13
|
+
const renderTabLabel: NonNullable<TabDescriptor<Route>['label']> = ({
|
|
14
|
+
labelText,
|
|
15
|
+
color,
|
|
16
|
+
}) => (
|
|
17
|
+
<KLabel.Text typo="TextNmBold" color={color}>
|
|
18
|
+
{labelText}
|
|
19
|
+
</KLabel.Text>
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
// Uses `react-native-tab-view`'s own `TabBar` (animated sliding indicator,
|
|
23
|
+
// built-in accessibility/ripple) instead of a hand-rolled tab row — only the
|
|
24
|
+
// design tokens are custom (colors/indicator/label typography), matching the
|
|
25
|
+
// Figma underline-tab spec via the library's supported customization surface
|
|
26
|
+
// (`activeColor`/`inactiveColor`/`indicatorStyle`/per-route `label`).
|
|
27
|
+
//
|
|
28
|
+
// NOT wrapped in `React.memo`: `TabView` calls `renderTabBar(props)` as a plain
|
|
29
|
+
// function (not `<Comp/>` JSX — see `TabView.js`: `renderTabBar({...})`), and a
|
|
30
|
+
// `memo()`-wrapped component is a React element object, not a callable function.
|
|
31
|
+
// Passing the memoized object here throws `renderTabBar is not a function`.
|
|
32
|
+
export function ResourceCenterTabBar<T extends Route>(props: TabBarProps<T>) {
|
|
33
|
+
const options = useMemo<Record<string, TabDescriptor<T>>>(
|
|
34
|
+
() =>
|
|
35
|
+
Object.fromEntries(
|
|
36
|
+
props.navigationState.routes.map((route) => [
|
|
37
|
+
route.key,
|
|
38
|
+
{
|
|
39
|
+
labelText: (route as Route & { title?: string }).title,
|
|
40
|
+
label: renderTabLabel,
|
|
41
|
+
} satisfies TabDescriptor<T>,
|
|
42
|
+
])
|
|
43
|
+
),
|
|
44
|
+
[props.navigationState.routes]
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<TabBar
|
|
49
|
+
{...props}
|
|
50
|
+
scrollEnabled
|
|
51
|
+
options={options}
|
|
52
|
+
activeColor={KColors.palette.primary.w400}
|
|
53
|
+
inactiveColor={KColors.palette.gray.w400}
|
|
54
|
+
pressColor="transparent"
|
|
55
|
+
style={styles.bar}
|
|
56
|
+
tabStyle={styles.tab}
|
|
57
|
+
indicatorStyle={styles.indicator}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const styles = StyleSheet.create({
|
|
63
|
+
// Figma `Tab-Bar` (14208:92918): solid white background + 1px bottom hairline
|
|
64
|
+
// (`gray.w25` #EFEFEF) — no shadow/elevation.
|
|
65
|
+
bar: {
|
|
66
|
+
backgroundColor: KColors.white,
|
|
67
|
+
borderBottomWidth: 1,
|
|
68
|
+
borderBottomColor: KColors.palette.gray.w25,
|
|
69
|
+
shadowOpacity: 0,
|
|
70
|
+
elevation: 0,
|
|
71
|
+
},
|
|
72
|
+
// `width: 'auto'` is REQUIRED alongside `scrollEnabled` above — without it,
|
|
73
|
+
// `TabBar`'s internal `getComputedTabWidth` falls back to `layout.width / 5 * 2`
|
|
74
|
+
// (40% of screen width per tab) before it has measured anything, rendering
|
|
75
|
+
// hugely oversized tabs. `'auto'` makes it use each tab's real measured label
|
|
76
|
+
// width instead (`tabWidths[route.key]`) — matches Figma's hug-content tabs
|
|
77
|
+
// (widths 53/65/73/81pt), fixed to each tab's own text, not stretched/guessed.
|
|
78
|
+
tab: {
|
|
79
|
+
width: 'auto',
|
|
80
|
+
paddingHorizontal: KSpacingValue['0.75rem'],
|
|
81
|
+
minHeight: 0,
|
|
82
|
+
},
|
|
83
|
+
// Figma: 3px bar, cornerRadius 4, primary blue.
|
|
84
|
+
indicator: {
|
|
85
|
+
backgroundColor: KColors.palette.primary.w400,
|
|
86
|
+
height: 3,
|
|
87
|
+
borderRadius: 4,
|
|
88
|
+
},
|
|
89
|
+
});
|