@droppii-org/chat-mobile 0.2.44 → 0.2.46

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 (48) hide show
  1. package/lib/module/core/index.js +2 -0
  2. package/lib/module/core/index.js.map +1 -1
  3. package/lib/module/core/useChatListener.js +7 -2
  4. package/lib/module/core/useChatListener.js.map +1 -1
  5. package/lib/module/core/useNetworkStatusSync.js +14 -0
  6. package/lib/module/core/useNetworkStatusSync.js.map +1 -0
  7. package/lib/module/hooks/message/usePinMessage.js +12 -8
  8. package/lib/module/hooks/message/usePinMessage.js.map +1 -1
  9. package/lib/module/screens/chat-detail/ChatComposer.js +87 -69
  10. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  11. package/lib/module/screens/chat-detail/ChatDetail.js +39 -34
  12. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  13. package/lib/module/screens/chat-detail/ChatListLegend.js +16 -8
  14. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  15. package/lib/module/screens/chat-detail/hooks/useChatComposerAnimation.js +7 -65
  16. package/lib/module/screens/chat-detail/hooks/useChatComposerAnimation.js.map +1 -1
  17. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +6 -1
  18. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  19. package/lib/module/store/pinnedMessage.js +10 -5
  20. package/lib/module/store/pinnedMessage.js.map +1 -1
  21. package/lib/module/translation/resources/i18n.js +1 -1
  22. package/lib/module/translation/resources/i18n.js.map +1 -1
  23. package/lib/typescript/src/core/index.d.ts.map +1 -1
  24. package/lib/typescript/src/core/useChatListener.d.ts.map +1 -1
  25. package/lib/typescript/src/core/useNetworkStatusSync.d.ts +2 -0
  26. package/lib/typescript/src/core/useNetworkStatusSync.d.ts.map +1 -0
  27. package/lib/typescript/src/hooks/message/usePinMessage.d.ts.map +1 -1
  28. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts +5 -1
  29. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  30. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  31. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +15 -1
  32. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  33. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerAnimation.d.ts +2 -3
  34. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerAnimation.d.ts.map +1 -1
  35. package/lib/typescript/src/store/pinnedMessage.d.ts +2 -1
  36. package/lib/typescript/src/store/pinnedMessage.d.ts.map +1 -1
  37. package/package.json +4 -2
  38. package/src/core/index.ts +2 -0
  39. package/src/core/useChatListener.ts +14 -1
  40. package/src/core/useNetworkStatusSync.ts +13 -0
  41. package/src/hooks/message/usePinMessage.ts +16 -8
  42. package/src/screens/chat-detail/ChatComposer.tsx +132 -99
  43. package/src/screens/chat-detail/ChatDetail.tsx +41 -37
  44. package/src/screens/chat-detail/ChatListLegend.tsx +36 -7
  45. package/src/screens/chat-detail/hooks/useChatComposerAnimation.ts +11 -106
  46. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +6 -1
  47. package/src/store/pinnedMessage.ts +20 -6
  48. package/src/translation/resources/i18n.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@droppii-org/chat-mobile",
3
- "version": "0.2.44",
3
+ "version": "0.2.46",
4
4
  "description": "Droppii chat mobile",
