@droppii-org/chat-mobile 0.2.43 → 0.2.45

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 (44) hide show
  1. package/lib/module/context/ChatContext.js +6 -0
  2. package/lib/module/context/ChatContext.js.map +1 -1
  3. package/lib/module/hooks/message/usePinMessage.js +12 -8
  4. package/lib/module/hooks/message/usePinMessage.js.map +1 -1
  5. package/lib/module/screens/chat-detail/ChatComposer.js +87 -69
  6. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  7. package/lib/module/screens/chat-detail/ChatDetail.js +39 -34
  8. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  9. package/lib/module/screens/chat-detail/ChatListLegend.js +16 -8
  10. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  11. package/lib/module/screens/chat-detail/hooks/useChatComposerAnimation.js +7 -65
  12. package/lib/module/screens/chat-detail/hooks/useChatComposerAnimation.js.map +1 -1
  13. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +6 -1
  14. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  15. package/lib/module/store/pinnedMessage.js +10 -5
  16. package/lib/module/store/pinnedMessage.js.map +1 -1
  17. package/lib/module/translation/resources/i18n.js +1 -1
  18. package/lib/module/translation/resources/i18n.js.map +1 -1
  19. package/lib/module/types/chat.js.map +1 -1
  20. package/lib/typescript/src/context/ChatContext.d.ts +1 -1
  21. package/lib/typescript/src/context/ChatContext.d.ts.map +1 -1
  22. package/lib/typescript/src/hooks/message/usePinMessage.d.ts.map +1 -1
  23. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts +5 -1
  24. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  25. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  26. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +15 -1
  27. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  28. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerAnimation.d.ts +2 -3
  29. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerAnimation.d.ts.map +1 -1
  30. package/lib/typescript/src/store/pinnedMessage.d.ts +2 -1
  31. package/lib/typescript/src/store/pinnedMessage.d.ts.map +1 -1
  32. package/lib/typescript/src/types/chat.d.ts +1 -0
  33. package/lib/typescript/src/types/chat.d.ts.map +1 -1
  34. package/package.json +2 -2
  35. package/src/context/ChatContext.tsx +6 -0
  36. package/src/hooks/message/usePinMessage.ts +16 -8
  37. package/src/screens/chat-detail/ChatComposer.tsx +132 -99
  38. package/src/screens/chat-detail/ChatDetail.tsx +41 -37
  39. package/src/screens/chat-detail/ChatListLegend.tsx +36 -7
  40. package/src/screens/chat-detail/hooks/useChatComposerAnimation.ts +11 -106
  41. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +6 -1
  42. package/src/store/pinnedMessage.ts +20 -6
  43. package/src/translation/resources/i18n.ts +1 -1
  44. package/src/types/chat.ts +1 -0
@@ -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}
@@ -1,22 +1,15 @@
1
- import { useCallback, useEffect, useRef } from 'react';
1
+ import { useCallback, useEffect } from 'react';
2
2
  import { Keyboard } from 'react-native';
3
3
  import {
4
- runOnJS,
5
- useAnimatedReaction,
6
4
  useAnimatedStyle,
7
5
  useSharedValue,
8
6
  withTiming,
9
7
  } from 'react-native-reanimated';
10
- import {
11
- useKeyboardState,
12
- useReanimatedKeyboardAnimation,
13
- } from 'react-native-keyboard-controller';
8
+ import { useKeyboardState } from 'react-native-keyboard-controller';
14
9
 
15
10
  const ACCESSORY_ANIMATION_MS = 250;
16
- const ACCESSORY_SETTLE_MS = 150;
17
11
 
18
12
  interface UseChatComposerAnimationProps {
19
- isAttachmentOpen: boolean;
20
13
  panelHeight: number;
21
14
  onAttachmentOpenChange: (isOpen: boolean) => void;
22
15
  }
@@ -26,23 +19,17 @@ interface UseChatComposerAnimationReturn {
26
19
  accessorySlotStyle: any;
27
20
  toggleAttachmentVisibility: () => void;
28
21
  openAttachment: () => void;
29
- closeAttachment: () => void;
22
+ closeAttachment: (speedUp?: boolean) => void;
30
23
  }
31
24
 
