@droppii-org/chat-mobile 0.2.107 → 0.2.108

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 (123) hide show
  1. package/lib/module/components/ThreadCard/ThreadCard.js +2 -0
  2. package/lib/module/components/ThreadCard/ThreadCard.js.map +1 -1
  3. package/lib/module/components/messages/fileMessage/index.js +9 -2
  4. package/lib/module/components/messages/fileMessage/index.js.map +1 -1
  5. package/lib/module/components/messages/quotedMessage/index.js +6 -1
  6. package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
  7. package/lib/module/context/TimelinePressGuardContext.js +14 -0
  8. package/lib/module/context/TimelinePressGuardContext.js.map +1 -0
  9. package/lib/module/hooks/useConversationList.js +4 -0
  10. package/lib/module/hooks/useConversationList.js.map +1 -1
  11. package/lib/module/hooks/useDismissKeyboardFirst.js +19 -0
  12. package/lib/module/hooks/useDismissKeyboardFirst.js.map +1 -0
  13. package/lib/module/hooks/useExpandableText.js +6 -1
  14. package/lib/module/hooks/useExpandableText.js.map +1 -1
  15. package/lib/module/hooks/useGuardedPress.js +19 -0
  16. package/lib/module/hooks/useGuardedPress.js.map +1 -0
  17. package/lib/module/screens/chat-detail/ChatComposer.js +89 -498
  18. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  19. package/lib/module/screens/chat-detail/ChatDetail.js +38 -20
  20. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  21. package/lib/module/screens/chat-detail/ChatListLegend.js +113 -76
  22. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  23. package/lib/module/screens/chat-detail/components/ChatComposerAccessorySlot.js +47 -0
  24. package/lib/module/screens/chat-detail/components/ChatComposerAccessorySlot.js.map +1 -0
  25. package/lib/module/screens/chat-detail/components/ChatComposerInputArea.js +149 -0
  26. package/lib/module/screens/chat-detail/components/ChatComposerInputArea.js.map +1 -0
  27. package/lib/module/screens/chat-detail/components/ChatComposerStatusBanner.js +104 -0
  28. package/lib/module/screens/chat-detail/components/ChatComposerStatusBanner.js.map +1 -0
  29. package/lib/module/screens/chat-detail/hooks/useChatComposerAnimation.js +0 -17
  30. package/lib/module/screens/chat-detail/hooks/useChatComposerAnimation.js.map +1 -1
  31. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js +16 -2
  32. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js.map +1 -1
  33. package/lib/module/screens/chat-detail/hooks/useComposerBotMenu.js +122 -0
  34. package/lib/module/screens/chat-detail/hooks/useComposerBotMenu.js.map +1 -0
  35. package/lib/module/screens/chat-detail/hooks/useComposerPanelActions.js +46 -0
  36. package/lib/module/screens/chat-detail/hooks/useComposerPanelActions.js.map +1 -0
  37. package/lib/module/screens/chat-detail/hooks/useComposerPanelDismiss.js +75 -0
  38. package/lib/module/screens/chat-detail/hooks/useComposerPanelDismiss.js.map +1 -0
  39. package/lib/module/screens/chat-detail/hooks/useComposerPanels.js +136 -0
  40. package/lib/module/screens/chat-detail/hooks/useComposerPanels.js.map +1 -0
  41. package/lib/module/screens/chat-detail/hooks/useComposerSend.js +74 -0
  42. package/lib/module/screens/chat-detail/hooks/useComposerSend.js.map +1 -0
  43. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +2 -0
  44. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  45. package/lib/module/screens/chat-detail/types.js +5 -0
  46. package/lib/module/screens/chat-detail/types.js.map +1 -1
  47. package/lib/module/store/message.js +14 -0
  48. package/lib/module/store/message.js.map +1 -1
  49. package/lib/module/utils/composerPanelDismissGesture.js +52 -0
  50. package/lib/module/utils/composerPanelDismissGesture.js.map +1 -0
  51. package/lib/module/utils/perf-marker.js +44 -0
  52. package/lib/module/utils/perf-marker.js.map +1 -0
  53. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  54. package/lib/typescript/src/components/messages/fileMessage/index.d.ts.map +1 -1
  55. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
  56. package/lib/typescript/src/context/TimelinePressGuardContext.d.ts +3 -0
  57. package/lib/typescript/src/context/TimelinePressGuardContext.d.ts.map +1 -0
  58. package/lib/typescript/src/hooks/useConversationList.d.ts.map +1 -1
  59. package/lib/typescript/src/hooks/useDismissKeyboardFirst.d.ts +8 -0
  60. package/lib/typescript/src/hooks/useDismissKeyboardFirst.d.ts.map +1 -0
  61. package/lib/typescript/src/hooks/useExpandableText.d.ts.map +1 -1
  62. package/lib/typescript/src/hooks/useGuardedPress.d.ts +9 -0
  63. package/lib/typescript/src/hooks/useGuardedPress.d.ts.map +1 -0
  64. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts +7 -3
  65. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  66. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  67. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +5 -1
  68. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  69. package/lib/typescript/src/screens/chat-detail/components/ChatComposerAccessorySlot.d.ts +12 -0
  70. package/lib/typescript/src/screens/chat-detail/components/ChatComposerAccessorySlot.d.ts.map +1 -0
  71. package/lib/typescript/src/screens/chat-detail/components/ChatComposerInputArea.d.ts +30 -0
  72. package/lib/typescript/src/screens/chat-detail/components/ChatComposerInputArea.d.ts.map +1 -0
  73. package/lib/typescript/src/screens/chat-detail/components/ChatComposerStatusBanner.d.ts +19 -0
  74. package/lib/typescript/src/screens/chat-detail/components/ChatComposerStatusBanner.d.ts.map +1 -0
  75. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerAnimation.d.ts +0 -1
  76. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerAnimation.d.ts.map +1 -1
  77. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts +7 -0
  78. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts.map +1 -1
  79. package/lib/typescript/src/screens/chat-detail/hooks/useComposerBotMenu.d.ts +7 -0
  80. package/lib/typescript/src/screens/chat-detail/hooks/useComposerBotMenu.d.ts.map +1 -0
  81. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelActions.d.ts +17 -0
  82. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelActions.d.ts.map +1 -0
  83. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelDismiss.d.ts +12 -0
  84. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelDismiss.d.ts.map +1 -0
  85. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanels.d.ts +25 -0
  86. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanels.d.ts.map +1 -0
  87. package/lib/typescript/src/screens/chat-detail/hooks/useComposerSend.d.ts +16 -0
  88. package/lib/typescript/src/screens/chat-detail/hooks/useComposerSend.d.ts.map +1 -0
  89. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  90. package/lib/typescript/src/screens/chat-detail/types.d.ts +8 -0
  91. package/lib/typescript/src/screens/chat-detail/types.d.ts.map +1 -1
  92. package/lib/typescript/src/store/message.d.ts.map +1 -1
  93. package/lib/typescript/src/utils/composerPanelDismissGesture.d.ts +33 -0
  94. package/lib/typescript/src/utils/composerPanelDismissGesture.d.ts.map +1 -0
  95. package/lib/typescript/src/utils/perf-marker.d.ts +8 -0
  96. package/lib/typescript/src/utils/perf-marker.d.ts.map +1 -0
  97. package/package.json +2 -1
  98. package/src/components/ThreadCard/ThreadCard.tsx +2 -0
  99. package/src/components/messages/fileMessage/index.tsx +17 -3
  100. package/src/components/messages/quotedMessage/index.tsx +5 -1
  101. package/src/context/TimelinePressGuardContext.tsx +15 -0
  102. package/src/hooks/useConversationList.ts +2 -0
  103. package/src/hooks/useDismissKeyboardFirst.ts +19 -0
  104. package/src/hooks/useExpandableText.ts +5 -1
  105. package/src/hooks/useGuardedPress.ts +20 -0
  106. package/src/screens/chat-detail/ChatComposer.tsx +105 -631
  107. package/src/screens/chat-detail/ChatDetail.tsx +38 -20
  108. package/src/screens/chat-detail/ChatListLegend.tsx +122 -71
  109. package/src/screens/chat-detail/components/ChatComposerAccessorySlot.tsx +61 -0
  110. package/src/screens/chat-detail/components/ChatComposerInputArea.tsx +198 -0
  111. package/src/screens/chat-detail/components/ChatComposerStatusBanner.tsx +116 -0
  112. package/src/screens/chat-detail/hooks/useChatComposerAnimation.ts +0 -29
  113. package/src/screens/chat-detail/hooks/useChatComposerState.ts +25 -2
  114. package/src/screens/chat-detail/hooks/useComposerBotMenu.tsx +140 -0
  115. package/src/screens/chat-detail/hooks/useComposerPanelActions.ts +57 -0
  116. package/src/screens/chat-detail/hooks/useComposerPanelDismiss.ts +111 -0
  117. package/src/screens/chat-detail/hooks/useComposerPanels.ts +197 -0
  118. package/src/screens/chat-detail/hooks/useComposerSend.ts +98 -0
  119. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +2 -0
  120. package/src/screens/chat-detail/types.ts +9 -0
  121. package/src/store/message.ts +14 -0
  122. package/src/utils/composerPanelDismissGesture.ts +72 -0
  123. package/src/utils/perf-marker.ts +39 -0