5
5
  "keywords": [
6
6
  "react-native",
@@ -85,6 +85,7 @@
85
85
  "@growthbook/growthbook-react": "*",
86
86
  "@jest/globals": "^30.0.0",
87
87
  "@react-native-clipboard/clipboard": "^1.16.3",
88
+ "@react-native-community/netinfo": "11.4.1",
88
89
  "@react-native-documents/picker": "^12.0.1",
89
90
  "@react-native/babel-preset": "0.85.0",
90
91
  "@react-native/eslint-config": "0.85.0",
@@ -117,7 +118,7 @@
117
118
  "react-native-file-viewer": "^2.1.5",
118
119
  "react-native-gesture-handler": "^3.0.0",
119
120
  "react-native-image-crop-picker": "^0.51.1",
120
- "react-native-keyboard-controller": "1.19.0",
121
+ "react-native-keyboard-controller": "^1.21.9",
121
122
  "react-native-linear-gradient": "^2.8.3",
122
123
  "react-native-mmkv": "4.0.0",
123
124
  "react-native-permissions": "^5.6.0",
@@ -139,6 +140,7 @@
139
140
  "peerDependencies": {
140
141
  "@droppii/libs": "*",
141
142
  "@react-native-clipboard/clipboard": "*",
143
+ "@react-native-community/netinfo": "*",
142
144
  "@react-native-documents/picker": "*",
143
145
  "@tanstack/react-query": "*",
144
146
  "react": "*",
package/src/core/index.ts CHANGED
@@ -4,6 +4,7 @@ import { initBaseUrl } from '../config/configuration';
4
4
  import { useUserListener } from './useUserListener';
5
5
  import { useChatListener } from './useChatListener';
6
6
  import { useBackgroundStatusSync } from './notification/useBackgroundStatusSync';
7
+ import { useNetworkStatusSync } from './useNetworkStatusSync';
7
8
 
8
9
  export * from './notification';
9
10
 
@@ -35,4 +36,5 @@ export const useChatSdk = () => {
35
36
  useUserListener();
36
37
  useChatListener();
37
38
  useBackgroundStatusSync();
39
+ useNetworkStatusSync();
38
40
  };
@@ -70,8 +70,8 @@ export const useChatListener = () => {
70
70
  },
71
71
  });
72
72
  }
73
- useConversationStore.getState().updateConversations(conversations);
74
73
  }
74
+ useConversationStore.getState().updateConversations(conversations);
75
75
  },
76
76
  [currentUserId, isShowFLashMessage, onPressFlashMessage]
77
77
  );
@@ -134,6 +134,10 @@ export const useChatListener = () => {
134
134
  relevant.forEach((msg) => useMessageStore.getState().pushNewMessage(msg));
135
135
  }, []);
136
136
 