32
25
  export const useChatComposerAnimation = ({
33
- isAttachmentOpen,
34
26
  panelHeight,
35
27
  onAttachmentOpenChange,
36
28
  }: UseChatComposerAnimationProps): UseChatComposerAnimationReturn => {
37
- const isSwitchingToAttachmentRef = useRef(false);
38
29
  const panelHeightSV = useSharedValue(panelHeight);
39
30
  const attachmentHeightSV = useSharedValue(0);
40
- const switchKeyboardHeightSV = useSharedValue(0);
41
- const isKeyboardToAttachmentSwitchSV = useSharedValue(false);
42
31
 
43
32
  const isKeyboardVisible = useKeyboardState((state) => state.isVisible);
44
- const keyboardHeight = useKeyboardState((state) => state.height);
45
- const { progress: keyboardProgress } = useReanimatedKeyboardAnimation();
46
33
 
47
34
  useEffect(() => {
48
35
  panelHeightSV.value = panelHeight;
@@ -55,92 +42,18 @@ export const useChatComposerAnimation = ({
55
42
  onAttachmentOpenChange(true);
56
43
  }, [attachmentHeightSV, panelHeight, onAttachmentOpenChange]);
57
44
 
58
- const closeAttachment = useCallback(() => {
59
- attachmentHeightSV.value = withTiming(0, {
60
- duration: ACCESSORY_ANIMATION_MS,
61
- });
62
- onAttachmentOpenChange(false);
63
- }, [attachmentHeightSV, onAttachmentOpenChange]);
64
-
65
- const finishKeyboardToAttachmentSwitch = useCallback(() => {
66
- if (!isSwitchingToAttachmentRef.current) {
67
- return;
68
- }
69
-
70
- isSwitchingToAttachmentRef.current = false;
71
- isKeyboardToAttachmentSwitchSV.value = false;
72
-
73
- const targetHeight = panelHeightSV.value;
74
- const startHeight = switchKeyboardHeightSV.value;
75
-
76
- if (Math.abs(startHeight - targetHeight) < 1) {
77
- attachmentHeightSV.value = targetHeight;
78
- return;
79
- }
80
-
81
- attachmentHeightSV.value = withTiming(targetHeight, {
82
- duration: ACCESSORY_SETTLE_MS,
83
- });
84
- }, [
85
- attachmentHeightSV,
86
- isKeyboardToAttachmentSwitchSV,
87
- panelHeightSV,
88
- switchKeyboardHeightSV,
89
- ]);
90
-
91
- useEffect(() => {
92
- if (!isKeyboardVisible && isSwitchingToAttachmentRef.current) {
93
- finishKeyboardToAttachmentSwitch();
94
- }
95
- }, [finishKeyboardToAttachmentSwitch, isKeyboardVisible]);
96
-
97
- useAnimatedReaction(
98
- () => ({
99
- switching: isKeyboardToAttachmentSwitchSV.value,
100
- progress: keyboardProgress.value,
101
- }),
102
- (current, previous) => {
103
- if (
104
- current.switching &&
105
- current.progress === 0 &&
106
- (previous?.progress ?? 1) > 0
107
- ) {
108
- runOnJS(finishKeyboardToAttachmentSwitch)();
109
- }
110
- },
111
- [finishKeyboardToAttachmentSwitch]
112
- );
113
-
114
- useEffect(() => {
115
- // Close panel when keyboard becomes visible (unless doing keyboard↔panel switch)
116
- // Don't depend on closeAttachment to avoid triggering effect when callback changes
117
- if (
118
- !isSwitchingToAttachmentRef.current &&
119
- isKeyboardVisible &&
120
- isAttachmentOpen
121
- ) {
45
+ const closeAttachment = useCallback(
46
+ (speedUp = false) => {
122
47
  attachmentHeightSV.value = withTiming(0, {
123
- duration: ACCESSORY_ANIMATION_MS,
48
+ duration: ACCESSORY_ANIMATION_MS / (speedUp ? 2 : 1),
124
49
  });
125
50
  onAttachmentOpenChange(false);
126
- }
127
- }, [
128
- isAttachmentOpen,
129
- isKeyboardVisible,
130
- attachmentHeightSV,
131
- onAttachmentOpenChange,
132
- ]);
51
+ },
52
+ [attachmentHeightSV, onAttachmentOpenChange]
53
+ );
133
54
 
134
55
  const accessorySlotStyle = useAnimatedStyle(() => {
135
- if (isKeyboardToAttachmentSwitchSV.value) {
136
- return {
137
- height: switchKeyboardHeightSV.value * (1 - keyboardProgress.value),
138
- };
139
- }
140
-
141
- return {
142
- height: attachmentHeightSV.value,
143
- };
56
+ return { height: attachmentHeightSV.value };
144
57
  });
145
58
 
146
59
  const toggleAttachmentVisibility = useCallback(() => {
@@ -152,24 +65,16 @@ export const useChatComposerAnimation = ({
152
65
  return;
153
66
  }
154
67
 
155
- if (isKeyboardVisible && keyboardHeight > 0) {
156
- // Dismiss keyboard first, then open attachment
68
+ if (isKeyboardVisible) {
157
69
  Keyboard.dismiss();
158
- isSwitchingToAttachmentRef.current = true;
159
- switchKeyboardHeightSV.value = keyboardHeight;
160
- isKeyboardToAttachmentSwitchSV.value = true;
161
70
  onAttachmentOpenChange(true);
162
- return;
163
71
  }
164
72
 
165
73
  openAttachment();
166
74
  }, [
167
75
  closeAttachment,
168
76
  isKeyboardVisible,
169
- keyboardHeight,
170
- isKeyboardToAttachmentSwitchSV,
171
77
  onAttachmentOpenChange,
172
- switchKeyboardHeightSV,
173
78
  openAttachment,
174
79
  attachmentHeightSV,
175
80
  ]);
@@ -134,7 +134,12 @@ const styles = StyleSheet.create({
134
134
  },
135
135
  pinBadge: {
136
136
  position: 'absolute',
137
- top: -8,
137
+ // Capped at the inter-row gap (messageRowLeft/Right's marginBottom) so the
138
+ // badge never overlaps the row above: LegendList's non-sticky item cells
139
+ // don't carry a zIndex, so cross-row paint order follows recycled view-slot
140
+ // order, not visual position — any real overlap can end up hidden behind
141
+ // the row above depending on scroll history.
142
+ top: -4,
138
143
  right: -8,
139
144
  width: 24,
140
145
  height: 20,