@droppii-org/chat-mobile 0.2.87 → 0.2.89

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 (98) hide show
  1. package/lib/module/components/ThreadCard/ThreadCard.js +0 -1
  2. package/lib/module/components/ThreadCard/ThreadCard.js.map +1 -1
  3. package/lib/module/components/messages/LinkifiedText.js +34 -0
  4. package/lib/module/components/messages/LinkifiedText.js.map +1 -0
  5. package/lib/module/components/messages/quotedMessage/index.js +14 -2
  6. package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
  7. package/lib/module/components/messages/textMessage/index.js +15 -22
  8. package/lib/module/components/messages/textMessage/index.js.map +1 -1
  9. package/lib/module/core/useChatListener.js +2 -2
  10. package/lib/module/core/useChatListener.js.map +1 -1
  11. package/lib/module/hooks/contacts/useSearchContacts.js +3 -2
  12. package/lib/module/hooks/contacts/useSearchContacts.js.map +1 -1
  13. package/lib/module/hooks/groups/useFetchPublicGroup.js +3 -2
  14. package/lib/module/hooks/groups/useFetchPublicGroup.js.map +1 -1
  15. package/lib/module/hooks/message/useMessages.js +6 -0
  16. package/lib/module/hooks/message/useMessages.js.map +1 -1
  17. package/lib/module/hooks/message/useSearchMessage.js +3 -2
  18. package/lib/module/hooks/message/useSearchMessage.js.map +1 -1
  19. package/lib/module/hooks/message/useSendMessage.js +10 -3
  20. package/lib/module/hooks/message/useSendMessage.js.map +1 -1
  21. package/lib/module/screens/ResourceCenter/ResourceCenterTabScene.js.map +1 -1
  22. package/lib/module/screens/SearchConversation/SearchAllResult.js +29 -22
  23. package/lib/module/screens/SearchConversation/SearchAllResult.js.map +1 -1
  24. package/lib/module/screens/SearchConversation/SearchCommunityResult.js +3 -2
  25. package/lib/module/screens/SearchConversation/SearchCommunityResult.js.map +1 -1
  26. package/lib/module/screens/SearchConversation/SearchContactsResult.js +4 -2
  27. package/lib/module/screens/SearchConversation/SearchContactsResult.js.map +1 -1
  28. package/lib/module/screens/SearchConversation/SearchMessagesResult.js +4 -2
  29. package/lib/module/screens/SearchConversation/SearchMessagesResult.js.map +1 -1
  30. package/lib/module/screens/SearchConversation/TabResult.js +10 -5
  31. package/lib/module/screens/SearchConversation/TabResult.js.map +1 -1
  32. package/lib/module/screens/chat-detail/ChatDetail.js +2 -1
  33. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  34. package/lib/module/screens/chat-detail/ChatListLegend.js +259 -117
  35. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  36. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +6 -5
  37. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  38. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +36 -4
  39. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
  40. package/lib/module/services/resourceCenter.js +45 -22
  41. package/lib/module/services/resourceCenter.js.map +1 -1
  42. package/lib/module/store/message.js +8 -1
  43. package/lib/module/store/message.js.map +1 -1
  44. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  45. package/lib/typescript/src/components/messages/LinkifiedText.d.ts +11 -0
  46. package/lib/typescript/src/components/messages/LinkifiedText.d.ts.map +1 -0
  47. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts +1 -0
  48. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
  50. package/lib/typescript/src/hooks/contacts/useSearchContacts.d.ts +2 -1
  51. package/lib/typescript/src/hooks/contacts/useSearchContacts.d.ts.map +1 -1
  52. package/lib/typescript/src/hooks/groups/useFetchPublicGroup.d.ts +1 -1
  53. package/lib/typescript/src/hooks/groups/useFetchPublicGroup.d.ts.map +1 -1
  54. package/lib/typescript/src/hooks/message/useMessages.d.ts.map +1 -1
  55. package/lib/typescript/src/hooks/message/useSearchMessage.d.ts +2 -1
  56. package/lib/typescript/src/hooks/message/useSearchMessage.d.ts.map +1 -1
  57. package/lib/typescript/src/hooks/message/useSendMessage.d.ts.map +1 -1
  58. package/lib/typescript/src/screens/ResourceCenter/ResourceCenterTabScene.d.ts.map +1 -1
  59. package/lib/typescript/src/screens/SearchConversation/SearchAllResult.d.ts +2 -1
  60. package/lib/typescript/src/screens/SearchConversation/SearchAllResult.d.ts.map +1 -1
  61. package/lib/typescript/src/screens/SearchConversation/SearchCommunityResult.d.ts +2 -1
  62. package/lib/typescript/src/screens/SearchConversation/SearchCommunityResult.d.ts.map +1 -1
  63. package/lib/typescript/src/screens/SearchConversation/SearchContactsResult.d.ts +2 -1
  64. package/lib/typescript/src/screens/SearchConversation/SearchContactsResult.d.ts.map +1 -1
  65. package/lib/typescript/src/screens/SearchConversation/SearchMessagesResult.d.ts +2 -1
  66. package/lib/typescript/src/screens/SearchConversation/SearchMessagesResult.d.ts.map +1 -1
  67. package/lib/typescript/src/screens/SearchConversation/TabResult.d.ts.map +1 -1
  68. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  69. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +1 -1
  70. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  71. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  72. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts +2 -0
  73. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
  74. package/lib/typescript/src/services/resourceCenter.d.ts.map +1 -1
  75. package/lib/typescript/src/store/message.d.ts.map +1 -1
  76. package/package.json +3 -3
  77. package/src/components/ThreadCard/ThreadCard.tsx +0 -2
  78. package/src/components/messages/LinkifiedText.tsx +37 -0
  79. package/src/components/messages/quotedMessage/index.tsx +18 -2
  80. package/src/components/messages/textMessage/index.tsx +18 -27
  81. package/src/core/useChatListener.ts +2 -2
  82. package/src/hooks/contacts/useSearchContacts.ts +3 -1
  83. package/src/hooks/groups/useFetchPublicGroup.ts +2 -1
  84. package/src/hooks/message/useMessages.ts +6 -0
  85. package/src/hooks/message/useSearchMessage.ts +3 -1
  86. package/src/hooks/message/useSendMessage.ts +7 -0
  87. package/src/screens/ResourceCenter/ResourceCenterTabScene.tsx +0 -1
  88. package/src/screens/SearchConversation/SearchAllResult.tsx +25 -15
  89. package/src/screens/SearchConversation/SearchCommunityResult.tsx +7 -2
  90. package/src/screens/SearchConversation/SearchContactsResult.tsx +7 -1
  91. package/src/screens/SearchConversation/SearchMessagesResult.tsx +7 -1
  92. package/src/screens/SearchConversation/TabResult.tsx +7 -0
  93. package/src/screens/chat-detail/ChatDetail.tsx +1 -0
  94. package/src/screens/chat-detail/ChatListLegend.tsx +292 -130
  95. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +7 -5
  96. package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +46 -2
  97. package/src/services/resourceCenter.ts +77 -36
  98. package/src/store/message.ts +7 -1