137
+ const onRecvOfflineNewMessages = useCallback((messages: MessageItem[]) => {
138
+ console.log('[Listener] onRecvOfflineNewMessages', messages);
139
+ }, []);
140
+
137
141
  useEffect(() => {
138
142
  // Conversation
139
143
  OpenIMSDK.on(OpenIMEvent.OnConversationChanged, onConversationChanged);
@@ -148,6 +152,10 @@ export const useChatListener = () => {
148
152
  OpenIMSDK.on(OpenIMEvent.OnNewRecvMessageRevoked, onMessageRevoked);
149
153
  OpenIMSDK.on(OpenIMEvent.OnRecvMessagePinned, onMessagePinned);
150
154
  OpenIMSDK.on(OpenIMEvent.OnRecvNewMessages, onRecvNewMessages);
155
+ OpenIMSDK.on(
156
+ OpenIMEvent.OnRecvOfflineNewMessages,
157
+ onRecvOfflineNewMessages
158
+ );
151
159
 
152
160
  return () => {
153
161
  // Conversation
@@ -163,6 +171,10 @@ export const useChatListener = () => {
163
171
  OpenIMSDK.off(OpenIMEvent.OnRecvNewMessages, onRecvNewMessages);
164
172
  OpenIMSDK.off(OpenIMEvent.OnNewRecvMessageRevoked, onMessageRevoked);
165
173
  OpenIMSDK.off(OpenIMEvent.OnRecvMessagePinned, onMessagePinned);
174
+ OpenIMSDK.off(
175
+ OpenIMEvent.OnRecvOfflineNewMessages,
176
+ onRecvOfflineNewMessages
177
+ );
166
178
  };
167
179
  }, [
168
180
  onConversationChanged,
@@ -173,5 +185,6 @@ export const useChatListener = () => {
173
185
  onNewConversation,
174
186
  onSendMessageProgress,
175
187
  onRecvNewMessages,
188
+ onRecvOfflineNewMessages,
176
189
  ]);
177
190
  };
@@ -0,0 +1,13 @@
1
+ import { useEffect } from 'react';
2
+ import NetInfo from '@react-native-community/netinfo';
3
+ import OpenIMSDK from '@droppii/openim-rn-client-sdk';
4
+
5
+ export const useNetworkStatusSync = () => {
6
+ useEffect(() => {
7
+ const unsubscribe = NetInfo.addEventListener(() => {
8
+ OpenIMSDK.networkStatusChanged();
9
+ });
10
+
11
+ return unsubscribe;
12
+ }, []);
13
+ };
@@ -8,13 +8,13 @@ import { useUserStore } from '../../store/user';
8
8
  import { UIUtils } from '../../utils/UIUtils';
9
9
  import { trans } from '../../translation';
10
10
 
11
- const MAX_PINNED_MESSAGES = 5;
11
+ const PINNED_MESSAGES_PAGE_SIZE = 5;
12
12
 
13
13
  export const fetchPinnedMessages = async (conversationID: string) => {
14
14
  const result = await OpenIMSDK.getPinnedMessageList({
15
15
  conversationID,
16
16
  offset: 0,
17
- pageSize: MAX_PINNED_MESSAGES,
17
+ pageSize: PINNED_MESSAGES_PAGE_SIZE,
18
18
  });
19
19
 
20
20
  const previouslyPinnedIds = new Set(
@@ -24,7 +24,11 @@ export const fetchPinnedMessages = async (conversationID: string) => {
24
24
 
25
25
  usePinnedMessageStore
26
26
  .getState()
27
- .setMessages(result.messageList, result.totalCount);
27
+ .setMessages(
28
+ result.messageList,
29
+ result.totalCount,
30
+ result.remainingPinCount
31
+ );
28
32
 
29
33
  // Reconcile the per-message `isPinned` flag (used by the toolbox and the
30
34
  // pinned badge) with the authoritative list from the server — messages
@@ -41,10 +45,10 @@ export const fetchPinnedMessages = async (conversationID: string) => {
41
45
  });
42
46
  };
43
47
 
44
- const showPinLimitReachedModal = () => {
48
+ const showPinLimitReachedModal = (maxCount: number) => {
45
49
  UIUtils.alert.open({
46
50
  title: trans('chat:pin_limit_title'),
47
- message: trans('chat:pin_limit_desc'),
51
+ message: trans('chat:pin_limit_desc', { maxCount }),
48
52
  primaryIndex: 0,
49
53
  buttons: [
50
54
  {
@@ -58,6 +62,10 @@ const showPinLimitReachedModal = () => {
58
62
 
59
63
  export const usePinMessage = (conversationID?: string) => {
60
64
  const pinnedMessages = usePinnedMessageStore((state) => state.messages);
65
+ const totalCount = usePinnedMessageStore((state) => state.totalCount);
66
+ const remainingPinCount = usePinnedMessageStore(
67
+ (state) => state.remainingPinCount
68
+ );
61
69
  const addMessage = usePinnedMessageStore((state) => state.addMessage);
62
70
  const removeMessage = usePinnedMessageStore((state) => state.removeMessage);
63
71
  const reset = usePinnedMessageStore((state) => state.reset);
@@ -78,8 +86,8 @@ export const usePinMessage = (conversationID?: string) => {
78
86
  async (message: MessageItem) => {
79
87
  if (!conversationID) return;
80
88
 
81
- if (pinnedMessages.length >= MAX_PINNED_MESSAGES) {
82
- showPinLimitReachedModal();
89
+ if (remainingPinCount <= 0) {
90
+ showPinLimitReachedModal(totalCount + remainingPinCount);
83
91
  return;
84
92
  }
85
93
 
@@ -104,7 +112,7 @@ export const usePinMessage = (conversationID?: string) => {
104
112
  });
105
113
  }
106
114
  },
107
- [conversationID, pinnedMessages.length, addMessage]
115
+ [conversationID, remainingPinCount, totalCount, addMessage]
108
116
  );
109
117
 
110
118
  const unpinMessage = useCallback(
@@ -1,7 +1,21 @@
1
- import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
2
- import { Platform, StyleSheet } from 'react-native';
1
+ import {
2
+ memo,
3
+ useCallback,
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ type ReactNode,
8
+ type RefObject,
9
+ } from 'react';
10
+ import {
11
+ Platform,
12
+ StyleSheet,
13
+ type LayoutChangeEvent,
14
+ type View,
15
+ } from 'react-native';
3
16
  import { debounce } from 'lodash';
4
17
  import Reanimated from 'react-native-reanimated';
18
+ import { KeyboardStickyView } from 'react-native-keyboard-controller';
5
19
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
20
  import { KContainer, KColors, KImage, KButton, KLabel } from '@droppii/libs';
7
21
 
@@ -38,6 +52,8 @@ interface ChatComposerInternalProps {
38
52
  isEmptyMessage?: boolean;
39
53
  pannelActions?: DChatPannelAction[];
40
54
  isDisableCompose?: boolean;
55
+ composerRef?: RefObject<View | null>;
56
+ onComposerLayout?: (event: LayoutChangeEvent) => void;
41
57
  }
42
58
 
43
59
  export const ChatComposer = memo(
@@ -45,6 +61,8 @@ export const ChatComposer = memo(
45
61
  isEmptyMessage = false,
46
62
  pannelActions,
47
63
  isDisableCompose = false,
64
+ composerRef,
65
+ onComposerLayout,
48
66
  }: ChatComposerInternalProps) => {
49
67
  const insets = useSafeAreaInsets();
50
68
 
@@ -70,7 +88,6 @@ export const ChatComposer = memo(
70
88
 
71
89
  const { accessorySlotStyle, toggleAttachmentVisibility, closeAttachment } =
72
90
  useChatComposerAnimation({
73
- isAttachmentOpen,
74
91
  panelHeight,
75
92
  onAttachmentOpenChange: setIsAttachmentOpen,
76
93
  });
@@ -86,7 +103,7 @@ export const ChatComposer = memo(
86
103
 
87
104
  const handleInputFocus = useCallback(() => {
88
105
  setIsInputFocused(true);
89
- if (isAttachmentOpen) closeAttachment();
106
+ if (isAttachmentOpen) closeAttachment(true);
90
107
  }, [closeAttachment, isAttachmentOpen, setIsInputFocused]);
91
108
 
92
109
  const handleInputBlur = useCallback(() => {
@@ -177,8 +194,14 @@ export const ChatComposer = memo(
177
194
  [compose]
178
195
  );
179
196
 
197
+ let content: ReactNode;
198
+ const isNewBotConversation =
199
+ isBotConversation &&
200
+ conversationFlow?.flowCurrentStep?.type === ReactFlowsNodeTypes.Start &&
201
+ isEmptyMessage;
202
+
180
203
  if (isDisableCompose) {
181
- return (
204
+ content = (
182
205
  <KContainer.View row alignItems justifyContent>
183
206
  <KContainer.View marginR="0.25rem">
184
207
  <KImage.VectorIcons
@@ -192,13 +215,8 @@ export const ChatComposer = memo(
192
215
  </KLabel.Text>
193
216
  </KContainer.View>
194
217
  );
195
- }
196
- if (
197
- isBotConversation &&
198
- conversationFlow?.flowCurrentStep?.type === ReactFlowsNodeTypes.Start &&
199
- isEmptyMessage
200
- ) {
201
- return (
218
+ } else if (isNewBotConversation) {
219
+ content = (
202
220
  <KContainer.View padding={'1.5rem'} paddingB={0}>
203
221
  <KButton.Solid
204
222
  size="md"
@@ -210,102 +228,117 @@ export const ChatComposer = memo(
210
228
  />
211
229
  </KContainer.View>
212
230
  );
213
- }
214
-
215
- return (
216
- <KContainer.View background={'rgba(131, 137, 157, 0.05)'}>
217
- <ChatCurrentQuotedMessage />
218
- <ChatQuickActions
219
- visible
220
- actions={DEFAULT_QUICK_ACTIONS}
221
- onActionPress={(action) =>
222
- console.log('[ChatComposer] quick action:', action.id)
223
- }
224
- />
225
-
226
- {compose.showLinkPreview && (
227
- <ChatLinkPreview
228
- metadata={compose.urlMetadata}
229
- isLoading={compose.isMetadataLoading}
230
- onDismiss={compose.dismissPreview}
231
+ } else {
232
+ content = (
233
+ <KContainer.View background={'#F9F9FA'}>
234
+ <ChatCurrentQuotedMessage />
235
+ <ChatQuickActions
236
+ visible
237
+ actions={DEFAULT_QUICK_ACTIONS}
238
+ onActionPress={(action) =>
239
+ console.log('[ChatComposer] quick action:', action.id)
240
+ }
231
241
  />
232
- )}
233
-
234
- <AttachmentPreview
235
- visible={
236
- compose.isImagePreviewVisible || compose.isFilePreviewVisible
237
- }
238
- attachments={attachments}
239
- onRemove={onRemove}
240
- />
241
-
242
- <KContainer.View
243
- row
244
- alignItems
245
- paddingH="0.75rem"
246
- paddingT="0.75rem"
247
- paddingB="0.75rem"
248
- style={inputRowStyle}
249
- >
250
- <KContainer.Touchable
251
- onPress={toggleAttachmentVisibility}
252
- center
253
- br="round"
254
- >
255
- <KImage.VectorIcons
256
- name={isAttachmentOpen ? 'close-circle-o' : 'plus-circle-o'}
257
- size={24}
258
- color={
259
- isAttachmentOpen
260
- ? KColors.palette.primary.w400
261
- : KColors.palette.gray.w600
262
- }
242
+
243
+ {compose.showLinkPreview && (
244
+ <ChatLinkPreview
245
+ metadata={compose.urlMetadata}
246
+ isLoading={compose.isMetadataLoading}
247
+ onDismiss={compose.dismissPreview}
263
248
  />
264
- </KContainer.Touchable>
265
-
266
- <ChatMessageInput
267
- value={compose.text}
268
- placeholder="Nhập tin nhắn..."
269
- onChangeText={handleChangeText}
270
- onFocus={handleInputFocus}
271
- onBlur={handleInputBlur}
272
- onContentSizeChange={handleInputContentSizeChange}
273
- isInputFocused={isInputFocused}
274
- isMultilineInput={isMultilineInput}
275
- inputRef={inputRef}
276
- />
249
+ )}
277
250
 
278
- <KContainer.Touchable
279
- onPress={compose.isUploading ? undefined : handleSend}
280
- pointerEvents={compose.isUploading ? 'none' : 'auto'}
281
- width={40}
282
- height={40}
283
- center
284
- br="round"
285
- background={
286
- compose.isUploading
287
- ? KColors.palette.gray.w400
288
- : KColors.palette.primary.w400
251
+ <AttachmentPreview
252
+ visible={
253
+ compose.isImagePreviewVisible || compose.isFilePreviewVisible
289
254
  }
290
- marginL="0.75rem"
291
- opacity={compose.isUploading ? 0.6 : 1}
255
+ attachments={attachments}
256
+ onRemove={onRemove}
257
+ />
258
+
259
+ <KContainer.View
260
+ row
261
+ alignItems
262
+ paddingH="0.75rem"
263
+ paddingT="0.75rem"
264
+ paddingB="0.75rem"
265
+ style={inputRowStyle}
292
266
  >
293
- <KImage.VectorIcons
294
- name="send"
295
- provider="MaterialCommunityIcons"
296
- size={20}
297
- color={KColors.white}
267
+ <KContainer.Touchable
268
+ onPress={toggleAttachmentVisibility}
269
+ center
270
+ br="round"
271
+ >
272
+ <KImage.VectorIcons
273
+ name={isAttachmentOpen ? 'close-circle-o' : 'plus-circle-o'}
274
+ size={24}
275
+ color={
276
+ isAttachmentOpen
277
+ ? KColors.palette.primary.w400
278
+ : KColors.palette.gray.w600
279
+ }
280
+ />
281
+ </KContainer.Touchable>
282
+
283
+ <ChatMessageInput
284
+ value={compose.text}
285
+ placeholder="Nhập tin nhắn..."
286
+ onChangeText={handleChangeText}
287
+ onFocus={handleInputFocus}
288
+ onBlur={handleInputBlur}
289
+ onContentSizeChange={handleInputContentSizeChange}
290
+ isInputFocused={isInputFocused}
291
+ isMultilineInput={isMultilineInput}
292
+ inputRef={inputRef}
298
293
  />
299
- </KContainer.Touchable>
294
+
295
+ <KContainer.Touchable
296
+ onPress={compose.isUploading ? undefined : handleSend}
297
+ pointerEvents={compose.isUploading ? 'none' : 'auto'}
298
+ width={40}
299
+ height={40}
300
+ center
301
+ br="round"
302
+ background={
303
+ compose.isUploading
304
+ ? KColors.palette.gray.w400
305
+ : KColors.palette.primary.w400
306
+ }
307
+ marginL="0.75rem"
308
+ opacity={compose.isUploading ? 0.6 : 1}
309
+ >
310
+ <KImage.VectorIcons
311
+ name="send"
312
+ provider="MaterialCommunityIcons"
313
+ size={20}
314
+ color={KColors.white}
315
+ />
316
+ </KContainer.Touchable>
317
+ </KContainer.View>
300
318
  </KContainer.View>
319
+ );
320
+ }
301
321
 
302
- <Reanimated.View
303
- style={[styles.accessorySlot, accessorySlotStyle]}
304
- pointerEvents={isAttachmentOpen ? 'auto' : 'none'}
322
+ return (
323
+ <>
324
+ <KeyboardStickyView
325
+ ref={composerRef}
326
+ onLayout={onComposerLayout}
327
+ offset={{ closed: 0, opened: insets.bottom }}
328
+ >
329
+ {content}
330
+ </KeyboardStickyView>
331
+ <KContainer.VisibleView
332
+ visible={!isDisableCompose && !isNewBotConversation}
305
333
  >
306
- <ChatAttachmentPanel actions={resolvedPannelActons} />
307
- </Reanimated.View>
308
- </KContainer.View>
334
+ <Reanimated.View
335
+ style={[styles.accessorySlot, accessorySlotStyle]}
336
+ pointerEvents={isAttachmentOpen ? 'auto' : 'none'}
337
+ >
338
+ <ChatAttachmentPanel actions={resolvedPannelActons} />
339
+ </Reanimated.View>
340
+ </KContainer.VisibleView>
341
+ </>
309
342
  );
310
343
  }
311
344
  );
@@ -1,7 +1,11 @@
1
1
  import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
2
- import { DeviceEventEmitter, Platform, StyleSheet } from 'react-native';
2
+ import { DeviceEventEmitter, View } from 'react-native';
3
3
 
4
- import { KeyboardAvoidingView } from 'react-native-keyboard-controller';
4
+ import {
5
+ useKeyboardChatComposerInset,
6
+ useKeyboardScrollToEnd,
7
+ } from '@legendapp/list/keyboard';
8
+ import type { LegendListRef } from '@legendapp/list/react-native';
5
9
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
6
10
  import { KContainer } from '@droppii/libs';
7
11
  import { useNavigation } from '@react-navigation/native';
@@ -123,10 +127,13 @@ const ChatDetail = memo(
123
127
 
124
128
  const insets = useSafeAreaInsets();
125
129
 
126
- const keyboardVerticalOffset = useMemo(
127
- () => Platform.select({ android: insets.bottom, default: 0 }),
128
- [insets.bottom]
129
- );
130
+ const listRef = useRef<LegendListRef>(null);
131
+ const composerRef = useRef<View>(null);
132
+ const { contentInsetEndAdjustment, onComposerLayout } =
133
+ useKeyboardChatComposerInset(listRef, composerRef);
134
+ const { freeze, scrollMessageToEnd } = useKeyboardScrollToEnd({
135
+ listRef,
136
+ });
130
137
 
131
138
  const handlePressUrl = useCallback(
132
139
  (url: string) => {
@@ -173,33 +180,34 @@ const ChatDetail = memo(
173
180
  onPress={handlePressPinnedMessageBar}
174
181
  />
175
182
 
176
- <KeyboardAvoidingView
177
- style={styles.keyboardAvoiding}
178
- behavior="padding"
179
- keyboardVerticalOffset={keyboardVerticalOffset}
180
- >
181
- <ChatListLegend
182
- messages={messages}
183
- conversationId={conversationId}
184
- customMessageItemFactory={customMessageItemFactory}
185
- isLoading={initLoading || isLoadingConversation}
186
- onLoadMoreOld={getMoreOldMessages}
187
- hasMoreOld={hasMoreOld}
188
- moreOldLoading={moreOldLoading}
189
- onLoadMoreNew={getMoreNewMessages}
190
- hasMoreNew={hasMoreNew}
191
- moreNewLoading={moreNewLoading}
192
- onMediaPress={handleMediaPress}
193
- onPressUrl={handlePressUrl}
194
- onLongPress={handleMessageLongPress}
195
- />
196
-
197
- <ChatComposer
198
- isEmptyMessage={messages?.length === 0}
199
- pannelActions={pannelActions}
200
- isDisableCompose={botStatus === DBotStatusValue.INACTIVE}
201
- />
202
- </KeyboardAvoidingView>
183
+ <ChatListLegend
184
+ messages={messages}
185
+ conversationId={conversationId}
186
+ customMessageItemFactory={customMessageItemFactory}
187
+ isLoading={initLoading || isLoadingConversation}
188
+ onLoadMoreOld={getMoreOldMessages}
189
+ hasMoreOld={hasMoreOld}
190
+ moreOldLoading={moreOldLoading}
191
+ onLoadMoreNew={getMoreNewMessages}
192
+ hasMoreNew={hasMoreNew}
193
+ moreNewLoading={moreNewLoading}
194
+ onMediaPress={handleMediaPress}
195
+ onPressUrl={handlePressUrl}
196
+ onLongPress={handleMessageLongPress}
197
+ listRef={listRef}
198
+ contentInsetEndAdjustment={contentInsetEndAdjustment}
199
+ freeze={freeze}
200
+ scrollMessageToEnd={scrollMessageToEnd}
201
+ keyboardOffset={insets.bottom}
202
+ />
203
+
204
+ <ChatComposer
205
+ isEmptyMessage={messages?.length === 0}
206
+ pannelActions={pannelActions}
207
+ isDisableCompose={botStatus === DBotStatusValue.INACTIVE}
208
+ composerRef={composerRef}
209
+ onComposerLayout={onComposerLayout}
210
+ />
203
211
 
204
212
  <MediaViewerModal ref={mediaViewerRef} />
205
213
  <MessageToolboxOverlay
@@ -216,8 +224,4 @@ const ChatDetail = memo(
216
224
 
217
225
  ChatDetail.displayName = 'ChatDetail';
218
226
 
219
- const styles = StyleSheet.create({
220
- keyboardAvoiding: { flex: 1 },
221
- });
222
-
223
227
  export default ChatDetail;
@@ -1,11 +1,21 @@
1
- import { memo, useCallback, useMemo, useEffect, useRef, useState } from 'react';
1
+ import {
2
+ memo,
3
+ useCallback,
4
+ useMemo,
5
+ useEffect,
6
+ useRef,
7
+ useState,
8
+ type RefObject,
9
+ } from 'react';
2
10
  import { DeviceEventEmitter, StyleSheet, type View } from 'react-native';
3
11
  import Animated, {
4
12
  useSharedValue,
5
13
  useAnimatedStyle,
6
14
  withSpring,
15
+ type SharedValue,
7
16
  } from 'react-native-reanimated';
8
- import { LegendList, type LegendListRef } from '@legendapp/list/react-native';
17
+ import { KeyboardAwareLegendList } from '@legendapp/list/keyboard';
18
+ import type { LegendListRef } from '@legendapp/list/react-native';
9
19
  import { KContainer, KColors, KSpacingValue, KLabel } from '@droppii/libs';
10
20
  import { precomputeLegendListData } from '../../utils/legendListMessage';
11
21
  import type { ChatListProps } from './types';
@@ -26,6 +36,17 @@ import type { MessageItem } from '@droppii/openim-rn-client-sdk';
26
36
  import { useUserStore } from '../../store';
27
37
  import { ChatMessageAPI } from '../../services';
28
38
 
39
+ interface ChatListLegendInternalProps extends ChatListProps {
40
+ listRef: RefObject<LegendListRef | null>;
41
+ contentInsetEndAdjustment: SharedValue<number>;
42
+ freeze: SharedValue<boolean>;
43
+ scrollMessageToEnd: (opts: {
44
+ animated: boolean;
45
+ closeKeyboard: boolean;
46
+ }) => Promise<void>;
47
+ keyboardOffset: number;
48
+ }
49
+
29
50
  export const ChatListLegend = memo(
30
51
  ({
31
52
  messages = [],
@@ -40,7 +61,12 @@ export const ChatListLegend = memo(
40
61
  onMediaPress,
41
62
  onPressUrl,
42
63
  onLongPress,
43
- }: ChatListProps) => {
64
+ listRef,
65
+ contentInsetEndAdjustment,
66
+ freeze,
67
+ scrollMessageToEnd,
68
+ keyboardOffset,
69
+ }: ChatListLegendInternalProps) => {
44
70
  const currentUserId = useUserStore((s) => s.user?.userID);
45
71
  // Precompute all message data in single pass (O(n))
46
72
  const messageDataMap = useMemo(
@@ -58,7 +84,6 @@ export const ChatListLegend = memo(
58
84
  const { mutate: runFlow } = useRunFlow();
59
85
  const { mutate: resetFlow } = useResetFlow();
60
86
 
61
- const listRef = useRef<LegendListRef>(null);
62
87
  const buttonOpacity = useSharedValue(0);
63
88
  const isAtBottomRef = useRef(true);
64
89
  const scrollDebounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
@@ -285,12 +310,12 @@ export const ChatListLegend = memo(
285
310
  if (hasMoreNew) {
286
311
  onLoadMoreOld?.(false);
287
312
  } else {
288
- listRef.current?.scrollToEnd({ animated: true });
313
+ scrollMessageToEnd({ animated: true, closeKeyboard: false });
289
314
  buttonOpacity.value = withSpring(0, { duration: 300 });
290
315
  isAtBottomRef.current = true;
291
316
  setShowBadge(false);
292
317
  }
293
- }, [buttonOpacity, onLoadMoreOld, hasMoreNew]);
318
+ }, [hasMoreNew, onLoadMoreOld, scrollMessageToEnd, buttonOpacity]);
294
319
 
295
320
  const animatedButtonStyle = useAnimatedStyle(() => ({
296
321
  opacity: buttonOpacity.value,
@@ -359,6 +384,7 @@ export const ChatListLegend = memo(
359
384
  conversationId,
360
385
  buttonOpacity,
361
386
  handleScrollToBottom,
387
+ listRef,
362
388
  ]);
363
389
 
364
390
  // if (isLoading) {
@@ -374,7 +400,7 @@ export const ChatListLegend = memo(
374
400
 
375
401
  return (
376
402
  <KContainer.View flex background={KColors.white} style={styles.container}>
377
- <LegendList
403
+ <KeyboardAwareLegendList
378
404
  ref={listRef}
379
405
  data={messages}
380
406
  renderItem={renderItem}
@@ -385,6 +411,9 @@ export const ChatListLegend = memo(
385
411
  alignItemsAtEnd
386
412
  maintainVisibleContentPosition={maintainVisiblePositionConfig}
387
413
  maintainScrollAtEnd={maintainScrollConfig}
414
+ contentInsetEndAdjustment={contentInsetEndAdjustment}
415
+ freeze={freeze}
416
+ keyboardOffset={keyboardOffset}
388
417
  scrollEventThrottle={16}
389
418
  onScroll={handleScroll}
390
419
  contentContainerStyle={styles.content}