@droppii-org/chat-mobile 0.2.4 → 0.2.7

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 (212) hide show
  1. package/lib/module/components/ThreadCard/AvatarSection.js +4 -4
  2. package/lib/module/components/ThreadCard/AvatarSection.js.map +1 -1
  3. package/lib/module/components/ThreadCard/NamePrefixIcon.js +13 -16
  4. package/lib/module/components/ThreadCard/NamePrefixIcon.js.map +1 -1
  5. package/lib/module/components/ThreadCard/ThreadCard.js +13 -33
  6. package/lib/module/components/ThreadCard/ThreadCard.js.map +1 -1
  7. package/lib/module/config/feature-flags.js +38 -0
  8. package/lib/module/config/feature-flags.js.map +1 -0
  9. package/lib/module/context/ChatContext.js +7 -6
  10. package/lib/module/context/ChatContext.js.map +1 -1
  11. package/lib/module/hooks/message/useSendMessage.js +101 -0
  12. package/lib/module/hooks/message/useSendMessage.js.map +1 -0
  13. package/lib/module/hooks/query-keys.js +4 -0
  14. package/lib/module/hooks/query-keys.js.map +1 -1
  15. package/lib/module/hooks/useChatMessages.js +54 -91
  16. package/lib/module/hooks/useChatMessages.js.map +1 -1
  17. package/lib/module/hooks/useConversationList.js +29 -17
  18. package/lib/module/hooks/useConversationList.js.map +1 -1
  19. package/lib/module/hooks/useLinkPreview/useFetchUrlMetadata.js +17 -0
  20. package/lib/module/hooks/useLinkPreview/useFetchUrlMetadata.js.map +1 -0
  21. package/lib/module/hooks/useLinkPreview/useLinkPreview.js +35 -0
  22. package/lib/module/hooks/useLinkPreview/useLinkPreview.js.map +1 -0
  23. package/lib/module/index.js.map +1 -1
  24. package/lib/module/screens/chat-detail/ChatComposer.js +20 -4
  25. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  26. package/lib/module/screens/chat-detail/ChatDetail.js +116 -22
  27. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  28. package/lib/module/screens/chat-detail/ChatDetailHeader.js +5 -8
  29. package/lib/module/screens/chat-detail/ChatDetailHeader.js.map +1 -1
  30. package/lib/module/screens/chat-detail/ChatLinkPreview.js +79 -0
  31. package/lib/module/screens/chat-detail/ChatLinkPreview.js.map +1 -0
  32. package/lib/module/screens/chat-detail/ChatList.js +2 -0
  33. package/lib/module/screens/chat-detail/ChatList.js.map +1 -1
  34. package/lib/module/screens/chat-detail/ChatListLegend.js +350 -0
  35. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -0
  36. package/lib/module/screens/chat-detail/ChatQuickActions.js +12 -2
  37. package/lib/module/screens/chat-detail/ChatQuickActions.js.map +1 -1
  38. package/lib/module/screens/chat-detail/conversationHeader.utils.js +29 -0
  39. package/lib/module/screens/chat-detail/conversationHeader.utils.js.map +1 -0
  40. package/lib/module/screens/chat-detail/index.js +1 -0
  41. package/lib/module/screens/chat-detail/index.js.map +1 -1
  42. package/lib/module/screens/chat-detail/legend/LegendChatDay.js +57 -0
  43. package/lib/module/screens/chat-detail/legend/LegendChatDay.js.map +1 -0
  44. package/lib/module/screens/chat-detail/legend/LegendChatLoadEarlier.js +21 -0
  45. package/lib/module/screens/chat-detail/legend/LegendChatLoadEarlier.js.map +1 -0
  46. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +34 -0
  47. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -0
  48. package/lib/module/screens/chat-detail/legend/LegendChatScrollToBottom.js +58 -0
  49. package/lib/module/screens/chat-detail/legend/LegendChatScrollToBottom.js.map +1 -0
  50. package/lib/module/screens/chat-detail/legend/message-types.js +244 -0
  51. package/lib/module/screens/chat-detail/legend/message-types.js.map +1 -0
  52. package/lib/module/screens/chat-detail/messages/ChatMessageBubble.js.map +1 -1
  53. package/lib/module/services/apis.js +1 -1
  54. package/lib/module/services/apis.js.map +1 -1
  55. package/lib/module/services/endpoints.js +8 -0
  56. package/lib/module/services/endpoints.js.map +1 -0
  57. package/lib/module/store/conversation.js +1 -1
  58. package/lib/module/store/conversation.js.map +1 -1
  59. package/lib/module/store/message.js +45 -0
  60. package/lib/module/store/message.js.map +1 -0
  61. package/lib/module/translation/resources/i18n.js +7 -1
  62. package/lib/module/translation/resources/i18n.js.map +1 -1
  63. package/lib/module/types/chat.js +2 -7
  64. package/lib/module/types/chat.js.map +1 -1
  65. package/lib/module/types/common.js +2 -0
  66. package/lib/module/types/common.js.map +1 -0
  67. package/lib/module/utils/conversation.js +34 -13
  68. package/lib/module/utils/conversation.js.map +1 -1
  69. package/lib/module/utils/legendListMessage.js +77 -0
  70. package/lib/module/utils/legendListMessage.js.map +1 -0
  71. package/lib/module/utils/message.js +5 -8
  72. package/lib/module/utils/message.js.map +1 -1
  73. package/lib/module/utils/url.js +7 -0
  74. package/lib/module/utils/url.js.map +1 -0
  75. package/lib/typescript/src/components/Avatar/Avatar.d.ts +1 -1
  76. package/lib/typescript/src/components/Avatar/Avatar.d.ts.map +1 -1
  77. package/lib/typescript/src/components/Avatar/AvatarBadge.d.ts +1 -1
  78. package/lib/typescript/src/components/Avatar/AvatarBadge.d.ts.map +1 -1
  79. package/lib/typescript/src/components/Avatar/DoubleAvatar.d.ts +1 -1
  80. package/lib/typescript/src/components/Avatar/DoubleAvatar.d.ts.map +1 -1
  81. package/lib/typescript/src/components/Avatar/SingleAvatar.d.ts +1 -1
  82. package/lib/typescript/src/components/Avatar/SingleAvatar.d.ts.map +1 -1
  83. package/lib/typescript/src/components/ThreadCard/AvatarSection.d.ts +2 -2
  84. package/lib/typescript/src/components/ThreadCard/AvatarSection.d.ts.map +1 -1
  85. package/lib/typescript/src/components/ThreadCard/NamePrefixIcon.d.ts +3 -4
  86. package/lib/typescript/src/components/ThreadCard/NamePrefixIcon.d.ts.map +1 -1
  87. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts +1 -1
  88. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  89. package/lib/typescript/src/components/ThreadCard/UnreadBadge.d.ts +1 -1
  90. package/lib/typescript/src/components/ThreadCard/UnreadBadge.d.ts.map +1 -1
  91. package/lib/typescript/src/config/feature-flags.d.ts +12 -0
  92. package/lib/typescript/src/config/feature-flags.d.ts.map +1 -0
  93. package/lib/typescript/src/context/ChatContext.d.ts +1 -1
  94. package/lib/typescript/src/context/ChatContext.d.ts.map +1 -1
  95. package/lib/typescript/src/hooks/message/useSendMessage.d.ts +12 -0
  96. package/lib/typescript/src/hooks/message/useSendMessage.d.ts.map +1 -0
  97. package/lib/typescript/src/hooks/query-keys.d.ts +4 -0
  98. package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
  99. package/lib/typescript/src/hooks/useChatMessages.d.ts +3 -1
  100. package/lib/typescript/src/hooks/useChatMessages.d.ts.map +1 -1
  101. package/lib/typescript/src/hooks/useConversationList.d.ts +2 -1
  102. package/lib/typescript/src/hooks/useConversationList.d.ts.map +1 -1
  103. package/lib/typescript/src/hooks/useLinkPreview/useFetchUrlMetadata.d.ts +3 -0
  104. package/lib/typescript/src/hooks/useLinkPreview/useFetchUrlMetadata.d.ts.map +1 -0
  105. package/lib/typescript/src/hooks/useLinkPreview/useLinkPreview.d.ts +7 -0
  106. package/lib/typescript/src/hooks/useLinkPreview/useLinkPreview.d.ts.map +1 -0
  107. package/lib/typescript/src/index.d.ts +1 -1
  108. package/lib/typescript/src/index.d.ts.map +1 -1
  109. package/lib/typescript/src/screens/chat-detail/ChatAttachmentPanel.d.ts +1 -1
  110. package/lib/typescript/src/screens/chat-detail/ChatAttachmentPanel.d.ts.map +1 -1
  111. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts +1 -1
  112. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  113. package/lib/typescript/src/screens/chat-detail/ChatDay.d.ts +1 -1
  114. package/lib/typescript/src/screens/chat-detail/ChatDay.d.ts.map +1 -1
  115. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts +1 -1
  116. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  117. package/lib/typescript/src/screens/chat-detail/ChatDetailHeader.d.ts +1 -1
  118. package/lib/typescript/src/screens/chat-detail/ChatDetailHeader.d.ts.map +1 -1
  119. package/lib/typescript/src/screens/chat-detail/ChatLinkPreview.d.ts +9 -0
  120. package/lib/typescript/src/screens/chat-detail/ChatLinkPreview.d.ts.map +1 -0
  121. package/lib/typescript/src/screens/chat-detail/ChatList.d.ts +1 -1
  122. package/lib/typescript/src/screens/chat-detail/ChatList.d.ts.map +1 -1
  123. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +3 -0
  124. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -0
  125. package/lib/typescript/src/screens/chat-detail/ChatLoadEarlier.d.ts +1 -1
  126. package/lib/typescript/src/screens/chat-detail/ChatLoadEarlier.d.ts.map +1 -1
  127. package/lib/typescript/src/screens/chat-detail/ChatQuickActions.d.ts +1 -1
  128. package/lib/typescript/src/screens/chat-detail/ChatQuickActions.d.ts.map +1 -1
  129. package/lib/typescript/src/screens/chat-detail/ChatScrollToBottom.d.ts +1 -1
  130. package/lib/typescript/src/screens/chat-detail/ChatScrollToBottom.d.ts.map +1 -1
  131. package/lib/typescript/src/screens/chat-detail/ChatTextBubble.d.ts +1 -1
  132. package/lib/typescript/src/screens/chat-detail/ChatTextBubble.d.ts.map +1 -1
  133. package/lib/typescript/src/screens/chat-detail/conversationHeader.utils.d.ts +6 -0
  134. package/lib/typescript/src/screens/chat-detail/conversationHeader.utils.d.ts.map +1 -0
  135. package/lib/typescript/src/screens/chat-detail/index.d.ts +2 -1
  136. package/lib/typescript/src/screens/chat-detail/index.d.ts.map +1 -1
  137. package/lib/typescript/src/screens/chat-detail/legend/LegendChatDay.d.ts +6 -0
  138. package/lib/typescript/src/screens/chat-detail/legend/LegendChatDay.d.ts.map +1 -0
  139. package/lib/typescript/src/screens/chat-detail/legend/LegendChatLoadEarlier.d.ts +6 -0
  140. package/lib/typescript/src/screens/chat-detail/legend/LegendChatLoadEarlier.d.ts.map +1 -0
  141. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +13 -0
  142. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -0
  143. package/lib/typescript/src/screens/chat-detail/legend/LegendChatScrollToBottom.d.ts +6 -0
  144. package/lib/typescript/src/screens/chat-detail/legend/LegendChatScrollToBottom.d.ts.map +1 -0
  145. package/lib/typescript/src/screens/chat-detail/legend/message-types.d.ts +13 -0
  146. package/lib/typescript/src/screens/chat-detail/legend/message-types.d.ts.map +1 -0
  147. package/lib/typescript/src/screens/chat-detail/messages/ChatMessageBubble.d.ts +1 -1
  148. package/lib/typescript/src/screens/chat-detail/messages/ChatMessageBubble.d.ts.map +1 -1
  149. package/lib/typescript/src/screens/chat-detail/types.d.ts +33 -7
  150. package/lib/typescript/src/screens/chat-detail/types.d.ts.map +1 -1
  151. package/lib/typescript/src/screens/inbox/Inbox.d.ts +1 -1
  152. package/lib/typescript/src/screens/inbox/Inbox.d.ts.map +1 -1
  153. package/lib/typescript/src/screens/inbox/MessagesTab.d.ts +1 -1
  154. package/lib/typescript/src/screens/inbox/MessagesTab.d.ts.map +1 -1
  155. package/lib/typescript/src/services/apis.d.ts +1 -0
  156. package/lib/typescript/src/services/apis.d.ts.map +1 -1
  157. package/lib/typescript/src/services/endpoints.d.ts +6 -0
  158. package/lib/typescript/src/services/endpoints.d.ts.map +1 -0
  159. package/lib/typescript/src/store/message.d.ts +3 -0
  160. package/lib/typescript/src/store/message.d.ts.map +1 -0
  161. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  162. package/lib/typescript/src/types/chat.d.ts +28 -27
  163. package/lib/typescript/src/types/chat.d.ts.map +1 -1
  164. package/lib/typescript/src/types/common.d.ts +7 -0
  165. package/lib/typescript/src/types/common.d.ts.map +1 -0
  166. package/lib/typescript/src/utils/conversation.d.ts +3 -2
  167. package/lib/typescript/src/utils/conversation.d.ts.map +1 -1
  168. package/lib/typescript/src/utils/legendListMessage.d.ts +23 -0
  169. package/lib/typescript/src/utils/legendListMessage.d.ts.map +1 -0
  170. package/lib/typescript/src/utils/message.d.ts.map +1 -1
  171. package/lib/typescript/src/utils/url.d.ts +2 -0
  172. package/lib/typescript/src/utils/url.d.ts.map +1 -0
  173. package/package.json +5 -3
  174. package/src/components/ThreadCard/AvatarSection.tsx +5 -8
  175. package/src/components/ThreadCard/NamePrefixIcon.tsx +27 -38
  176. package/src/components/ThreadCard/ThreadCard.tsx +16 -30
  177. package/src/config/feature-flags.ts +49 -0
  178. package/src/context/ChatContext.tsx +12 -4
  179. package/src/hooks/message/useSendMessage.ts +136 -0
  180. package/src/hooks/query-keys.ts +5 -0
  181. package/src/hooks/useChatMessages.ts +90 -118
  182. package/src/hooks/useConversationList.ts +34 -16
  183. package/src/hooks/useLinkPreview/useFetchUrlMetadata.ts +18 -0
  184. package/src/hooks/useLinkPreview/useLinkPreview.ts +31 -0
  185. package/src/index.tsx +1 -0
  186. package/src/screens/chat-detail/ChatComposer.tsx +23 -2
  187. package/src/screens/chat-detail/ChatDetail.tsx +163 -30
  188. package/src/screens/chat-detail/ChatDetailHeader.tsx +4 -10
  189. package/src/screens/chat-detail/ChatLinkPreview.tsx +86 -0
  190. package/src/screens/chat-detail/ChatList.tsx +3 -0
  191. package/src/screens/chat-detail/ChatListLegend.tsx +403 -0
  192. package/src/screens/chat-detail/ChatQuickActions.tsx +19 -2
  193. package/src/screens/chat-detail/conversationHeader.utils.ts +49 -0
  194. package/src/screens/chat-detail/index.ts +7 -0
  195. package/src/screens/chat-detail/legend/LegendChatDay.tsx +70 -0
  196. package/src/screens/chat-detail/legend/LegendChatLoadEarlier.tsx +21 -0
  197. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +48 -0
  198. package/src/screens/chat-detail/legend/LegendChatScrollToBottom.tsx +56 -0
  199. package/src/screens/chat-detail/legend/message-types.tsx +304 -0
  200. package/src/screens/chat-detail/messages/ChatMessageBubble.tsx +0 -1
  201. package/src/screens/chat-detail/types.ts +45 -7
  202. package/src/services/apis.ts +1 -1
  203. package/src/services/endpoints.ts +5 -0
  204. package/src/store/conversation.ts +1 -1
  205. package/src/store/message.ts +44 -0
  206. package/src/translation/resources/i18n.ts +6 -0
  207. package/src/types/chat.ts +31 -30
  208. package/src/types/common.ts +6 -0
  209. package/src/utils/conversation.ts +44 -17
  210. package/src/utils/legendListMessage.ts +97 -0
  211. package/src/utils/message.ts +10 -12
  212. package/src/utils/url.ts +5 -0
