@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.
Files changed (162) hide show
  1. package/lib/module/assets/images/index.js +2 -1
  2. package/lib/module/assets/images/index.js.map +1 -1
  3. package/lib/module/assets/images/resource-center-empty-box.png +0 -0
  4. package/lib/module/components/MediaViewerModal.js +2 -1
  5. package/lib/module/components/MediaViewerModal.js.map +1 -1
  6. package/lib/module/components/MergedImageGrid.js +1 -0
  7. package/lib/module/components/MergedImageGrid.js.map +1 -1
  8. package/lib/module/config/api-endpoints.js +2 -1
  9. package/lib/module/config/api-endpoints.js.map +1 -1
  10. package/lib/module/context/ChatDetailContext.js +2 -1
  11. package/lib/module/context/ChatDetailContext.js.map +1 -1
  12. package/lib/module/core/notification/pushToken.js +7 -7
  13. package/lib/module/core/notification/pushToken.js.map +1 -1
  14. package/lib/module/hooks/flows/useFlow.js +10 -0
  15. package/lib/module/hooks/flows/useFlow.js.map +1 -1
  16. package/lib/module/hooks/query-keys.js +5 -0
  17. package/lib/module/hooks/query-keys.js.map +1 -1
  18. package/lib/module/hooks/resourceCenter/useThreadResources.js +116 -0
  19. package/lib/module/hooks/resourceCenter/useThreadResources.js.map +1 -0
  20. package/lib/module/hooks/useImageAttachment.js +0 -2
  21. package/lib/module/hooks/useImageAttachment.js.map +1 -1
  22. package/lib/module/hooks/useMediaViewer.js +4 -1
  23. package/lib/module/hooks/useMediaViewer.js.map +1 -1
  24. package/lib/module/hooks/useSendAttachment.js +4 -1
  25. package/lib/module/hooks/useSendAttachment.js.map +1 -1
  26. package/lib/module/index.js +1 -0
  27. package/lib/module/index.js.map +1 -1
  28. package/lib/module/screens/MediaView/index.js +104 -42
  29. package/lib/module/screens/MediaView/index.js.map +1 -1
  30. package/lib/module/screens/ResourceCenter/FileResourceRow.js +80 -0
  31. package/lib/module/screens/ResourceCenter/FileResourceRow.js.map +1 -0
  32. package/lib/module/screens/ResourceCenter/GridRenderer.js +83 -0
  33. package/lib/module/screens/ResourceCenter/GridRenderer.js.map +1 -0
  34. package/lib/module/screens/ResourceCenter/GridThumbnail.js +97 -0
  35. package/lib/module/screens/ResourceCenter/GridThumbnail.js.map +1 -0
  36. package/lib/module/screens/ResourceCenter/LinkResourceRow.js +81 -0
  37. package/lib/module/screens/ResourceCenter/LinkResourceRow.js.map +1 -0
  38. package/lib/module/screens/ResourceCenter/ListRenderer.js +60 -0
  39. package/lib/module/screens/ResourceCenter/ListRenderer.js.map +1 -0
  40. package/lib/module/screens/ResourceCenter/ResourceCenterTabBar.js +75 -0
  41. package/lib/module/screens/ResourceCenter/ResourceCenterTabBar.js.map +1 -0
  42. package/lib/module/screens/ResourceCenter/ResourceCenterTabScene.js +122 -0
  43. package/lib/module/screens/ResourceCenter/ResourceCenterTabScene.js.map +1 -0
  44. package/lib/module/screens/ResourceCenter/ResourceEmptyState.js +36 -0
  45. package/lib/module/screens/ResourceCenter/ResourceEmptyState.js.map +1 -0
  46. package/lib/module/screens/ResourceCenter/index.js +104 -0
  47. package/lib/module/screens/ResourceCenter/index.js.map +1 -0
  48. package/lib/module/screens/ResourceCenter/types.js +25 -0
  49. package/lib/module/screens/ResourceCenter/types.js.map +1 -0
  50. package/lib/module/screens/chat-detail/BotMenuSheet.js +73 -0
  51. package/lib/module/screens/chat-detail/BotMenuSheet.js.map +1 -0
  52. package/lib/module/screens/chat-detail/ChatComposer.js +78 -3
  53. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  54. package/lib/module/screens/chat-detail/components/ChatMessageInput.js +11 -6
  55. package/lib/module/screens/chat-detail/components/ChatMessageInput.js.map +1 -1
  56. package/lib/module/services/attachmentOrchestrator.js +19 -4
  57. package/lib/module/services/attachmentOrchestrator.js.map +1 -1
  58. package/lib/module/services/endpoints.js +1 -0
  59. package/lib/module/services/endpoints.js.map +1 -1
  60. package/lib/module/services/resourceCenter.js +102 -0
  61. package/lib/module/services/resourceCenter.js.map +1 -0
  62. package/lib/module/translation/resources/i18n.js +13 -1
  63. package/lib/module/translation/resources/i18n.js.map +1 -1
  64. package/lib/module/types/attachment.js +2 -0
  65. package/lib/module/types/chat.js +20 -0
  66. package/lib/module/types/chat.js.map +1 -1
  67. package/lib/module/utils/url.js +11 -0
  68. package/lib/module/utils/url.js.map +1 -1
  69. package/lib/typescript/src/assets/images/index.d.ts +1 -0
  70. package/lib/typescript/src/assets/images/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/MediaViewerModal.d.ts +3 -1
  72. package/lib/typescript/src/components/MediaViewerModal.d.ts.map +1 -1
  73. package/lib/typescript/src/components/MergedImageGrid.d.ts.map +1 -1
  74. package/lib/typescript/src/config/api-endpoints.d.ts +1 -0
  75. package/lib/typescript/src/config/api-endpoints.d.ts.map +1 -1
  76. package/lib/typescript/src/context/ChatDetailContext.d.ts.map +1 -1
  77. package/lib/typescript/src/hooks/flows/useFlow.d.ts +1 -0
  78. package/lib/typescript/src/hooks/flows/useFlow.d.ts.map +1 -1
  79. package/lib/typescript/src/hooks/query-keys.d.ts +5 -0
  80. package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
  81. package/lib/typescript/src/hooks/resourceCenter/useThreadResources.d.ts +21 -0
  82. package/lib/typescript/src/hooks/resourceCenter/useThreadResources.d.ts.map +1 -0
  83. package/lib/typescript/src/hooks/useImageAttachment.d.ts.map +1 -1
  84. package/lib/typescript/src/hooks/useMediaViewer.d.ts +5 -1
  85. package/lib/typescript/src/hooks/useMediaViewer.d.ts.map +1 -1
  86. package/lib/typescript/src/hooks/useSendAttachment.d.ts.map +1 -1
  87. package/lib/typescript/src/index.d.ts +2 -0
  88. package/lib/typescript/src/index.d.ts.map +1 -1
  89. package/lib/typescript/src/screens/MediaView/index.d.ts.map +1 -1
  90. package/lib/typescript/src/screens/ResourceCenter/FileResourceRow.d.ts +7 -0
  91. package/lib/typescript/src/screens/ResourceCenter/FileResourceRow.d.ts.map +1 -0
  92. package/lib/typescript/src/screens/ResourceCenter/GridRenderer.d.ts +14 -0
  93. package/lib/typescript/src/screens/ResourceCenter/GridRenderer.d.ts.map +1 -0
  94. package/lib/typescript/src/screens/ResourceCenter/GridThumbnail.d.ts +10 -0
  95. package/lib/typescript/src/screens/ResourceCenter/GridThumbnail.d.ts.map +1 -0
  96. package/lib/typescript/src/screens/ResourceCenter/LinkResourceRow.d.ts +8 -0
  97. package/lib/typescript/src/screens/ResourceCenter/LinkResourceRow.d.ts.map +1 -0
  98. package/lib/typescript/src/screens/ResourceCenter/ListRenderer.d.ts +16 -0
  99. package/lib/typescript/src/screens/ResourceCenter/ListRenderer.d.ts.map +1 -0
  100. package/lib/typescript/src/screens/ResourceCenter/ResourceCenterTabBar.d.ts +3 -0
  101. package/lib/typescript/src/screens/ResourceCenter/ResourceCenterTabBar.d.ts.map +1 -0
  102. package/lib/typescript/src/screens/ResourceCenter/ResourceCenterTabScene.d.ts +16 -0
  103. package/lib/typescript/src/screens/ResourceCenter/ResourceCenterTabScene.d.ts.map +1 -0
  104. package/lib/typescript/src/screens/ResourceCenter/ResourceEmptyState.d.ts +6 -0
  105. package/lib/typescript/src/screens/ResourceCenter/ResourceEmptyState.d.ts.map +1 -0
  106. package/lib/typescript/src/screens/ResourceCenter/index.d.ts +4 -0
  107. package/lib/typescript/src/screens/ResourceCenter/index.d.ts.map +1 -0
  108. package/lib/typescript/src/screens/ResourceCenter/types.d.ts +28 -0
  109. package/lib/typescript/src/screens/ResourceCenter/types.d.ts.map +1 -0
  110. package/lib/typescript/src/screens/chat-detail/BotMenuSheet.d.ts +7 -0
  111. package/lib/typescript/src/screens/chat-detail/BotMenuSheet.d.ts.map +1 -0
  112. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  113. package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts +2 -1
  114. package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts.map +1 -1
  115. package/lib/typescript/src/services/attachmentOrchestrator.d.ts.map +1 -1
  116. package/lib/typescript/src/services/endpoints.d.ts +1 -0
  117. package/lib/typescript/src/services/endpoints.d.ts.map +1 -1
  118. package/lib/typescript/src/services/resourceCenter.d.ts +9 -0
  119. package/lib/typescript/src/services/resourceCenter.d.ts.map +1 -0
  120. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  121. package/lib/typescript/src/types/attachment.d.ts +2 -0
  122. package/lib/typescript/src/types/attachment.d.ts.map +1 -1
  123. package/lib/typescript/src/types/chat.d.ts +60 -1
  124. package/lib/typescript/src/types/chat.d.ts.map +1 -1
  125. package/lib/typescript/src/utils/url.d.ts +7 -0
  126. package/lib/typescript/src/utils/url.d.ts.map +1 -1
  127. package/package.json +1 -1
  128. package/src/assets/images/index.ts +1 -0
  129. package/src/assets/images/resource-center-empty-box.png +0 -0
  130. package/src/components/MediaViewerModal.tsx +8 -3
  131. package/src/components/MergedImageGrid.tsx +1 -0
  132. package/src/config/api-endpoints.ts +2 -0
  133. package/src/context/ChatDetailContext.tsx +1 -0
  134. package/src/core/notification/pushToken.ts +7 -7
  135. package/src/hooks/flows/useFlow.ts +14 -0
  136. package/src/hooks/query-keys.ts +10 -0
  137. package/src/hooks/resourceCenter/useThreadResources.ts +124 -0
  138. package/src/hooks/useImageAttachment.ts +1 -2
  139. package/src/hooks/useMediaViewer.ts +21 -7
  140. package/src/hooks/useSendAttachment.ts +4 -1
  141. package/src/index.tsx +2 -0
  142. package/src/screens/MediaView/index.tsx +117 -54
  143. package/src/screens/ResourceCenter/FileResourceRow.tsx +99 -0
  144. package/src/screens/ResourceCenter/GridRenderer.tsx +104 -0
  145. package/src/screens/ResourceCenter/GridThumbnail.tsx +98 -0
  146. package/src/screens/ResourceCenter/LinkResourceRow.tsx +96 -0
  147. package/src/screens/ResourceCenter/ListRenderer.tsx +81 -0
  148. package/src/screens/ResourceCenter/ResourceCenterTabBar.tsx +89 -0
  149. package/src/screens/ResourceCenter/ResourceCenterTabScene.tsx +142 -0
  150. package/src/screens/ResourceCenter/ResourceEmptyState.tsx +39 -0
  151. package/src/screens/ResourceCenter/index.tsx +129 -0
  152. package/src/screens/ResourceCenter/types.ts +56 -0
  153. package/src/screens/chat-detail/BotMenuSheet.tsx +94 -0
  154. package/src/screens/chat-detail/ChatComposer.tsx +100 -3
  155. package/src/screens/chat-detail/components/ChatMessageInput.tsx +11 -7
  156. package/src/services/attachmentOrchestrator.ts +15 -2
  157. package/src/services/endpoints.ts +2 -0
  158. package/src/services/resourceCenter.ts +137 -0
  159. package/src/translation/resources/i18n.ts +13 -0
  160. package/src/types/attachment.ts +3 -0
  161. package/src/types/chat.ts +75 -0
  162. package/src/utils/url.ts +11 -0