@@ -23,6 +23,8 @@ import { clearExpandableTextCache } from '../../hooks/useExpandableText';
23
23
  import ChatDetailHeader from './ChatDetailHeader';
24
24
  import { ChatListLegend } from './ChatListLegend';
25
25
  import { ChatComposer } from './ChatComposer';
26
+ import { useComposerPanelDismiss } from './hooks/useComposerPanelDismiss';
27
+ import { TimelinePressGuardProvider } from '../../context/TimelinePressGuardContext';
26
28
  import { shouldShowAddMember } from './conversationHeader.utils';
27
29
  import type { ChatDetailProps } from './types';
28
30
  import { normalizeUrl } from '../../utils/url';
@@ -191,6 +193,15 @@ const ChatDetail = memo(
191
193
  [onPressUrl]
192
194
  );
193
195
 
196
+ const {
197
+ panelControllerRef,
198
+ isComposerPanelOpen,
199
+ onComposerPanelOpenChange,
200
+ dismissAreaTouchProps,
201
+ notifyScrollBeginDrag,
202
+ shouldSwallowPress,
203
+ } = useComposerPanelDismiss();
204
+
194
205
  const handleMessageLongPress = useCallback<MessageToolboxShowFn>(
195
206
  (message, rect, groupPosition) => {
196
207
  messageToolboxRef.current?.show(message, rect, groupPosition);
@@ -249,26 +260,31 @@ const ChatDetail = memo(
249
260
  onPress={handlePressPinnedMessageBar}
250
261
  />
251
262
 
252
- <ChatListLegend
253
- messages={messages}
254
- conversationId={conversationId}
255
- customMessageItemFactory={customMessageItemFactory}
256
- isLoading={initLoading}
257
- onLoadMoreOld={getMoreOldMessages}
258
- hasMoreOld={hasMoreOld}
259
- moreOldLoading={moreOldLoading}
260
- onLoadMoreNew={getMoreNewMessages}
261
- hasMoreNew={hasMoreNew}
262
- moreNewLoading={moreNewLoading}
263
- onMediaPress={handleMediaPress}
264
- onPressUrl={handlePressUrl}
265
- onLongPress={handleMessageLongPress}
266
- listRef={listRef}
267
- freeze={freeze}
268
- scrollMessageToEnd={scrollMessageToEnd}
269
- keyboardOffset={insets.bottom}
270
- renderConversationViewHeader={renderConversationViewHeader}
271
- />
263
+ <TimelinePressGuardProvider value={shouldSwallowPress}>
264
+ <ChatListLegend
265
+ messages={messages}
266
+ conversationId={conversationId}
267
+ customMessageItemFactory={customMessageItemFactory}
268
+ isLoading={initLoading}
269
+ onLoadMoreOld={getMoreOldMessages}
270
+ hasMoreOld={hasMoreOld}
271
+ moreOldLoading={moreOldLoading}
272
+ onLoadMoreNew={getMoreNewMessages}
273
+ hasMoreNew={hasMoreNew}
274
+ moreNewLoading={moreNewLoading}
275
+ onMediaPress={handleMediaPress}
276
+ onPressUrl={handlePressUrl}
277
+ onLongPress={handleMessageLongPress}
278
+ listRef={listRef}
279
+ freeze={freeze}
280
+ scrollMessageToEnd={scrollMessageToEnd}
281
+ keyboardOffset={insets.bottom}
282
+ renderConversationViewHeader={renderConversationViewHeader}
283
+ dismissAreaTouchProps={dismissAreaTouchProps}
284
+ isComposerPanelOpen={isComposerPanelOpen}
285
+ onDismissAreaScrollBeginDrag={notifyScrollBeginDrag}
286
+ />
287
+ </TimelinePressGuardProvider>
272
288
 
273
289
  <ChatComposer
274
290
  isEmptyMessage={messages?.length === 0}
@@ -276,6 +292,8 @@ const ChatDetail = memo(
276
292
  isComposeDisabled={isComposeDisabled}
277
293
  onPressJoinGroup={handlePressJoinGroup}
278
294
  isJoiningGroup={isJoiningGroup}
295
+ panelControllerRef={panelControllerRef}
296
+ onPanelOpenChange={onComposerPanelOpenChange}
279
297
  />
280
298
 
281
299
  <MediaViewerModal ref={mediaViewerRef} />
@@ -69,6 +69,11 @@ import ChatImageDimens, {
69
69
  CHAT_IMAGE_VIEW_SOURCE,
70
70
  } from '../../utils/chatImageDimens';
71
71
  import { ChatTypingIndicator } from './ChatTypingIndicator';
72
+ import type { DismissAreaTouchProps } from './hooks/useComposerPanelDismiss';
73
+ import { useGuardedPress } from '../../hooks/useGuardedPress';
74
+ import { useDismissKeyboardFirst } from '../../hooks/useDismissKeyboardFirst';
75
+ import { useTimelinePressGuard } from '../../context/TimelinePressGuardContext';
76
+ import { perfMark } from '../../utils/perf-marker';
72
77
 
73
78
  // Cluster spacing: a wide gap after the message that closes a cluster, a tight
74
79
  // one between messages inside it. These override the flat margin on the row
@@ -135,6 +140,12 @@ interface ChatListLegendInternalProps extends ChatListProps {
135
140
  closeKeyboard: boolean;
136
141
  }) => Promise<void>;
137
142
  keyboardOffset: number;
143
+ // Tap-outside dismiss of the composer panel (useComposerPanelDismiss).
144
+ // Internal — not part of the public ChatListProps.
145
+ dismissAreaTouchProps?: DismissAreaTouchProps;
146
+ // While true the content is non-hittable: taps only dismiss the panel.
147
+ isComposerPanelOpen?: boolean;
148
+ onDismissAreaScrollBeginDrag?: () => void;
138
149
  }
139
150
 
140
151
  export const ChatListLegend = memo(
@@ -147,8 +158,8 @@ export const ChatListLegend = memo(
147
158
  hasMoreOld,
148
159
  onLoadMoreNew,
149
160
  hasMoreNew,
150
- onMediaPress,
151
- onPressUrl,
161
+ onMediaPress: onMediaPressProp,
162
+ onPressUrl: onPressUrlProp,
152
163
  onLongPress,
153
164
  listRef,
154
165
  freeze,
@@ -156,6 +167,9 @@ export const ChatListLegend = memo(
156
167
  keyboardOffset,
157
168
  isLoading,
158
169
  renderConversationViewHeader,
170
+ dismissAreaTouchProps,
171
+ isComposerPanelOpen = false,
172
+ onDismissAreaScrollBeginDrag,
159
173
  }: ChatListLegendInternalProps) => {
160
174
  const currentUserId = useUserStore((s) => s.user?.userID);
161
175
  const windowWidth = useWindowDimensions().width;
@@ -177,7 +191,19 @@ export const ChatListLegend = memo(
177
191
  [messages, isBotConversation, currentUserId]
178
192
  );
179
193
 
180
- const { retryMessage } = useSendMessage();
194
+ // True for presses of a touch that began with the panel open — covers the
195
+ // render gap before `isComposerPanelOpen` reaches the list.
196
+ const shouldSwallowPress = useTimelinePressGuard();
197
+ const { retryMessage: retryMessageRaw } = useSendMessage();
198
+ const retryMessage = useGuardedPress(retryMessageRaw, shouldSwallowPress);
199
+ const onMediaPress = useGuardedPress(
200
+ useDismissKeyboardFirst(onMediaPressProp),
201
+ shouldSwallowPress
202
+ );
203
+ const onPressUrl = useGuardedPress(
204
+ useDismissKeyboardFirst(onPressUrlProp),
205
+ shouldSwallowPress
206
+ );
181
207
 
182
208
  const lastMessageId = useMemo(() => {
183
209
  const last = messages[messages.length - 1];
@@ -267,6 +293,7 @@ export const ChatListLegend = memo(
267
293
 
268
294
  const handleMessageLongPress = useCallback(
269
295
  (messageId: string, message: MessageItem) => {
296
+ if (shouldSwallowPress()) return;
270
297
  const node = rowRefsMap.current.get(messageId);
271
298
  node?.measureInWindow((x, y, width, height) => {
272
299
  if (x == null || y == null || width == null || height == null) return;
@@ -274,23 +301,29 @@ export const ChatListLegend = memo(
274
301
  onLongPress?.(message, { x, y, width, height }, groupPosition);
275
302
  });
276
303
  },
277
- [onLongPress, messageDataMap]
304
+ [onLongPress, messageDataMap, shouldSwallowPress]
278
305
  );
279
306
 
280
307
  const onRunFlow = useCallback(
281
308
  (payload: IRunConversationFlowPayload) => {
309
+ if (shouldSwallowPress()) return;
282
310
  runFlow(payload);
283
311
  },
284
- [runFlow]
312
+ [runFlow, shouldSwallowPress]
285
313
  );
286
314
 
287
315
  const onResetFlow = useCallback(() => {
316
+ if (shouldSwallowPress()) return;
288
317
  resetFlow();
289
- }, [resetFlow]);
318
+ }, [resetFlow, shouldSwallowPress]);
290
319
 
291
- const handleMessagePress = useCallback((messageId: string) => {
292
- setRevealedMessageId((prev) => (prev === messageId ? null : messageId));
293
- }, []);
320
+ const handleMessagePress = useCallback(
321
+ (messageId: string) => {
322
+ if (shouldSwallowPress()) return;
323
+ setRevealedMessageId((prev) => (prev === messageId ? null : messageId));
324
+ },
325
+ [shouldSwallowPress]
326
+ );
294
327
 
295
328
  const handleRenderConversationViewHeader = useCallback(() => {
296
329
  if (!renderConversationViewHeader) return null;
@@ -554,6 +587,7 @@ export const ChatListLegend = memo(
554
587
  // load-more the user never asked for.
555
588
  if (!hasUserScrolledRef.current) return;
556
589
  if (hasMoreOld && !useMessageStore.getState().moreOldLoading) {
590
+ perfMark('history_start_reached');
557
591
  onLoadMoreOld?.(true);
558
592
  }
559
593
  }, [hasMoreOld, onLoadMoreOld]);
@@ -567,7 +601,8 @@ export const ChatListLegend = memo(
567
601
 
568
602
  const handleScrollBeginDrag = useCallback(() => {
569
603
  hasUserScrolledRef.current = true;
570
- }, []);
604
+ onDismissAreaScrollBeginDrag?.();
605
+ }, [onDismissAreaScrollBeginDrag]);
571
606
 
572
607
  const handleScroll = useCallback(
573
608
  (e: any) => {
@@ -649,8 +684,11 @@ export const ChatListLegend = memo(
649
684
  () => [
650
685
  styles.content,
651
686
  { paddingBottom: CONTENT_PADDING_VERTICAL + keyboardInset },
687
+ // Composer panel open: bubbles stop receiving touches (a tap only
688
+ // dismisses the panel) while the scroll view itself still scrolls.
689
+ isComposerPanelOpen && styles.contentNotHittable,
652
690
  ],
653
- [keyboardInset]
691
+ [keyboardInset, isComposerPanelOpen]
654
692
  );
655
693
 
656
694
  const animatedButtonStyle = useAnimatedStyle(() => ({
@@ -866,6 +904,7 @@ export const ChatListLegend = memo(
866
904
  const handleListLoad = useCallback(() => {
867
905
  if (hasCorrectedInitialPaddingRef.current) return;
868
906
  hasCorrectedInitialPaddingRef.current = true;
907
+ perfMark('history_list_loaded');
869
908
 
870
909
  scrollMessageToEnd({ animated: false, closeKeyboard: false });
871
910
  }, [scrollMessageToEnd]);
@@ -942,66 +981,75 @@ export const ChatListLegend = memo(
942
981
  return (
943
982
  <KContainer.View flex background={KColors.white} style={styles.container}>
944
983
  {handleRenderConversationViewHeader()}
945
- <KeyboardAwareLegendList
946
- ref={listRef}
947
- data={messages}
948
- renderItem={renderItem}
949
- // Deliberate lower bound (not a best-guess average) — see the
950
- // comment above `hasCorrectedInitialPaddingRef`. A single-line
951
- // bubble floors out around 38-44px, so this stays safely under it.
952
- estimatedItemSize={36}
953
- getItemType={getItemType}
954
- getFixedItemSize={getFixedItemSize}
955
- keyExtractor={(item) => String(item.clientMsgID || item.serverMsgID)}
956
- recycleItems
957
- extraData={listExtraData}
958
- alignItemsAtEnd
959
- alwaysRender={{
960
- top: 10,
961
- }}
962
- maintainScrollAtEnd
963
- maintainVisibleContentPosition={maintainVisiblePositionConfig}
964
- // See the comment above `maintainVisiblePositionConfig` — this is
965
- // what keeps a history prepend's anchor item mounted through a slow
966
- // fetch instead of the default 250px window losing it.
967
- drawDistance={800}
968
- freeze={freeze}
969
- keyboardOffset={DISABLE_NATIVE_KEYBOARD_INSET}
970
- // iOS only. Left on, React Native recomputes and applies its own
971
- // `contentInset` every time the keyboard moves — measured here as
972
- // {top: 59, bottom: keyboardHeight}, both wrong. The bottom value
973
- // over-insets by the safe area, and the phantom top one is worse:
974
- // iOS rests a scroll view at `-contentInset.top`.
975
- // That resting offset is what put the content 59pt low and hid the
976
- // last message behind the composer the moment a touch let UIKit
977
- // re-clamp. legend-list's own KeyboardAvoidingLegendList disables
978
- // this for the same reason.
979
- automaticallyAdjustContentInsets={false}
980
- // No scroll shift from the library either — the content padding
981
- // above is the single compensator. Measured with it on: a 302px
982
- // keyboard produced a 604px lift, exactly double.
983
- keyboardLiftBehavior="never"
984
- scrollEventThrottle={16}
985
- onScroll={handleScroll}
986
- onScrollBeginDrag={handleScrollBeginDrag}
987
- onLoad={handleListLoad}
988
- contentContainerStyle={contentContainerStyle}
989
- scrollIndicatorInsets={{ right: 1 }}
990
- ListHeaderComponent={
991
- <LegendChatLoadEarlier isLoading={moreOldLoading} />
992
- }
993
- onStartReached={handleStartReached}
994
- // LegendList only re-fires onStartReached after scrolling away from
995
- // the threshold by 1.3x and back. At 0.5 that distance exceeded a
996
- // short conversation's whole scrollable range, so the mount-time
997
- // firing we suppress in handleStartReached "used up" the only
998
- // trigger. 0.3 keeps that reset distance safely under a
999
- // ~1.5-screen conversation; shorter ones can still hit it.
1000
- onStartReachedThreshold={0.2}
1001
- onEndReached={handleEndReached}
1002
- onEndReachedThreshold={0.2}
1003
- initialScrollAtEnd
1004
- />
984
+ <KContainer.View flex {...dismissAreaTouchProps}>
985
+ <KeyboardAwareLegendList
986
+ ref={listRef}
987
+ data={messages}
988
+ renderItem={renderItem}
989
+ // Deliberate lower bound (not a best-guess average) — see the
990
+ // comment above `hasCorrectedInitialPaddingRef`. A single-line
991
+ // bubble floors out around 38-44px, so this stays safely under it.
992
+ estimatedItemSize={36}
993
+ getItemType={getItemType}
994
+ getFixedItemSize={getFixedItemSize}
995
+ keyExtractor={(item) =>
996
+ String(item.clientMsgID || item.serverMsgID)
997
+ }
998
+ recycleItems
999
+ extraData={listExtraData}
1000
+ alignItemsAtEnd
1001
+ alwaysRender={{
1002
+ top: 10,
1003
+ }}
1004
+ maintainScrollAtEnd
1005
+ maintainVisibleContentPosition={maintainVisiblePositionConfig}
1006
+ // See the comment above `maintainVisiblePositionConfig` — this is
1007
+ // what keeps a history prepend's anchor item mounted through a slow
1008
+ // fetch instead of the default 250px window losing it.
1009
+ drawDistance={800}
1010
+ freeze={freeze}
1011
+ keyboardOffset={DISABLE_NATIVE_KEYBOARD_INSET}
1012
+ // iOS only. Left on, React Native recomputes and applies its own
1013
+ // `contentInset` every time the keyboard moves — measured here as
1014
+ // {top: 59, bottom: keyboardHeight}, both wrong. The bottom value
1015
+ // over-insets by the safe area, and the phantom top one is worse:
1016
+ // iOS rests a scroll view at `-contentInset.top`.
1017
+ // That resting offset is what put the content 59pt low and hid the
1018
+ // last message behind the composer the moment a touch let UIKit
1019
+ // re-clamp. legend-list's own KeyboardAvoidingLegendList disables
1020
+ // this for the same reason.
1021
+ automaticallyAdjustContentInsets={false}
1022
+ // No scroll shift from the library either — the content padding
1023
+ // above is the single compensator. Measured with it on: a 302px
1024
+ // keyboard produced a 604px lift, exactly double.
1025
+ keyboardLiftBehavior="never"
1026
+ // With the default 'never' the first tap while the keyboard is up
1027
+ // only dismisses it, so "Xem thêm", links and buttons don't fire.
1028
+ // 'handled': a touchable consumes the tap and the keyboard stays;
1029
+ // a tap on anything else still dismisses it.
1030
+ keyboardShouldPersistTaps="handled"
1031
+ scrollEventThrottle={16}
1032
+ onScroll={handleScroll}
1033
+ onScrollBeginDrag={handleScrollBeginDrag}
1034
+ onLoad={handleListLoad}
1035
+ contentContainerStyle={contentContainerStyle}
1036
+ scrollIndicatorInsets={{ right: 1 }}
1037
+ ListHeaderComponent={
1038
+ <LegendChatLoadEarlier isLoading={moreOldLoading} />
1039
+ }
1040
+ onStartReached={handleStartReached}
1041
+ // LegendList only re-fires onStartReached after scrolling away from
1042
+ // the threshold by 1.3x and back. At 0.5 that distance exceeded a
1043
+ // short conversation's whole scrollable range, so the mount-time
1044
+ // firing we suppress in handleStartReached "used up" the only
1045
+ // trigger. 0.3 keeps that reset distance safely under a
1046
+ // ~1.5-screen conversation; shorter ones can still hit it.
1047
+ onStartReachedThreshold={0.2}
1048
+ onEndReached={handleEndReached}
1049
+ onEndReachedThreshold={0.2}
1050
+ initialScrollAtEnd
1051
+ />
1052
+ </KContainer.View>
1005
1053
  <Animated.View
1006
1054
  style={[styles.scrollToBottomButton, animatedButtonStyle]}
1007
1055
  >
@@ -1033,6 +1081,9 @@ const styles = StyleSheet.create({
1033
1081
  container: {
1034
1082
  position: 'relative',
1035
1083
  },
1084
+ contentNotHittable: {
1085
+ pointerEvents: 'none',
1086
+ },
1036
1087
  header: {
1037
1088
  position: 'absolute',
1038
1089
  top: 0,
@@ -0,0 +1,61 @@
1
+ import { useCallback } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import Reanimated from 'react-native-reanimated';
4
+ import { KContainer, KColors } from '@droppii/libs';
5
+
6
+ import { ChatAttachmentPanel } from '../ChatAttachmentPanel';
7
+ import { StickerPicker } from '../../../components/StickerPicker';
8
+ import { useSendMessage } from '../../../hooks/message/useSendMessage';
9
+ import type { StickerItem } from '../../../types/sticker';
10
+ import type { ActivePanelType } from '../hooks/useChatComposerState';
11
+ import type { DChatPannelAction } from '../types';
12
+
13
+ interface ChatComposerAccessorySlotProps {
14
+ visible: boolean;
15
+ accessorySlotStyle: any;
16
+ isAttachmentOpen: boolean;
17
+ activePanelType: ActivePanelType;
18
+ pannelActions: DChatPannelAction[];
19
+ }
20
+
21
+ // The keyboard-area slot below the composer that hosts the attachment
22
+ // panel or the sticker picker; its height is driven by useComposerPanels.
23
+ export const ChatComposerAccessorySlot = ({
24
+ visible,
25
+ accessorySlotStyle,
26
+ isAttachmentOpen,
27
+ activePanelType,
28
+ pannelActions,
29
+ }: ChatComposerAccessorySlotProps) => {
30
+ const { sendMessage } = useSendMessage();
31
+
32
+ const handleStickerSelect = useCallback(
33
+ (sticker: StickerItem) => {
34
+ sendMessage({ sticker });
35
+ },
36
+ [sendMessage]
37
+ );
38
+
39
+ return (
40
+ <KContainer.VisibleView visible={visible}>
41
+ <Reanimated.View
42
+ style={[styles.accessorySlot, accessorySlotStyle]}
43
+ pointerEvents={isAttachmentOpen ? 'auto' : 'none'}
44
+ >
45
+ {activePanelType === 'attachment' && (
46
+ <ChatAttachmentPanel actions={pannelActions} />
47
+ )}
48
+ {activePanelType === 'sticker' && (
49
+ <StickerPicker onSelectSticker={handleStickerSelect} />
50
+ )}
51
+ </Reanimated.View>
52
+ </KContainer.VisibleView>
53
+ );
54
+ };
55
+
56
+ const styles = StyleSheet.create({
57
+ accessorySlot: {
58
+ overflow: 'hidden',
59
+ backgroundColor: KColors.palette.gray.w25,
60
+ },
61
+ });
@@ -0,0 +1,198 @@
1
+ import { useCallback, useMemo, type RefObject } from 'react';
2
+ import { StyleSheet } from 'react-native';
3
+ import { KContainer, KColors, KImage } from '@droppii/libs';
4
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
5
+ import { PeerType } from '@droppii/openim-rn-client-sdk';
6
+
7
+ import { ChatQuickActions } from '../ChatQuickActions';
8
+ import { ChatLinkPreview } from '../ChatLinkPreview';
9
+ import { ChatCurrentQuotedMessage } from '../ChatCurrentQuotedMessage';
10
+ import { ChatMessageInput } from './ChatMessageInput';
11
+ import { MentionSuggestionList } from './MentionSuggestionList';
12
+ import { AttachmentPreview } from '../../../components/AttachmentPreview';
13
+ import { DEFAULT_QUICK_ACTIONS } from '../constants';
14
+ import { useChatDetailContext } from '../../../context/ChatDetailContext';
15
+ import { useAttachmentMapping } from '../hooks/useAttachmentMapping';
16
+ import type { ActivePanelType } from '../hooks/useChatComposerState';
17
+ import type { useMentionComposer } from '../hooks/useMentionComposer';
18
+ import type { useChatCompose } from '../../../hooks/useChatCompose';
19
+ import type { AttachmentType } from '../../../types/attachment';
20
+
21
+ interface ChatComposerInputAreaProps {
22
+ compose: ReturnType<typeof useChatCompose>;
23
+ mentionComposer: ReturnType<typeof useMentionComposer>;
24
+ activePanelType: ActivePanelType;
25
+ isAttachmentOpen: boolean;
26
+ isInputFocused: boolean;
27
+ isMultilineInput: boolean;
28
+ inputRef: RefObject<any>;
29
+ onToggleAttachment: () => void;
30
+ onToggleSticker: () => void;
31
+ onChangeText: (text?: string) => void;
32
+ onInputFocus: () => void;
33
+ onInputBlur: () => void;
34
+ onInputContentSizeChange: (event: {
35
+ nativeEvent: { contentSize: { height: number } };
36
+ }) => void;
37
+ onOpenBotMenu?: () => void;
38
+ onSend: () => void;
39
+ }
40
+
41
+ // The composer's normal state: quoted message, previews, mention picker and
42
+ // the input row (attachment toggle / input / send).
43
+ export const ChatComposerInputArea = ({
44
+ compose,
45
+ mentionComposer,
46
+ activePanelType,
47
+ isAttachmentOpen,
48
+ isInputFocused,
49
+ isMultilineInput,
50
+ inputRef,
51
+ onToggleAttachment,
52
+ onToggleSticker,
53
+ onChangeText,
54
+ onInputFocus,
55
+ onInputBlur,
56
+ onInputContentSizeChange,
57
+ onOpenBotMenu,
58
+ onSend,
59
+ }: ChatComposerInputAreaProps) => {
60
+ const insets = useSafeAreaInsets();
61
+ const { conversation } = useChatDetailContext();
62
+
63
+ const inputRowStyle = useMemo(
64
+ () => ({ paddingBottom: isAttachmentOpen ? 8 : insets.bottom + 12 }),
65
+ [insets.bottom, isAttachmentOpen]
66
+ );
67
+
68
+ const attachments = useAttachmentMapping({
69
+ selectedImages: compose.selectedImages,
70
+ selectedFiles: compose.selectedFiles,
71
+ });
72
+
73
+ const onRemove = useCallback(
74
+ (type: AttachmentType, data: unknown) => {
75
+ if (type === 'file') return compose.removeFile(data as string);
76
+ return compose.removeImage(data as number);
77
+ },
78
+ [compose]
79
+ );
80
+
81
+ return (
82
+ <KContainer.View background={'#F9F9FA'}>
83
+ <ChatCurrentQuotedMessage />
84
+ <KContainer.VisibleView visible={conversation?.peerType === PeerType.Bot}>
85
+ <ChatQuickActions
86
+ visible
87
+ actions={DEFAULT_QUICK_ACTIONS}
88
+ onActionPress={(action) =>
89
+ console.log('[ChatComposer] quick action:', action.id)
90
+ }
91
+ />
92
+ </KContainer.VisibleView>
93
+ {compose.showLinkPreview && (
94
+ <ChatLinkPreview
95
+ metadata={compose.urlMetadata}
96
+ isLoading={compose.isMetadataLoading}
97
+ onDismiss={compose.dismissPreview}
98
+ />
99
+ )}
100
+
101
+ <AttachmentPreview
102
+ visible={compose.isImagePreviewVisible || compose.isFilePreviewVisible}
103
+ attachments={attachments}
104
+ onRemove={onRemove}
105
+ />
106
+
107
+ <KContainer.View style={styles.inputRowWrapper}>
108
+ <KContainer.VisibleView visible={mentionComposer.isPanelOpen}>
109
+ <KContainer.View style={styles.mentionPanel}>
110
+ <MentionSuggestionList
111
+ suggestions={mentionComposer.suggestions}
112
+ isSearching={mentionComposer.isSearching}
113
+ isLoadingMore={mentionComposer.isLoadingMore}
114
+ hasMore={mentionComposer.hasMore}
115
+ onLoadMore={mentionComposer.loadMore}
116
+ onSelect={mentionComposer.selectSuggestion}
117
+ />
118
+ </KContainer.View>
119
+ </KContainer.VisibleView>
120
+
121
+ <KContainer.View
122
+ row
123
+ alignItems
124
+ paddingH="0.75rem"
125
+ paddingT="0.75rem"
126
+ paddingB="0.75rem"
127
+ style={inputRowStyle}
128
+ >
129
+ <KContainer.Touchable onPress={onToggleAttachment} center br="round">
130
+ <KImage.VectorIcons
131
+ name={
132
+ activePanelType === 'attachment'
133
+ ? 'close-circle-b'
134
+ : 'plus-circle-o'
135
+ }
136
+ size={24}
137
+ color={
138
+ activePanelType === 'attachment'
139
+ ? KColors.palette.primary.w400
140
+ : KColors.palette.gray.w600
141
+ }
142
+ />
143
+ </KContainer.Touchable>
144
+
145
+ <ChatMessageInput
146
+ value={compose.text}
147
+ placeholder="Nhập tin nhắn..."
148
+ onChangeText={onChangeText}
149
+ onFocus={onInputFocus}
150
+ onBlur={onInputBlur}
151
+ onContentSizeChange={onInputContentSizeChange}
152
+ onSelectionChange={mentionComposer.handleSelectionChange}
153
+ mentions={mentionComposer.mentions}
154
+ isInputFocused={isInputFocused}
155
+ isMultilineInput={isMultilineInput}
156
+ inputRef={inputRef}
157
+ onEmojiPress={onToggleSticker}
158
+ onSuggestPress={onOpenBotMenu}
159
+ activePanelType={activePanelType}
160
+ />
161
+
162
+ <KContainer.Touchable
163
+ onPress={compose.isUploading ? undefined : onSend}
164
+ pointerEvents={compose.isUploading ? 'none' : 'auto'}
165
+ width={40}
166
+ height={40}
167
+ center
168
+ br="round"
169
+ background={
170
+ compose.isUploading
171
+ ? KColors.palette.primary.w100
172
+ : KColors.palette.primary.w400
173
+ }
174
+ marginL="0.75rem"
175
+ opacity={compose.isUploading ? 0.25 : 1}
176
+ >
177
+ <KImage.VectorIcons name="send-b" size={20} color={KColors.white} />
178
+ </KContainer.Touchable>
179
+ </KContainer.View>
180
+ </KContainer.View>
181
+ </KContainer.View>
182
+ );
183
+ };
184
+
185
+ const styles = StyleSheet.create({
186
+ inputRowWrapper: {
187
+ position: 'relative',
188
+ },
189
+ // Floats above the input row without going through the accessorySlot
190
+ // (attachment/sticker) render path — that slot intentionally takes over
191
+ // the keyboard area, which the mention picker must not do (finding #5).
192
+ mentionPanel: {
193
+ position: 'absolute',
194
+ left: 0,
195
+ right: 0,
196
+ bottom: '100%',
197
+ },
198
+ });