@@ -0,0 +1,403 @@
1
+ import { memo, useCallback, useMemo, useEffect, useRef, useState } from 'react';
2
+ import { ActivityIndicator, StyleSheet } from 'react-native';
3
+ import Animated, {
4
+ useSharedValue,
5
+ useAnimatedStyle,
6
+ withSpring,
7
+ } from 'react-native-reanimated';
8
+ import { LegendList, type LegendListRef } from '@legendapp/list/react-native';
9
+ import { KContainer, KColors, KSpacingValue, KLabel } from '@droppii/libs';
10
+ import { precomputeLegendListData } from '../../utils/legendListMessage';
11
+ import type { ChatListProps } from './types';
12
+ import type { DMessageItem } from '../../types/chat';
13
+ import { LegendChatMessage } from './legend/LegendChatMessage';
14
+ import { LegendChatDay } from './legend/LegendChatDay';
15
+ import { LegendChatLoadEarlier } from './legend/LegendChatLoadEarlier';
16
+
17
+ export const ChatListLegend = memo(
18
+ ({
19
+ messages = [],
20
+ currentUserId = '',
21
+ renderChat,
22
+ onLoadEarlier,
23
+ isLoading,
24
+ isLoadingEarlier,
25
+ hasMoreEarlier,
26
+ onLoadNewer,
27
+ isLoadingNewer,
28
+ hasMoreNewer,
29
+ }: ChatListProps) => {
30
+ if (__DEV__) {
31
+ console.log('[LegendList] Props:', {
32
+ hasMoreEarlier,
33
+ isLoadingEarlier,
34
+ hasMoreNewer,
35
+ isLoadingNewer,
36
+ messagesCount: messages.length,
37
+ });
38
+ }
39
+
40
+ // Precompute all message data in single pass (O(n))
41
+ const messageDataMap = useMemo(
42
+ () => precomputeLegendListData(messages),
43
+ [messages]
44
+ );
45
+
46
+ const listRef = useRef<LegendListRef>(null);
47
+ const buttonOpacity = useSharedValue(0);
48
+ const isAtBottomRef = useRef(true);
49
+ const lastMessageCountRef = useRef(messages.length);
50
+ const lastFirstMessageIdRef = useRef<string | null>(null);
51
+ const lastLastMessageIdRef = useRef<string | null>(null);
52
+ const scrollDebounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
53
+ const itemHeightsRef = useRef<Map<string, number>>(new Map());
54
+ const avgItemHeightRef = useRef(120);
55
+ const heightDebounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
56
+ const [showBadge, setShowBadge] = useState(false);
57
+
58
+ const handleItemLayout = useCallback(
59
+ (messageId: string, height: number) => {
60
+ itemHeightsRef.current.set(messageId, height);
61
+
62
+ // Debounce average height calculation (batch updates)
63
+ if (heightDebounceRef.current) {
64
+ clearTimeout(heightDebounceRef.current);
65
+ }
66
+
67
+ heightDebounceRef.current = setTimeout(() => {
68
+ if (itemHeightsRef.current.size > 0) {
69
+ const total = Array.from(itemHeightsRef.current.values()).reduce(
70
+ (a, b) => a + b,
71
+ 0
72
+ );
73
+ avgItemHeightRef.current = Math.round(
74
+ total / itemHeightsRef.current.size
75
+ );
76
+ }
77
+ }, 100); // Batch updates every 100ms during load
78
+ },
79
+ []
80
+ );
81
+
82
+ const renderItem = useCallback(
83
+ ({ item, index }: any) => {
84
+ try {
85
+ if (!item) return null;
86
+
87
+ const message = item as DMessageItem;
88
+ const isOutgoing = message.sendID === currentUserId;
89
+ // Use same fallback as precomputeLegendListData
90
+ const messageId =
91
+ message.clientMsgID ||
92
+ message.serverMsgID ||
93
+ `temp-${message.sendTime || message.createTime}-${index}`;
94
+
95
+ const messageData = messageDataMap.get(messageId);
96
+ if (!messageData) {
97
+ return null; // Shouldn't happen if logic is consistent
98
+ }
99
+
100
+ const { dayStart, createdAt } = messageData;
101
+
102
+ return (
103
+ <>
104
+ {dayStart && <LegendChatDay createdAt={createdAt} />}
105
+ <KContainer.View
106
+ style={[
107
+ styles.messageRow,
108
+ isOutgoing ? styles.messageRowRight : styles.messageRowLeft,
109
+ ]}
110
+ onLayout={(e) =>
111
+ handleItemLayout(messageId, e.nativeEvent.layout.height)
112
+ }
113
+ >
114
+ {renderChat ? (
115
+ renderChat(message)
116
+ ) : (
117
+ <LegendChatMessage
118
+ message={message}
119
+ isOutgoing={isOutgoing}
120
+ createdAtTime={createdAt}
121
+ />
122
+ )}
123
+ </KContainer.View>
124
+ </>
125
+ );
126
+ } catch (error) {
127
+ if (__DEV__)
128
+ console.error('[ChatListLegend] renderItem error:', error);
129
+ return (
130
+ <KContainer.View style={styles.errorRow}>
131
+ <KLabel.Text typo="TextXsNormal">
132
+ Error rendering message
133
+ </KLabel.Text>
134
+ </KContainer.View>
135
+ );
136
+ }
137
+ },
138
+ [currentUserId, renderChat, messageDataMap, handleItemLayout]
139
+ );
140
+
141
+ const maintainScrollConfig = useMemo(
142
+ () => ({
143
+ animated: false,
144
+ on: {
145
+ dataChange: !isLoadingEarlier,
146
+ layout: !isLoadingEarlier,
147
+ itemLayout: true,
148
+ },
149
+ }),
150
+ [isLoadingEarlier]
151
+ );
152
+
153
+ const handleStartReached = useCallback(() => {
154
+ if (__DEV__) {
155
+ console.log('[LegendList] onStartReached triggered', {
156
+ hasMoreEarlier,
157
+ isLoadingEarlier,
158
+ willLoad: hasMoreEarlier && !isLoadingEarlier,
159
+ });
160
+ }
161
+ if (hasMoreEarlier && !isLoadingEarlier) {
162
+ onLoadEarlier?.();
163
+ }
164
+ }, [hasMoreEarlier, isLoadingEarlier, onLoadEarlier]);
165
+
166
+ const handleEndReached = useCallback(() => {
167
+ if (hasMoreNewer && !isLoadingNewer) {
168
+ onLoadNewer?.();
169
+ }
170
+ }, [hasMoreNewer, isLoadingNewer, onLoadNewer]);
171
+
172
+ const handleScroll = useCallback(
173
+ (e: any) => {
174
+ const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
175
+ const isAtBottom =
176
+ contentOffset.y + layoutMeasurement.height >=
177
+ contentSize.height - 100;
178
+
179
+ // Debounce animation updates
180
+ if (scrollDebounceRef.current) {
181
+ clearTimeout(scrollDebounceRef.current);
182
+ }
183
+
184
+ scrollDebounceRef.current = setTimeout(() => {
185
+ if (isAtBottomRef.current !== isAtBottom) {
186
+ isAtBottomRef.current = isAtBottom;
187
+ buttonOpacity.value = withSpring(isAtBottom ? 0 : 1, {
188
+ duration: 300,
189
+ });
190
+ }
191
+ }, 50);
192
+ },
193
+ [buttonOpacity]
194
+ );
195
+
196
+ const handleScrollToBottom = useCallback(() => {
197
+ listRef.current?.scrollToEnd({ animated: true });
198
+ buttonOpacity.value = withSpring(0, { duration: 300 });
199
+ isAtBottomRef.current = true;
200
+ setShowBadge(false);
201
+ }, [buttonOpacity]);
202
+
203
+ const animatedButtonStyle = useAnimatedStyle(() => ({
204
+ opacity: buttonOpacity.value,
205
+ pointerEvents: buttonOpacity.value === 0 ? 'none' : 'auto',
206
+ }));
207
+
208
+ // Auto-scroll to bottom when new messages arrive
209
+ useEffect(() => {
210
+ const currentCount = messages.length;
211
+ const previousCount = lastMessageCountRef.current;
212
+ let timeoutId: NodeJS.Timeout | undefined;
213
+
214
+ if (currentCount > previousCount) {
215
+ // Detect if messages were prepended (added to top) or appended (added to bottom)
216
+ const firstMsgId = messages[0]?.clientMsgID || messages[0]?.serverMsgID;
217
+ const lastMsgId =
218
+ messages[currentCount - 1]?.clientMsgID ||
219
+ messages[currentCount - 1]?.serverMsgID;
220
+ const prevFirstMsgId = lastFirstMessageIdRef.current;
221
+ const prevLastMsgId = lastLastMessageIdRef.current;
222
+
223
+ // Messages were prepended if last message ID stayed same but first changed
224
+ const wasPrepended =
225
+ prevLastMsgId === lastMsgId && firstMsgId !== prevFirstMsgId;
226
+
227
+ // Check if new message is from current user
228
+ const newMessage = messages[currentCount - 1];
229
+ const isOwnMessage = newMessage?.sendID === currentUserId;
230
+
231
+ // Auto-scroll conditions:
232
+ // 1. Own message: ALWAYS scroll to bottom (user needs to see what they sent)
233
+ // 2. Received message: Only scroll if already at bottom (don't interrupt reading history)
234
+ const shouldScroll =
235
+ !wasPrepended &&
236
+ !isLoadingEarlier &&
237
+ (isOwnMessage || isAtBottomRef.current);
238
+
239
+ if (shouldScroll) {
240
+ timeoutId = setTimeout(() => {
241
+ listRef.current?.scrollToEnd({ animated: true });
242
+ buttonOpacity.value = withSpring(0, { duration: 300 });
243
+ setShowBadge(false);
244
+ }, 100);
245
+ } else if (!wasPrepended && !isLoadingEarlier && !isOwnMessage) {
246
+ // Show badge for received messages while scrolled up
247
+ setShowBadge(true);
248
+ }
249
+ }
250
+
251
+ lastMessageCountRef.current = currentCount;
252
+ if (messages.length > 0) {
253
+ // Use same ID logic as precomputeLegendListData (with fallback)
254
+ lastFirstMessageIdRef.current =
255
+ messages[0]?.clientMsgID || messages[0]?.serverMsgID || null;
256
+ lastLastMessageIdRef.current =
257
+ messages[messages.length - 1]?.clientMsgID ||
258
+ messages[messages.length - 1]?.serverMsgID ||
259
+ null;
260
+ }
261
+
262
+ return () => {
263
+ if (timeoutId) clearTimeout(timeoutId);
264
+ };
265
+ }, [messages, isLoadingEarlier, buttonOpacity, currentUserId]);
266
+
267
+ // Cleanup debounce timeouts on unmount
268
+ useEffect(() => {
269
+ return () => {
270
+ if (scrollDebounceRef.current) {
271
+ clearTimeout(scrollDebounceRef.current);
272
+ }
273
+ if (heightDebounceRef.current) {
274
+ clearTimeout(heightDebounceRef.current);
275
+ }
276
+ };
277
+ }, []);
278
+
279
+ if (isLoading) {
280
+ return (
281
+ <KContainer.View flex center background={KColors.white}>
282
+ <ActivityIndicator
283
+ color={KColors.palette.primary.w400}
284
+ size="small"
285
+ />
286
+ </KContainer.View>
287
+ );
288
+ }
289
+
290
+ return (
291
+ <KContainer.View flex background={KColors.white} style={styles.container}>
292
+ <LegendList
293
+ ref={listRef}
294
+ data={messages}
295
+ renderItem={renderItem}
296
+ estimatedItemSize={avgItemHeightRef.current}
297
+ keyExtractor={(item) => String(item.clientMsgID || item.serverMsgID)}
298
+ recycleItems
299
+ alignItemsAtEnd
300
+ maintainVisibleContentPosition={!isLoadingEarlier}
301
+ maintainScrollAtEnd={maintainScrollConfig}
302
+ scrollEventThrottle={16}
303
+ onScroll={handleScroll}
304
+ contentContainerStyle={styles.content}
305
+ scrollIndicatorInsets={{ right: 1 }}
306
+ ListHeaderComponent={
307
+ <LegendChatLoadEarlier isLoading={isLoadingEarlier} />
308
+ }
309
+ onStartReached={handleStartReached}
310
+ onStartReachedThreshold={0.5}
311
+ onEndReached={handleEndReached}
312
+ onEndReachedThreshold={0.2}
313
+ showsVerticalScrollIndicator={false}
314
+ initialScrollAtEnd
315
+ />
316
+ <Animated.View
317
+ style={[styles.scrollToBottomButton, animatedButtonStyle]}
318
+ >
319
+ <KContainer.Touchable
320
+ onPress={handleScrollToBottom}
321
+ activeOpacity={0.7}
322
+ style={styles.buttonInner}
323
+ accessible
324
+ accessibilityLabel="Scroll to latest messages"
325
+ accessibilityRole="button"
326
+ >
327
+ <KLabel.Text
328
+ style={styles.scrollToBottomText}
329
+ accessibilityLabel=""
330
+ >
331
+
332
+ </KLabel.Text>
333
+ {showBadge && <KContainer.View style={styles.badge} />}
334
+ </KContainer.Touchable>
335
+ </Animated.View>
336
+ </KContainer.View>
337
+ );
338
+ }
339
+ );
340
+
341
+ ChatListLegend.displayName = 'ChatListLegend';
342
+
343
+ const styles = StyleSheet.create({
344
+ container: {
345
+ position: 'relative',
346
+ },
347
+ messageRow: {
348
+ width: '100%',
349
+ paddingHorizontal: KSpacingValue['0.75rem'],
350
+ marginBottom: KSpacingValue['0.25rem'],
351
+ },
352
+ messageRowLeft: {
353
+ alignItems: 'flex-start',
354
+ },
355
+ messageRowRight: {
356
+ alignItems: 'flex-end',
357
+ },
358
+ content: {
359
+ paddingVertical: KSpacingValue['0.75rem'],
360
+ flexGrow: 1,
361
+ },
362
+ scrollToBottomButton: {
363
+ position: 'absolute',
364
+ bottom: 20,
365
+ right: 16,
366
+ zIndex: 999,
367
+ },
368
+ buttonInner: {
369
+ position: 'relative',
370
+ width: 40,
371
+ height: 40,
372
+ borderRadius: 20,
373
+ backgroundColor: KColors.white,
374
+ justifyContent: 'center',
375
+ alignItems: 'center',
376
+ shadowColor: KColors.black,
377
+ shadowOffset: { width: 0, height: 1 },
378
+ shadowOpacity: 0.16,
379
+ shadowRadius: 8,
380
+ elevation: 8,
381
+ },
382
+ scrollToBottomText: {
383
+ fontSize: 20,
384
+ fontWeight: 'bold',
385
+ color: KColors.palette.primary.w400,
386
+ },
387
+ errorRow: {
388
+ padding: 16,
389
+ justifyContent: 'center',
390
+ alignItems: 'center',
391
+ },
392
+ badge: {
393
+ position: 'absolute',
394
+ top: -4,
395
+ right: -4,
396
+ width: 12,
397
+ height: 12,
398
+ borderRadius: 6,
399
+ backgroundColor: KColors.palette.primary.w400,
400
+ borderWidth: 2,
401
+ borderColor: KColors.white,
402
+ },
403
+ });
@@ -61,7 +61,13 @@ const QuickActionItem = memo(
61
61
  QuickActionItem.displayName = 'QuickActionItem';
62
62
 
63
63
  export const ChatQuickActions = memo(
64
- ({ actions, onActionPress, renderAction }: ChatQuickActionsProps) => {
64
+ ({
65
+ visible = true,
66
+ actions,
67
+ onActionPress,
68
+ renderAction,
69
+ renderQuickActions,
70
+ }: ChatQuickActionsProps) => {
65
71
  const handleActionPress = useChatActionPress(onActionPress);
66
72
 
67
73
  const renderItem = useCallback<ListRenderItem<DChatQuickAction>>(
@@ -79,10 +85,21 @@ export const ChatQuickActions = memo(
79
85
 
80
86
  const keyExtractor = useCallback((item: DChatQuickAction) => item.id, []);
81
87
 
82
- if (!actions?.length) {
88
+ if (!visible || !actions?.length) {
83
89
  return null;
84
90
  }
85
91
 
92
+ if (renderQuickActions) {
93
+ return (
94
+ <>
95
+ {renderQuickActions({
96
+ actions,
97
+ onActionPress: handleActionPress,
98
+ })}
99
+ </>
100
+ );
101
+ }
102
+
86
103
  return (
87
104
  <KContainer.View paddingT="0.75rem" paddingB="0.25rem">
88
105
  <FlatList
@@ -0,0 +1,49 @@
1
+ import { PeerType } from '@droppii/openim-rn-client-sdk';
2
+ import { type DConversationItem } from '../../types/chat';
3
+
4
+ export const getConversationTitle = (
5
+ conversation?: DConversationItem
6
+ ): string => {
7
+ if (!conversation) {
8
+ return 'Tin nhắn';
9
+ }
10
+
11
+ return conversation.showName ?? 'Tin nhắn';
12
+ };
13
+
14
+ export const getConversationAvatarUri = (
15
+ conversation?: DConversationItem
16
+ ): string | null => {
17
+ return conversation?.faceURL ?? null;
18
+ };
19
+
20
+ export const getConversationSubtitle = (
21
+ conversation?: DConversationItem
22
+ ): string | undefined => {
23
+ if (!conversation) {
24
+ return undefined;
25
+ }
26
+
27
+ if (conversation.peerType === PeerType.Group) {
28
+ const unreadLabel =
29
+ (conversation.unreadCount ?? 0) > 0
30
+ ? `${conversation.unreadCount} tin nhắn mới`
31
+ : 'Không có tin mới';
32
+ return `20 thành viên • ${unreadLabel}`;
33
+ }
34
+
35
+ if (
36
+ conversation.peerType === PeerType.Customer ||
37
+ conversation.applicationType === 'MALL'
38
+ ) {
39
+ return '338,5K Đã bán • 4,2K Theo dõi';
40
+ }
41
+
42
+ return 'Nhấn để xem thông tin';
43
+ };
44
+
45
+ export const shouldShowAddMember = (
46
+ conversation?: DConversationItem
47
+ ): boolean => {
48
+ return conversation?.peerType === PeerType.Group;
49
+ };
@@ -9,6 +9,12 @@ export {
9
9
  DEFAULT_ATTACHMENT_ACTIONS,
10
10
  DEFAULT_ATTACHMENT_PANEL_HEIGHT,
11
11
  } from './constants';
12
+ export {
13
+ getConversationTitle,
14
+ getConversationAvatarUri,
15
+ getConversationSubtitle,
16
+ shouldShowAddMember,
17
+ } from './conversationHeader.utils';
12
18
  export type {
13
19
  DChatActionItem,
14
20
  DChatActionIconProvider,
@@ -19,6 +25,7 @@ export type {
19
25
  ChatListProps,
20
26
  ChatComposerProps,
21
27
  ChatQuickActionsProps,
28
+ ChatQuickActionsRenderParams,
22
29
  ChatAttachmentPanelProps,
23
30
  } from './types';
24
31
  export type { DGiftedChatMessage } from '../../utils/giftedChatMessage';
@@ -0,0 +1,70 @@
1
+ import { memo, useMemo } from 'react';
2
+ import { KContainer, KLabel } from '@droppii/libs';
3
+ import { CHAT_BUBBLE_COLORS } from '../constants';
4
+
5
+ const formatTime = (date: Date) =>
6
+ date.toLocaleTimeString('vi-VN', {
7
+ hour: '2-digit',
8
+ minute: '2-digit',
9
+ hour12: false,
10
+ });
11
+
12
+ const formatDayLabel = (createdAt: number): string | null => {
13
+ const date = new Date(createdAt);
14
+ if (Number.isNaN(date.getTime())) return null;
15
+
16
+ const today = new Date();
17
+ today.setHours(0, 0, 0, 0);
18
+
19
+ const messageDay = new Date(date);
20
+ messageDay.setHours(0, 0, 0, 0);
21
+
22
+ if (messageDay.getTime() === today.getTime()) {
23
+ return `${formatTime(date)} Hôm nay`;
24
+ }
25
+
26
+ const yesterday = new Date(today);
27
+ yesterday.setDate(yesterday.getDate() - 1);
28
+
29
+ if (messageDay.getTime() === yesterday.getTime()) {
30
+ return `${formatTime(date)} Hôm qua`;
31
+ }
32
+
33
+ if (messageDay.getFullYear() === today.getFullYear()) {
34
+ return `${formatTime(date)} ${date.toLocaleDateString('vi-VN', {
35
+ day: 'numeric',
36
+ month: 'long',
37
+ })}`;
38
+ }
39
+
40
+ return `${formatTime(date)} ${date.toLocaleDateString('vi-VN', {
41
+ day: 'numeric',
42
+ month: 'long',
43
+ year: 'numeric',
44
+ })}`;
45
+ };
46
+
47
+ interface LegendChatDayProps {
48
+ createdAt: number;
49
+ }
50
+
51
+ export const LegendChatDay = memo(({ createdAt }: LegendChatDayProps) => {
52
+ const dateStr = useMemo(
53
+ () => (createdAt == null ? null : formatDayLabel(createdAt)),
54
+ [createdAt]
55
+ );
56
+
57
+ if (!dateStr) return null;
58
+
59
+ return (
60
+ <KContainer.View center marginV="0.5rem">
61
+ <KContainer.View>
62
+ <KLabel.Text typo="TextXsNormal" color={CHAT_BUBBLE_COLORS.dayLabel}>
63
+ {dateStr}
64
+ </KLabel.Text>
65
+ </KContainer.View>
66
+ </KContainer.View>
67
+ );
68
+ });
69
+
70
+ LegendChatDay.displayName = 'LegendChatDay';
@@ -0,0 +1,21 @@
1
+ import { memo } from 'react';
2
+ import { ActivityIndicator } from 'react-native';
3
+ import { KContainer, KColors } from '@droppii/libs';
4
+
5
+ interface LegendChatLoadEarlierProps {
6
+ isLoading?: boolean;
7
+ }
8
+
9
+ export const LegendChatLoadEarlier = memo(
10
+ ({ isLoading = false }: LegendChatLoadEarlierProps) => {
11
+ if (!isLoading) return null;
12
+
13
+ return (
14
+ <KContainer.View center marginV="0.75rem">
15
+ <ActivityIndicator color={KColors.palette.primary.w400} size="small" />
16
+ </KContainer.View>
17
+ );
18
+ }
19
+ );
20
+
21
+ LegendChatLoadEarlier.displayName = 'LegendChatLoadEarlier';
@@ -0,0 +1,48 @@
1
+ import { memo } from 'react';
2
+ import type { ComponentType } from 'react';
3
+ import { MessageType } from '@droppii/openim-rn-client-sdk';
4
+ import type { DMessageItem } from '../../../types/chat';
5
+ import {
6
+ LegendTextMessage,
7
+ LegendImageMessage,
8
+ LegendVideoMessage,
9
+ LegendFileMessage,
10
+ LegendLinkMessage,
11
+ } from './message-types';
12
+
13
+ interface LegendChatMessageProps {
14
+ message: DMessageItem;
15
+ isOutgoing: boolean;
16
+ createdAtTime: number;
17
+ }
18
+
19
+ const messageComponentMap: Partial<Record<MessageType, ComponentType<any>>> = {
20
+ [MessageType.TextMessage]: LegendTextMessage,
21
+ [MessageType.AtTextMessage]: LegendTextMessage,
22
+ [MessageType.QuoteMessage]: LegendTextMessage,
23
+ [MessageType.UrlTextMessage]: LegendLinkMessage,
24
+ [MessageType.PictureMessage]: LegendImageMessage,
25
+ [MessageType.VideoMessage]: LegendVideoMessage,
26
+ [MessageType.FileMessage]: LegendFileMessage,
27
+ };
28
+
29
+ /**
30
+ * Dispatcher component - renders the appropriate message component
31
+ * based on message type. Extensible for new message types.
32
+ */
33
+ export const LegendChatMessage = memo(
34
+ ({ message, isOutgoing, createdAtTime }: LegendChatMessageProps) => {
35
+ const MessageComponent =
36
+ messageComponentMap[message?.contentType] ?? LegendTextMessage;
37
+
38
+ return (
39
+ <MessageComponent
40
+ message={message}
41
+ isOutgoing={isOutgoing}
42
+ createdAtTime={createdAtTime}
43
+ />
44
+ );
45
+ }
46
+ );
47
+
48
+ LegendChatMessage.displayName = 'LegendChatMessage';
@@ -0,0 +1,56 @@
1
+ import { memo, useCallback } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import { KContainer, KColors, KLabel } from '@droppii/libs';
4
+
5
+ interface LegendChatScrollToBottomProps {
6
+ listRef: React.RefObject<any>;
7
+ }
8
+
9
+ export const LegendChatScrollToBottom = memo(
10
+ ({ listRef }: LegendChatScrollToBottomProps) => {
11
+ const handlePress = useCallback(() => {
12
+ listRef.current?.scrollToEnd({ animated: true });
13
+ }, [listRef]);
14
+
15
+ return (
16
+ <KContainer.Touchable
17
+ onPress={handlePress}
18
+ style={styles.scrollToBottom}
19
+ activeOpacity={0.7}
20
+ >
21
+ <KContainer.View style={styles.scrollToBottomContent}>
22
+ <KLabel.Text style={styles.arrowDown}>↓</KLabel.Text>
23
+ </KContainer.View>
24
+ </KContainer.Touchable>
25
+ );
26
+ }
27
+ );
28
+
29
+ LegendChatScrollToBottom.displayName = 'LegendChatScrollToBottom';
30
+
31
+ const styles = StyleSheet.create({
32
+ scrollToBottom: {
33
+ position: 'absolute',
34
+ bottom: 20,
35
+ right: 16,
36
+ zIndex: 999,
37
+ },
38
+ scrollToBottomContent: {
39
+ backgroundColor: KColors.white,
40
+ width: 32,
41
+ height: 32,
42
+ borderRadius: 16,
43
+ shadowColor: KColors.black,
44
+ shadowOffset: { width: 0, height: 1 },
45
+ shadowOpacity: 0.16,
46
+ shadowRadius: 8,
47
+ elevation: 8,
48
+ justifyContent: 'center',
49
+ alignItems: 'center',
50
+ },
51
+ arrowDown: {
52
+ fontSize: 16,
53
+ fontWeight: 'bold',
54
+ color: KColors.palette.primary.w400,
55
+ },
56
+ });