@@ -4,7 +4,11 @@ import MediaViewerScreen from '../screens/MediaView';
4
4
  import { useMediaViewer, type MediaItem } from '../hooks/useMediaViewer';
5
5
 
6
6
  export interface MediaViewerModalHandle {
7
- show: (items: MediaItem[], index?: number) => void;
7
+ show: (
8
+ items: MediaItem[],
9
+ index?: number,
10
+ options?: { pagination?: boolean }
11
+ ) => void;
8
12
  }
9
13
 
10
14
  interface MediaViewerModalProps {
@@ -15,7 +19,8 @@ export const MediaViewerModal = forwardRef<
15
19
  MediaViewerModalHandle,
16
20
  MediaViewerModalProps
17
21
  >(({ onDismiss }, ref) => {
18
- const { visible, mediaList, initIndex, show, hide } = useMediaViewer();
22
+ const { visible, mediaList, initIndex, pagination, show, hide } =
23
+ useMediaViewer();
19
24
 
20
25
  // Expose show method to parent via ref
21
26
  useImperativeHandle(
@@ -53,7 +58,7 @@ export const MediaViewerModal = forwardRef<
53
58
  <MediaViewerScreen
54
59
  images={images}
55
60
  initIndex={initIndex}
56
- pagination={images.length > 1}
61
+ pagination={pagination && images.length > 1}
57
62
  onDismiss={handleDismiss}
58
63
  download
59
64
  />
@@ -190,6 +190,7 @@ export const MergedImageGrid = memo(
190
190
  width={cellSize}
191
191
  height={cellHeight}
192
192
  style={styles.image}
193
+ transition="fade"
193
194
  onLoadEnd={() => handleImageLoadEnd(index)}
194
195
  />
195
196
 
@@ -20,6 +20,7 @@ export interface ApiEndpoints {
20
20
  stickerCategories: string;
21
21
  stickersByCategory: string;
22
22
  stickerHistory: string;
23
+ messagesResources: string;
23
24
  }
24
25
 
25
26
  const DEFAULT_ENDPOINTS: ApiEndpoints = {
@@ -31,6 +32,7 @@ const DEFAULT_ENDPOINTS: ApiEndpoints = {
31
32
  stickerCategories: '/chat-service/v1/app/sticker-categories',
32
33
  stickersByCategory: '/chat-service/v1/app/sticker-categories',
33
34
  stickerHistory: '/chat-service/v1/app/stickers/history',
35
+ messagesResources: '/chat-service/v1/app/messages/resources',
34
36
  };
35
37
 
36
38
  let endpoints: ApiEndpoints = { ...DEFAULT_ENDPOINTS };
@@ -136,6 +136,7 @@ export const ChatDetailProvider = ({ children }: ChatDetailProviderProps) => {
136
136
  conversation,
137
137
  quotedMessage,
138
138
  setQuotedMessage,
139
+ conversationFlowMainMenu,
139
140
  }}
140
141
  >
141
142
  {children}
@@ -16,13 +16,13 @@ const uploadFcmToken = async (
16
16
  project: DChatProject,
17
17
  expireTime: number
18
18
  ): Promise<void> => {
19
- console.log('[chat-mobile] uploadFcmToken', {
20
- platformID,
21
- fcmToken,
22
- account: userID,
23
- project,
24
- expireTime,
25
- });
19
+ // console.log('[chat-mobile] uploadFcmToken', {
20
+ // platformID,
21
+ // fcmToken,
22
+ // account: userID,
23
+ // project,
24
+ // expireTime,
25
+ // });
26
26
  const token = useUserStore.getState().token;
27
27
  if (!token) {
28
28
  console.warn(
@@ -48,6 +48,20 @@ export const useResetFlow = () => {
48
48
  });
49
49
  };
50
50
 
51
+ export const useCloseSupportSession = () => {
52
+ const conversationId =
53
+ useConversationStore((state) => state.currentConversationId) || '';
54
+ return useMutation({
55
+ mutationKey: conversationQueryKeys.closeSession(conversationId),
56
+ mutationFn: async () => {
57
+ const res = await apiInstance?.post<BaseResponse<unknown>>(
58
+ ENDPOINTS.chatService.closeConversationSession(conversationId)
59
+ );
60
+ return res?.data?.data;
61
+ },
62
+ });
63
+ };
64
+
51
65
  export const useRunFlow = () => {
52
66
  const conversationId =
53
67
  useConversationStore((state) => state.currentConversationId) || '';
@@ -24,6 +24,10 @@ export const conversationQueryKeys = {
24
24
  conversationId,
25
25
  ],
26
26
  runFlow: (conversationId: string) => ['runConversationFlow', conversationId],
27
+ closeSession: (conversationId: string) => [
28
+ 'closeConversationSession',
29
+ conversationId,
30
+ ],
27
31
  detailByBotId: (botId: string) => ['conversationByBotId', botId],
28
32
  detailBySourceId: (sourceId: string) => ['detailBySourceId', sourceId],
29
33
  joinedGroup: (groupId?: string) => [...conversationQueryKeys.all, groupId],
@@ -44,3 +48,9 @@ export const stickerQueryKeys = {
44
48
  [...stickerQueryKeys.all, 'category', categoryId] as const,
45
49
  recent: () => [...stickerQueryKeys.all, 'recent'] as const,
46
50
  };
51
+
52
+ export const resourceCenterQueryKeys = {
53
+ all: ['threadResources'] as const,
54
+ byType: (recvID: string, contentType: number) =>
55
+ [...resourceCenterQueryKeys.all, recvID, contentType] as const,
56
+ };
@@ -0,0 +1,124 @@
1
+ import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
2
+ import { useCallback, useMemo, useState } from 'react';
3
+ import { resourceCenterQueryKeys } from '../query-keys';
4
+ import { useChatContext } from '../../context';
5
+ import { ResourceCenterService } from '../../services/resourceCenter';
6
+ import type {
7
+ DThreadResource,
8
+ DThreadResourceContentType,
9
+ } from '../../types/chat';
10
+
11
+ const PAGE_SIZE = 15;
12
+
13
+ /**
14
+ * U5 resolved against a real staging response: revoked (recalled) messages ARE
15
+ * still returned by `/messages/resources` with `isRevoked: true` — BE does NOT
16
+ * pre-filter them. This client-side check is load-bearing, not defense-in-depth.
17
+ * `deleted`/`hidden` flags weren't observed in the tested rows — if BE adds them
18
+ * later, extend this predicate.
19
+ */
20
+ const isVisibleResource = (item: DThreadResource): boolean => !item.isRevoked;
21
+
22
+ export const useThreadResources = (
23
+ recvID: string,
24
+ contentType: DThreadResourceContentType,
25
+ chatType: 'SINGLE' | 'GROUP'
26
+ ) => {
27
+ const { applicationType } = useChatContext();
28
+ const queryClient = useQueryClient();
29
+ const queryKey = resourceCenterQueryKeys.byType(recvID, contentType);
30
+
31
+ const {
32
+ data,
33
+ isLoading,
34
+ isError,
35
+ hasNextPage,
36
+ fetchNextPage,
37
+ isFetchingNextPage,
38
+ refetch,
39
+ } = useInfiniteQuery({
40
+ queryKey,
41
+ // `initialPageParam` is a react-query v5-only option — this library's
42
+ // `@tanstack/react-query` peer dep is unpinned (`"*"`), so a host app on v4
43
+ // (confirmed: biz-mobile pins 4.40.1) silently ignores this option and the
44
+ // first fetch's `pageParam` comes back `undefined`, not `1`. The `?? 1`
45
+ // fallback below is what actually makes page 1 work under v4 — do not remove
46
+ // it even though it looks redundant next to `initialPageParam`.
47
+ initialPageParam: 1,
48
+ queryFn: ({ pageParam }) =>
49
+ ResourceCenterService.fetchThreadResources({
50
+ recvID,
51
+ contentType,
52
+ chatType,
53
+ page: pageParam ?? 1,
54
+ pageSize: PAGE_SIZE,
55
+ applicationType,
56
+ }),
57
+ getNextPageParam: (lastPage) =>
58
+ lastPage.hasMore ? lastPage.page + 1 : undefined,
59
+ enabled: !!recvID && !!chatType,
60
+ });
61
+
62
+ // De-dupe by `id` across pages — offset pagination (`page`/`pageSize`) can hand
63
+ // back the same item twice if new messages land in the thread between page
64
+ // fetches (classic offset-pagination drift), which LegendList's `keyExtractor`
65
+ // requires be unique or it throws ("overlapping key" / missing items & gaps).
66
+ const items = useMemo(() => {
67
+ const seen = new Set<string>();
68
+ const deduped: DThreadResource[] = [];
69
+ for (const page of data?.pages ?? []) {
70
+ for (const item of page.items) {
71
+ if (seen.has(item.id)) continue;
72
+ seen.add(item.id);
73
+ if (isVisibleResource(item)) deduped.push(item);
74
+ }
75
+ }
76
+ return deduped;
77
+ }, [data]);
78
+
79
+ // Pull-to-refresh should (a) only re-fetch page 1, not every already-loaded
80
+ // page, and (b) keep the currently-shown items visible while that fetch is in
81
+ // flight — no flash to empty/loading. Neither `refetch()` nor `resetQueries()`
82
+ // give us both:
83
+ // - `refetch()` on an infinite query re-fetches ALL loaded pages (wasteful).
84
+ // - `resetQueries()` clears cached data FIRST, then fetches — causes exactly
85
+ // the empty-state flash we don't want.
86
+ // - `refetch({ refetchPage })` (page-1-only) is a react-query v4-only option,
87
+ // removed in v5 — another cross-version footgun like `initialPageParam`.
88
+ // So: fetch page 1 directly via the service, then splice it into the cache
89
+ // with `setQueryData` — `data`/`items` never go empty in between, and exactly
90
+ // one request is made regardless of how many pages were already loaded.
91
+ const [isRefreshingFirstPage, setIsRefreshingFirstPage] = useState(false);
92
+
93
+ const refreshFirstPage = useCallback(async () => {
94
+ setIsRefreshingFirstPage(true);
95
+ try {
96
+ const firstPage = await ResourceCenterService.fetchThreadResources({
97
+ recvID,
98
+ contentType,
99
+ chatType,
100
+ page: 1,
101
+ pageSize: PAGE_SIZE,
102
+ applicationType,
103
+ });
104
+ queryClient.setQueryData(queryKey, {
105
+ pages: [firstPage],
106
+ pageParams: [1],
107
+ });
108
+ } finally {
109
+ setIsRefreshingFirstPage(false);
110
+ }
111
+ }, [queryClient, queryKey, recvID, contentType, chatType, applicationType]);
112
+
113
+ return {
114
+ items,
115
+ isLoading,
116
+ isError,
117
+ hasMore: !!hasNextPage,
118
+ loadMore: fetchNextPage,
119
+ isFetchingNextPage,
120
+ isRefetching: isRefreshingFirstPage,
121
+ refetch,
122
+ refreshFirstPage,
123
+ };
124
+ };
@@ -171,8 +171,7 @@ export const useImageAttachment = (): UseImageAttachmentReturn => {
171
171
  const maxSize = isVideoFromPicker(item)
172
172
  ? UPLOAD_LIMITS.video
173
173
  : UPLOAD_LIMITS.image;
174
- console.log('maxSize', maxSize);
175
- console.log('item.size', item.size);
174
+
176
175
  return (item.size || 0) <= maxSize;
177
176
  };
178
177
 
@@ -5,11 +5,20 @@ export interface MediaItem {
5
5
  isVideo?: boolean;
6
6
  }
7
7
 
8
+ export interface ShowMediaViewerOptions {
9
+ pagination?: boolean;
10
+ }
11
+
8
12
  export interface UseMediaViewerReturn {
9
13
  visible: boolean;
10
14
  mediaList: MediaItem[];
11
15
  initIndex: number;
12
- show: (items: MediaItem[], index?: number) => void;
16
+ pagination: boolean;
17
+ show: (
18
+ items: MediaItem[],
19
+ index?: number,
20
+ options?: ShowMediaViewerOptions
21
+ ) => void;
13
22
  hide: () => void;
14
23
  }
15
24
 
@@ -17,16 +26,21 @@ export function useMediaViewer(): UseMediaViewerReturn {
17
26
  const [visible, setVisible] = useState(false);
18
27
  const [mediaList, setMediaList] = useState<MediaItem[]>([]);
19
28
  const [initIndex, setInitIndex] = useState(0);
29
+ const [pagination, setPagination] = useState(true);
20
30
 
21
- const show = useCallback((items: MediaItem[], index = 0) => {
22
- setMediaList(items);
23
- setInitIndex(index);
24
- setVisible(true);
25
- }, []);
31
+ const show = useCallback(
32
+ (items: MediaItem[], index = 0, options?: ShowMediaViewerOptions) => {
33
+ setMediaList(items);
34
+ setInitIndex(index);
35
+ setPagination(options?.pagination ?? true);
36
+ setVisible(true);
37
+ },
38
+ []
39
+ );
26
40
 
27
41
  const hide = useCallback(() => {
28
42
  setVisible(false);
29
43
  }, []);
30
44
 
31
- return { visible, mediaList, initIndex, show, hide };
45
+ return { visible, mediaList, initIndex, pagination, show, hide };
32
46
  }
@@ -2,6 +2,7 @@ import { useCallback, useState } from 'react';
2
2
  import { AttachmentOrchestratorService } from '../services/attachmentOrchestrator';
3
3
  import type { Attachment, AttachmentSendOptions } from '../types/attachment';
4
4
  import { getMimeType } from '../utils/imageUtils';
5
+ import { useChatContext } from '../context';
5
6
 
6
7
  export interface ImageItem {
7
8
  type: 'image';
@@ -70,6 +71,7 @@ export interface UseSendAttachmentReturn {
70
71
  export function useSendAttachment(): UseSendAttachmentReturn {
71
72
  const [isLoading, setIsLoading] = useState(false);
72
73
  const [error, setError] = useState<string | null>(null);
74
+ const applicationType = useChatContext().applicationType;
73
75
 
74
76
  const send = useCallback(
75
77
  async (
@@ -242,6 +244,7 @@ export function useSendAttachment(): UseSendAttachmentReturn {
242
244
  uploadedUrls,
243
245
  {
244
246
  strategy: 'individual',
247
+ applicationType,
245
248
  ...options,
246
249
  }
247
250
  );
@@ -278,7 +281,7 @@ export function useSendAttachment(): UseSendAttachmentReturn {
278
281
  console.log('[useSendAttachment] Finished');
279
282
  }
280
283
  },
281
- []
284
+ [applicationType]
282
285
  );
283
286
 
284
287
  const clearError = useCallback(() => setError(null), []);
package/src/index.tsx CHANGED
@@ -28,6 +28,8 @@ export { AttachmentPreview } from './components/AttachmentPreview';
28
28
  export { MediaViewerModal } from './components/MediaViewerModal';
29
29
  export { Inbox } from './screens/inbox';
30
30
  export { default as MediaViewerScreen } from './screens/MediaView';
31
+ export { default as ResourceCenterScreen } from './screens/ResourceCenter';
32
+ export type { ResourceCenterScreenProps } from './screens/ResourceCenter/types';
31
33
  export * from './screens/chat-detail';
32
34
 
33
35
  export type {
@@ -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 listRef = useRef<FlatList>(null);
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
- const onDownload = useCallback(() => {}, []);
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 }: ListRenderItemInfo<any>) => (
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
- if (pagination === false) return null;
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 style={styles.footer}>
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
- <Pagination
144
- dotContainerStyle={styles.dotContainerStyle}
145
- dotStyle={styles.dot}
146
- inactiveDotStyle={styles.inactiveDot}
147
- inactiveDotScale={0.6}
148
- dotsLength={images.length > 1 ? images.length : 3}
149
- activeDotIndex={images.length > 1 ? index : 1}
150
- inactiveDotOpacity={images.length > 1 ? 0.5 : 0}
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
- <KImage.VectorIcons
193
- name="download-o"
194
- size={24}
195
- color={KColors.white}
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
- <FlatList
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
- // position: 'absolute',
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,