@@ -23,7 +23,11 @@ import Animated, {
23
23
  type SharedValue,
24
24
  } from 'react-native-reanimated';
25
25
  import { KeyboardAwareLegendList } from '@legendapp/list/keyboard';
26
- import { LegendList, type LegendListRef } from '@legendapp/list/react-native';
26
+ import { KeyboardEvents } from 'react-native-keyboard-controller';
27
+ import {
28
+ type LegendListRef,
29
+ type MaintainVisibleContentPositionConfig,
30
+ } from '@legendapp/list/react-native';
27
31
  import {
28
32
  KContainer,
29
33
  KColors,
@@ -72,16 +76,17 @@ import ChatImageDimens, {
72
76
  const ROW_MARGIN_CLUSTER_END = KSpacingValue['0.5rem'];
73
77
  const ROW_MARGIN_IN_CLUSTER = KSpacingValue['0.25rem'];
74
78
 
79
+ // Tags every __DEV__ diagnostic log below with the platform, so logs from an
80
+ // iOS and an Android run can be told apart when compared side by side.
81
+ const LOG_TAG = `[ChatListLegend:${Platform.OS}]`;
82
+
83
+ const BUTTON_FADE_CONFIG = { duration: 200, easing: Easing.out(Easing.ease) };
84
+
75
85
  const getRowMarginBottom = (position: GroupPosition): number =>
76
86
  position === 'last' || position === 'solo'
77
87
  ? ROW_MARGIN_CLUSTER_END
78
88
  : ROW_MARGIN_IN_CLUSTER;
79
89
 
80
- // KeyboardAwareLegendList auto scroll when modal opened
81
- // TODO: Convert toolbox to absolute view
82
- const ListComponent =
83
- Platform.OS === 'ios' ? KeyboardAwareLegendList : LegendList;
84
-
85
90
  interface ChatListLegendInternalProps extends ChatListProps {
86
91
  listRef: RefObject<LegendListRef | null>;
87
92
  freeze: SharedValue<boolean>;
@@ -101,7 +106,6 @@ export const ChatListLegend = memo(
101
106
  moreOldLoading,
102
107
  hasMoreOld,
103
108
  onLoadMoreNew,
104
- moreNewLoading,
105
109
  hasMoreNew,
106
110
  onMediaPress,
107
111
  onPressUrl,
@@ -138,17 +142,58 @@ export const ChatListLegend = memo(
138
142
  const last = messages[messages.length - 1];
139
143
  return last?.clientMsgID || last?.serverMsgID || null;
140
144
  }, [messages]);
145
+ // Stopgap for the keyboard-open blank-gap bug (unresolved upstream —
146
+ // see project_chatlistlegend_scroll_debugging memory): below this
147
+ // threshold, disable `alignItemsAtEnd` and let plain CSS `flex-end`
148
+ // anchor short content to the bottom instead. Known trade-off (same
149
+ // memory, bug #17/#19): disabling `alignItemsAtEnd` removes the extra
150
+ // scrollable room `clampScrollOffset` needs, so sending a message while
151
+ // the keyboard is open on one of these same short conversations can
152
+ // land the new message away from the composer instead of scrolling to
153
+ // it — verify that scenario specifically after any change here.
154
+ const isVeryShortConversation = messages.length < 15;
141
155
  const { mutate: runFlow } = useRunFlow();
142
156
  const { mutate: resetFlow } = useResetFlow();
143
157
 
144
158
  const buttonOpacity = useSharedValue(0);
145
159
  const isAtBottomRef = useRef(true);
146
160
  const scrollDebounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
161
+ // Diagnostic only (__DEV__): the keyboard-open bottom inset `useChatKeyboard`
162
+ // computes internally (raw keyboard height minus `keyboardOffset`, matching
163
+ // how much the composer's KeyboardStickyView actually rises). Compared
164
+ // against the list's real, native-reported `contentInset.bottom` in
165
+ // `handleScroll` below to auto-detect the known issue where that native
166
+ // inset silently fails to apply (confirmed via manual trace: contentOffset
167
+ // scrolls correctly but contentInset stays {bottom:0} on this RN/Reanimated/
168
+ // Old-Architecture combo, leaving a blank gap past the real content end).
169
+ const expectedKeyboardInsetRef = useRef(0);
147
170
  const rowRefsMap = useRef<Map<string, View | null>>(new Map());
148
- // Flips true once LegendList's onLoad fires (initial layout + initial
149
- // scroll both settled) — before that, onStartReached/onEndReached can
150
- // fire purely from resting scroll position, not a real user gesture.
151
- const hasSettledRef = useRef(false);
171
+ // Set on a real touch-drag (`onScrollBeginDrag`), before onStartReached/
172
+ // onEndReached's threshold could fire. `onMomentumScrollBegin` was tried
173
+ // and reverted: it only fires after the finger lifts, so the first
174
+ // gesture's mid-drag threshold-crossing beats it, needing a wasted
175
+ // second gesture to actually unlock. See
176
+ // project_chatlistlegend_scroll_debugging for the full history.
177
+ const hasUserScrolledRef = useRef(false);
178
+ // Old Architecture defers recomputing `alignItemsAtEndPadding` when
179
+ // content shrinks while `initialScrollAtEnd` is active. `estimatedItemSize`
180
+ // below is a deliberate lower bound to mostly avoid that; this ref
181
+ // backstops the rest via one forced `scrollToEnd` on mount (`handleListLoad`).
182
+ const hasCorrectedInitialPaddingRef = useRef(false);
183
+ // `messages` starts empty, so `lastMessageId`'s first real value looks
184
+ // identical to a new message — tracking the previous id (not a one-shot
185
+ // flag) lets the effect below tell the two apart.
186
+ const previousLastMessageIdRef = useRef<string | null>(null);
187
+ // Read via ref (not as a dependency) by the `lastMessageId` effect below —
188
+ // a loading-state flip alone shouldn't re-run it.
189
+ const moreOldLoadingRef = useRef(moreOldLoading);
190
+ moreOldLoadingRef.current = moreOldLoading;
191
+ // Read via ref (not as a dependency) by the ScrollToMessage subscription
192
+ // below — the whole array changes on every new message and every old-
193
+ // history page, which would otherwise tear down and re-add both
194
+ // DeviceEventEmitter listeners on every one of those.
195
+ const messagesRef = useRef(messages);
196
+ messagesRef.current = messages;
152
197
  const [showBadge, setShowBadge] = useState(false);
153
198
  const [highlightedId, setHighlightedId] = useState<string | null>(null);
154
199
  const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
@@ -190,7 +235,7 @@ export const ChatListLegend = memo(
190
235
  }, []);
191
236
 
192
237
  const renderItem = useCallback(
193
- ({ item, index }: any) => {
238
+ ({ item, index }: { item: MessageItem; index: number }) => {
194
239
  try {
195
240
  if (!item) return null;
196
241
 
@@ -310,33 +355,21 @@ export const ChatListLegend = memo(
310
355
  ]
311
356
  );
312
357
 
313
- // const maintainScrollConfig = useMemo(
314
- // () => ({
315
- // animated: false,
316
- // on: {
317
- // dataChange: !moreOldLoading,
318
- // layout: !moreOldLoading,
319
- // // Must be disabled while prepending older history; otherwise re-measuring
320
- // // the freshly prepended items snaps the list back to the bottom.
321
- // itemLayout: !moreOldLoading,
322
- // },
323
- // }),
324
- // [moreOldLoading]
325
- // );
326
-
327
- // Stable config so the native maintainVisibleContentPosition prop isn't toggled
328
- // mid-prepend (toggling forces an extra layout recompute). `data` anchoring keeps
329
- // the viewport pinned to the item being read when older messages are added on top.
330
- const maintainVisiblePositionConfig = useMemo(() => ({ data: true }), []);
331
-
332
- // Buckets averaging (`averageSizes[itemType]`, internal to LegendList) per
333
- // content type, so a batch of images landing early doesn't pollute the
334
- // estimate used for still-unmeasured text rows and vice versa. Items
335
- // rendered via `customMessageItemFactory` (host apps can override ANY
336
- // content type with an arbitrary custom node — see `LegendChatMessage`'s
337
- // `customNode` short-circuit, checked before its own contentType switch)
338
- // get their own bucket per content type, so a custom node's real measured
339
- // height never pollutes the native component's average for that same type.
358
+ // `maintainVisibleContentPosition` anchors a prepend to whatever native
359
+ // view sits at the top. Default `drawDistance` (250px) doesn't survive a
360
+ // ~1-1.5s history fetch if the user keeps scrolling away — the anchor
361
+ // unmounts and the native compensation silently drops (the "list drifts
362
+ // after loading old history" bug). `drawDistance` below is raised to
363
+ // outlast a realistic scroll during that wait (measured ~300-350px).
364
+ // `recycleItems` is safe again since @legendapp/list 3.3.7 fixed
365
+ // recycled rows flashing wrong content on prepend.
366
+ const maintainVisiblePositionConfig: MaintainVisibleContentPositionConfig =
367
+ useMemo(() => ({ data: true }), []);
368
+
369
+ // Buckets LegendList's size-averaging per content type so a batch of
370
+ // images landing early doesn't pollute the estimate for still-unmeasured
371
+ // text rows. `customMessageItemFactory` nodes get their own bucket per
372
+ // type too, so a custom node's real height doesn't pollute that type's average.
340
373
  const getItemType = useCallback(
341
374
  (item: MessageItem) => {
342
375
  const isCustom = !!customMessageItemFactory?.(item);
@@ -345,14 +378,12 @@ export const ChatListLegend = memo(
345
378
  [customMessageItemFactory]
346
379
  );
347
380
 
348
- // Exact size for content types that are knowable ahead of render — see
349
- // `plans/260728-1400-chatlist-legend-fixed-item-size/plan.md`. Returns
350
- // `undefined` (defer to dynamic measurement) for anything whose size can't
351
- // be predicted, AND for any item carrying extra chrome this function
352
- // doesn't account for (day-divider above it, trailing flow-input-buttons
353
- // row) — `getFixedItemSize` describes the ENTIRE item LegendList renders,
354
- // and once it resolves a number that item is never measured again, so
355
- // guessing wrong here is a permanent regression, not just an approximation.
381
+ // Exact size for content types knowable ahead of render (see
382
+ // `plans/260728-1400-chatlist-legend-fixed-item-size/plan.md`). Returns
383
+ // `undefined` (defer to measurement) for anything unpredictable, or
384
+ // carrying chrome this function doesn't account for (day-divider, flow-
385
+ // input-buttons row) — a resolved number is never re-measured, so
386
+ // guessing wrong here is permanent, not just approximate.
356
387
  const getFixedItemSize = useCallback(
357
388
  (item: MessageItem, index: number): number | undefined => {
358
389
  const message = item;
@@ -432,13 +463,10 @@ export const ChatListLegend = memo(
432
463
  ]
433
464
  );
434
465
 
435
- // LegendList memoises each rendered item on [itemKey, item, extraData], so
436
- // an item whose own object is unchanged will not re-render. Cluster
437
- // position depends on a message's NEIGHBOURS, which can change while the
438
- // message itself does not — prepending older history is enough to turn a
439
- // 'first' into a 'middle'. messageDataMap is memoised on exactly the inputs
440
- // that can move those boundaries, so including it here is what keeps a
441
- // stale tail from being left behind.
466
+ // LegendList only re-renders an item when [itemKey, item, extraData]
467
+ // changes. Cluster position depends on a message's NEIGHBOURS, which can
468
+ // shift (prepending history can turn a 'first' into a 'middle') without
469
+ // the message itself changing — messageDataMap here is what catches that.
442
470
  const listExtraData = useMemo(
443
471
  () => ({
444
472
  messageDataMap,
@@ -457,59 +485,89 @@ export const ChatListLegend = memo(
457
485
  );
458
486
 
459
487
  const handleStartReached = useCallback(() => {
460
- const willLoad = hasMoreOld && !moreOldLoading;
461
488
  if (__DEV__) {
462
- console.log(
463
- willLoad && !hasSettledRef.current
464
- ? '[LegendList] ⚠️ onStartReached SPURIOUS mount-time load'
465
- : '[LegendList] onStartReached triggered',
466
- {
467
- conversationId,
468
- messagesLength: messages.length,
469
- firstMessageId:
470
- messages[0]?.clientMsgID || messages[0]?.serverMsgID,
471
- hasMoreOld,
472
- moreOldLoading,
473
- willLoad,
474
- duringInitialSettle: !hasSettledRef.current,
475
- }
476
- );
489
+ console.log(`${LOG_TAG} onStartReached fired`, {
490
+ conversationId,
491
+ hasUserScrolled: hasUserScrolledRef.current,
492
+ hasMoreOld,
493
+ moreOldLoading: useMessageStore.getState().moreOldLoading,
494
+ });
495
+ }
496
+ if (!hasUserScrolledRef.current) {
497
+ const wouldHaveLoaded =
498
+ __DEV__ && hasMoreOld && !useMessageStore.getState().moreOldLoading;
499
+ if (wouldHaveLoaded) {
500
+ console.warn(
501
+ `${LOG_TAG} onStartReached fired before user scroll — suppressed`,
502
+ { conversationId, hasMoreOld }
503
+ );
504
+ }
505
+ return;
477
506
  }
478
- if (willLoad) {
507
+ if (hasMoreOld && !useMessageStore.getState().moreOldLoading) {
479
508
  onLoadMoreOld?.(true);
480
509
  }
481
- }, [conversationId, messages, hasMoreOld, moreOldLoading, onLoadMoreOld]);
510
+ }, [conversationId, hasMoreOld, onLoadMoreOld]);
482
511
 
483
512
  const handleEndReached = useCallback(() => {
484
- const willLoad = hasMoreNew && !moreNewLoading;
485
- if (__DEV__) {
486
- console.log(
487
- willLoad && !hasSettledRef.current
488
- ? '[LegendList] ⚠️ onEndReached SPURIOUS mount-time load'
489
- : '[LegendList] onEndReached triggered',
490
- {
491
- conversationId,
492
- messagesLength: messages.length,
493
- lastMessageId: messages[messages.length - 1]?.clientMsgID,
494
- hasMoreNew,
495
- moreNewLoading,
496
- willLoad,
497
- duringInitialSettle: !hasSettledRef.current,
498
- }
499
- );
513
+ if (!hasUserScrolledRef.current) {
514
+ const wouldHaveLoaded =
515
+ __DEV__ && hasMoreNew && !useMessageStore.getState().moreNewLoading;
516
+ if (wouldHaveLoaded) {
517
+ console.warn(
518
+ `${LOG_TAG} onEndReached fired before user scroll — suppressed`,
519
+ { conversationId, hasMoreNew }
520
+ );
521
+ }
522
+ return;
500
523
  }
501
- if (willLoad) {
524
+ if (hasMoreNew && !useMessageStore.getState().moreNewLoading) {
525
+ if (__DEV__) console.log(`${LOG_TAG} handleEndReached`);
502
526
  onLoadMoreNew?.(true);
503
527
  }
504
- }, [conversationId, messages, hasMoreNew, moreNewLoading, onLoadMoreNew]);
528
+ }, [conversationId, hasMoreNew, onLoadMoreNew]);
529
+
530
+ const handleScrollBeginDrag = useCallback(() => {
531
+ hasUserScrolledRef.current = true;
532
+ }, []);
505
533
 
506
534
  const handleScroll = useCallback(
507
535
  (e: any) => {
508
- const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
536
+ const { contentOffset, contentSize, layoutMeasurement, contentInset } =
537
+ e.nativeEvent;
509
538
  const isAtBottom =
510
539
  contentOffset.y + layoutMeasurement.height >=
511
540
  contentSize.height - 100;
512
541
 
542
+ if (__DEV__) {
543
+ const expected = expectedKeyboardInsetRef.current;
544
+ const actual = contentInset?.bottom ?? 0;
545
+ // `contentInset` failing to apply is a constant, systemic mismatch
546
+ // (present regardless of conversation length) — but it only shows
547
+ // up as a visible gap once the scroll position has actually moved
548
+ // past the real end of content. Gate on that so the warning fires
549
+ // only when the bug is actually visible, not on every keyboard
550
+ // open in every conversation.
551
+ const scrolledPastRealContentEnd =
552
+ contentOffset.y + layoutMeasurement.height > contentSize.height;
553
+ if (
554
+ expected > 0 &&
555
+ Math.abs(actual - expected) > 5 &&
556
+ scrolledPastRealContentEnd
557
+ ) {
558
+ console.warn(
559
+ `${LOG_TAG} keyboard contentInset mismatch — expected bottom inset ~${expected} (keyboard open) but native reports ${actual}, and scroll position is past the real content end. This reproduces the known "blank gap" issue: contentOffset scrolls correctly but contentInset silently fails to apply.`,
560
+ {
561
+ expected,
562
+ actual,
563
+ contentOffsetY: contentOffset.y,
564
+ contentSizeHeight: contentSize.height,
565
+ layoutHeight: layoutMeasurement.height,
566
+ }
567
+ );
568
+ }
569
+ }
570
+
513
571
  // Debounce animation updates
514
572
  if (scrollDebounceRef.current) {
515
573
  clearTimeout(scrollDebounceRef.current);
@@ -518,10 +576,10 @@ export const ChatListLegend = memo(
518
576
  scrollDebounceRef.current = setTimeout(() => {
519
577
  if (isAtBottomRef.current !== isAtBottom) {
520
578
  isAtBottomRef.current = isAtBottom;
521
- buttonOpacity.value = withTiming(isAtBottom ? 0 : 1, {
522
- duration: 200,
523
- easing: Easing.out(Easing.ease),
524
- });
579
+ buttonOpacity.value = withTiming(
580
+ isAtBottom ? 0 : 1,
581
+ BUTTON_FADE_CONFIG
582
+ );
525
583
  }
526
584
  if (isAtBottom) {
527
585
  ChatMessageAPI.markConversationAsRead(conversationId || '');
@@ -534,15 +592,17 @@ export const ChatListLegend = memo(
534
592
  const handleScrollToBottom = useCallback(() => {
535
593
  if (hasMoreNew) {
536
594
  onLoadMoreOld?.(false);
537
- } else {
538
- scrollMessageToEnd({ animated: true, closeKeyboard: false });
539
- buttonOpacity.value = withTiming(0, {
540
- duration: 200,
541
- easing: Easing.out(Easing.ease),
542
- });
543
- isAtBottomRef.current = true;
544
- setShowBadge(false);
595
+ return;
545
596
  }
597
+ // `ChatEvent.ScrollToBottom` fires after every self-sent message, but
598
+ // the `lastMessageId` effect below already animates to it — re-scrolling
599
+ // here raced it and flickered. Only act when not already at bottom
600
+ // (FAB tap, or a send while scrolled away).
601
+ if (isAtBottomRef.current) return;
602
+ scrollMessageToEnd({ animated: true, closeKeyboard: false });
603
+ buttonOpacity.value = withTiming(0, BUTTON_FADE_CONFIG);
604
+ isAtBottomRef.current = true;
605
+ setShowBadge(false);
546
606
  }, [hasMoreNew, onLoadMoreOld, scrollMessageToEnd, buttonOpacity]);
547
607
 
548
608
  const animatedButtonStyle = useAnimatedStyle(() => ({
@@ -559,14 +619,104 @@ export const ChatListLegend = memo(
559
619
  };
560
620
  }, []);
561
621
 
562
- // Debug-only: this component can stay mounted across a conversationId
563
- // change (e.g. navigation.replace reusing the same screen instance), so
564
- // hasSettledRef must reset per-conversation for the spurious-load log
565
- // above to stay accurate for the newly opened conversation.
622
+ // Diagnostic only (__DEV__) — keeps `expectedKeyboardInsetRef` in sync so
623
+ // `handleScroll` can detect the known contentInset-not-applying issue.
624
+ // JS-thread only (no worklet involved), so this carries none of the
625
+ // reentrant-lock crash risk `useKeyboardHandler`'s own worklet hit.
566
626
  useEffect(() => {
567
- hasSettledRef.current = false;
627
+ if (!__DEV__) return;
628
+ const willShow = KeyboardEvents.addListener('keyboardWillShow', (e) => {
629
+ expectedKeyboardInsetRef.current = Math.max(
630
+ e.height - keyboardOffset,
631
+ 0
632
+ );
633
+ });
634
+ const willHide = KeyboardEvents.addListener('keyboardWillHide', () => {
635
+ expectedKeyboardInsetRef.current = 0;
636
+ });
637
+ return () => {
638
+ willShow.remove();
639
+ willHide.remove();
640
+ };
641
+ }, [keyboardOffset]);
642
+
643
+ // This component can stay mounted across a conversationId change (e.g.
644
+ // navigation.replace reusing the same screen instance), so this ref must
645
+ // reset per-conversation — otherwise a leftover "user scrolled" flag from
646
+ // the previous conversation would wrongly let load-more fire on the
647
+ // newly opened one before any real scroll there.
648
+ useEffect(() => {
649
+ hasUserScrolledRef.current = false;
650
+ hasCorrectedInitialPaddingRef.current = false;
651
+ previousLastMessageIdRef.current = null;
568
652
  }, [conversationId]);
569
653
 
654
+ // Single source of truth for "scroll to bottom on a new message" (self-
655
+ // sent or received). Skipped while prepending old history or scrolled
656
+ // away from the bottom. `moreOldLoading` is read via ref, not a
657
+ // dependency, so its own start/finish toggle doesn't re-run this.
658
+ useEffect(() => {
659
+ const previousLastMessageId = previousLastMessageIdRef.current;
660
+ previousLastMessageIdRef.current = lastMessageId;
661
+
662
+ // null → a real id is the initial fetch landing (initialScrollAtEnd +
663
+ // handleListLoad's job), not a new message; a real id → a different
664
+ // one is.
665
+ const shouldFire =
666
+ previousLastMessageId !== null &&
667
+ !!lastMessageId &&
668
+ !moreOldLoadingRef.current &&
669
+ isAtBottomRef.current;
670
+
671
+ if (__DEV__) {
672
+ console.log(
673
+ `${LOG_TAG} lastMessageId effect — ${shouldFire ? 'firing animated scroll' : 'skipped'}`,
674
+ {
675
+ conversationId,
676
+ lastMessageId,
677
+ initialPopulation: previousLastMessageId === null,
678
+ moreOldLoading: moreOldLoadingRef.current,
679
+ isAtBottom: isAtBottomRef.current,
680
+ }
681
+ );
682
+ }
683
+
684
+ if (!shouldFire) return;
685
+
686
+ scrollMessageToEnd({ animated: true, closeKeyboard: false });
687
+ }, [lastMessageId, scrollMessageToEnd, conversationId]);
688
+
689
+ const handleListLoad = useCallback(
690
+ (info: { elapsedTimeInMs: number }) => {
691
+ if (hasCorrectedInitialPaddingRef.current) return;
692
+ hasCorrectedInitialPaddingRef.current = true;
693
+
694
+ // Diagnostic: how long bootstrap + the flush below each take.
695
+ if (__DEV__) {
696
+ console.log(`${LOG_TAG} onLoad (bootstrap settle done)`, {
697
+ conversationId,
698
+ messagesLength: messages.length,
699
+ elapsedTimeInMs: info.elapsedTimeInMs,
700
+ });
701
+ }
702
+
703
+ const startedAt = __DEV__ ? Date.now() : 0;
704
+ const flushPromise = scrollMessageToEnd({
705
+ animated: false,
706
+ closeKeyboard: false,
707
+ });
708
+ if (__DEV__) {
709
+ flushPromise.then(() => {
710
+ console.log(`${LOG_TAG} handleListLoad flush resolved`, {
711
+ conversationId,
712
+ tookMs: Date.now() - startedAt,
713
+ });
714
+ });
715
+ }
716
+ },
717
+ [scrollMessageToEnd, conversationId, messages.length]
718
+ );
719
+
570
720
  useEffect(() => {
571
721
  const bottomSubscription = DeviceEventEmitter.addListener(
572
722
  ChatEvent.ScrollToBottom,
@@ -576,7 +726,7 @@ export const ChatListLegend = memo(
576
726
  const subscription = DeviceEventEmitter.addListener(
577
727
  ChatEvent.ScrollToMessage,
578
728
  ({ clientMessageId }: ScrollToMessagePayload) => {
579
- const index = messages.findIndex(
729
+ const index = messagesRef.current.findIndex(
580
730
  (m) => m.clientMsgID === clientMessageId
581
731
  );
582
732
 
@@ -621,11 +771,9 @@ export const ChatListLegend = memo(
621
771
  subscription.remove();
622
772
  };
623
773
  }, [
624
- messages,
625
774
  applicationType,
626
775
  searchMessages,
627
776
  conversationId,
628
- buttonOpacity,
629
777
  handleScrollToBottom,
630
778
  listRef,
631
779
  ]);
@@ -638,46 +786,57 @@ export const ChatListLegend = memo(
638
786
  );
639
787
  }
640
788
 
641
- console.log('messages length: ', +messages.length);
642
789
  return (
643
790
  <KContainer.View flex background={KColors.white} style={styles.container}>
644
- <ListComponent
791
+ <KeyboardAwareLegendList
645
792
  ref={listRef}
646
793
  data={messages}
647
794
  renderItem={renderItem}
648
- estimatedItemSize={200}
795
+ // Deliberate lower bound (not a best-guess average) — see the
796
+ // comment above `hasCorrectedInitialPaddingRef`. A single-line
797
+ // bubble floors out around 38-44px, so this stays safely under it.
798
+ estimatedItemSize={36}
649
799
  getItemType={getItemType}
650
800
  getFixedItemSize={getFixedItemSize}
651
801
  keyExtractor={(item) => String(item.clientMsgID || item.serverMsgID)}
652
- recycleItems={false}
802
+ recycleItems
653
803
  extraData={listExtraData}
654
- alignItemsAtEnd
804
+ alignItemsAtEnd={!isVeryShortConversation}
805
+ alwaysRender={{
806
+ top: 10,
807
+ }}
808
+ maintainScrollAtEnd
655
809
  maintainVisibleContentPosition={maintainVisiblePositionConfig}
810
+ // See the comment above `maintainVisiblePositionConfig` — this is
811
+ // what keeps a history prepend's anchor item mounted through a slow
812
+ // fetch instead of the default 250px window losing it.
813
+ drawDistance={800}
656
814
  freeze={freeze}
657
815
  keyboardOffset={keyboardOffset}
658
816
  scrollEventThrottle={16}
659
817
  onScroll={handleScroll}
660
- contentContainerStyle={styles.content}
818
+ onScrollBeginDrag={handleScrollBeginDrag}
819
+ onLoad={handleListLoad}
820
+ contentContainerStyle={
821
+ isVeryShortConversation
822
+ ? [styles.content, styles.contentBottomAnchored]
823
+ : styles.content
824
+ }
661
825
  scrollIndicatorInsets={{ right: 1 }}
662
826
  ListHeaderComponent={
663
827
  <LegendChatLoadEarlier isLoading={moreOldLoading} />
664
828
  }
665
- onLoad={(info: { elapsedTimeInMs: number }) => {
666
- hasSettledRef.current = true;
667
- if (__DEV__) {
668
- console.log('[LegendList] onLoad (initial settle done)', {
669
- conversationId,
670
- messagesLength: messages.length,
671
- elapsedTimeInMs: info.elapsedTimeInMs,
672
- });
673
- }
674
- }}
675
829
  onStartReached={handleStartReached}
676
- onStartReachedThreshold={0.5}
830
+ // LegendList only re-fires onStartReached after scrolling away from
831
+ // the threshold by 1.3x and back. At 0.5 that distance exceeded a
832
+ // short conversation's whole scrollable range, so the mount-time
833
+ // firing we suppress in handleStartReached "used up" the only
834
+ // trigger. 0.3 keeps that reset distance safely under a
835
+ // ~1.5-screen conversation; shorter ones can still hit it.
836
+ onStartReachedThreshold={0.2}
677
837
  onEndReached={handleEndReached}
678
838
  onEndReachedThreshold={0.2}
679
- showsVerticalScrollIndicator={false}
680
- initialScrollIndex={messages.length - 1}
839
+ initialScrollAtEnd
681
840
  />
682
841
  <Animated.View
683
842
  style={[styles.scrollToBottomButton, animatedButtonStyle]}
@@ -713,6 +872,9 @@ const styles = StyleSheet.create({
713
872
  paddingVertical: KSpacingValue['0.75rem'],
714
873
  flexGrow: 1,
715
874
  },
875
+ contentBottomAnchored: {
876
+ justifyContent: 'flex-end',
877
+ },
716
878
  scrollToBottomButton: {
717
879
  position: 'absolute',
718
880
  bottom: 20,
@@ -147,14 +147,16 @@ export const LegendChatMessage = memo(
147
147
 
148
148
  const hasMediaPress =
149
149
  MEDIA_TYPES.includes(message?.contentType) && onMediaPress;
150
- // AtTextMessage included: mention+URL combo attaches its link-preview
151
- // card via ex.urlMetadata on an AtTextMessage (see TextMessage's doc
152
- // comment) instead of a dedicated UrlTextMessage — the card still needs
153
- // a tap handler.
150
+ // AtTextMessage: mention+URL combo attaches its link-preview card via
151
+ // ex.urlMetadata (see TextMessage's doc comment) and/or the native
152
+ // AtTextElem.urls (SDK rc39+) for in-text link highlighting — either
153
+ // way the card/text needs a tap handler. QuoteMessage: same for a
154
+ // reply's own text via the native QuoteElem.urls (see QuotedMessage).
154
155
  const hasUrlPress =
155
156
  onPressUrl &&
156
157
  (message?.contentType === MessageType.UrlTextMessage ||
157
- message?.contentType === MessageType.AtTextMessage);
158
+ message?.contentType === MessageType.AtTextMessage ||
159
+ message?.contentType === MessageType.QuoteMessage);
158
160
  const canRevealTime = !TIME_REVEAL_EXCLUDED_TYPES.includes(
159
161
  message?.contentType
160
162
  );