@droppii-org/chat-mobile 0.2.88 → 0.2.90

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 (68) 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/message/useMessages.js +6 -0
  12. package/lib/module/hooks/message/useMessages.js.map +1 -1
  13. package/lib/module/hooks/message/useSendMessage.js +10 -3
  14. package/lib/module/hooks/message/useSendMessage.js.map +1 -1
  15. package/lib/module/screens/ResourceCenter/ResourceCenterTabScene.js.map +1 -1
  16. package/lib/module/screens/chat-detail/ChatDetail.js +2 -1
  17. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  18. package/lib/module/screens/chat-detail/ChatListLegend.js +235 -123
  19. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  20. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js +4 -1
  21. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js.map +1 -1
  22. package/lib/module/screens/chat-detail/PinnedMessagesSheetBackup.js +3 -1
  23. package/lib/module/screens/chat-detail/PinnedMessagesSheetBackup.js.map +1 -1
  24. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +11 -9
  25. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  26. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +37 -4
  27. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
  28. package/lib/module/services/resourceCenter.js +45 -22
  29. package/lib/module/services/resourceCenter.js.map +1 -1
  30. package/lib/module/store/message.js +8 -1
  31. package/lib/module/store/message.js.map +1 -1
  32. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  33. package/lib/typescript/src/components/messages/LinkifiedText.d.ts +11 -0
  34. package/lib/typescript/src/components/messages/LinkifiedText.d.ts.map +1 -0
  35. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts +1 -0
  36. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
  37. package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
  38. package/lib/typescript/src/hooks/message/useMessages.d.ts.map +1 -1
  39. package/lib/typescript/src/hooks/message/useSendMessage.d.ts.map +1 -1
  40. package/lib/typescript/src/screens/ResourceCenter/ResourceCenterTabScene.d.ts.map +1 -1
  41. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  42. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +1 -1
  43. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  44. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheet.d.ts.map +1 -1
  45. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheetBackup.d.ts.map +1 -1
  46. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +5 -3
  47. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  48. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts +2 -0
  49. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
  50. package/lib/typescript/src/services/resourceCenter.d.ts.map +1 -1
  51. package/lib/typescript/src/store/message.d.ts.map +1 -1
  52. package/package.json +2 -2
  53. package/src/components/ThreadCard/ThreadCard.tsx +0 -2
  54. package/src/components/messages/LinkifiedText.tsx +37 -0
  55. package/src/components/messages/quotedMessage/index.tsx +18 -2
  56. package/src/components/messages/textMessage/index.tsx +18 -27
  57. package/src/core/useChatListener.ts +2 -2
  58. package/src/hooks/message/useMessages.ts +6 -0
  59. package/src/hooks/message/useSendMessage.ts +7 -0
  60. package/src/screens/ResourceCenter/ResourceCenterTabScene.tsx +0 -1
  61. package/src/screens/chat-detail/ChatDetail.tsx +1 -0
  62. package/src/screens/chat-detail/ChatListLegend.tsx +236 -136
  63. package/src/screens/chat-detail/PinnedMessagesSheet.tsx +6 -1
  64. package/src/screens/chat-detail/PinnedMessagesSheetBackup.tsx +3 -1
  65. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +16 -11
  66. package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +47 -2
  67. package/src/services/resourceCenter.ts +77 -36
  68. 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,18 @@ 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
+ const BUTTON_FADE_CONFIG = { duration: 200, easing: Easing.out(Easing.ease) };
80
+
81
+ // Long enough to swallow Android's willShow→willHide→willShow flapping
82
+ // (~1.3s period, measured), short enough that a real keyboard close still
83
+ // settles within one animation.
84
+ const KEYBOARD_HIDE_DEBOUNCE_MS = 300;
85
+
75
86
  const getRowMarginBottom = (position: GroupPosition): number =>
76
87
  position === 'last' || position === 'solo'
77
88
  ? ROW_MARGIN_CLUSTER_END
78
89
  : ROW_MARGIN_IN_CLUSTER;
79
90
 
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
91
  interface ChatListLegendInternalProps extends ChatListProps {
86
92
  listRef: RefObject<LegendListRef | null>;
87
93
  freeze: SharedValue<boolean>;
@@ -101,7 +107,6 @@ export const ChatListLegend = memo(
101
107
  moreOldLoading,
102
108
  hasMoreOld,
103
109
  onLoadMoreNew,
104
- moreNewLoading,
105
110
  hasMoreNew,
106
111
  onMediaPress,
107
112
  onPressUrl,
@@ -144,11 +149,37 @@ export const ChatListLegend = memo(
144
149
  const buttonOpacity = useSharedValue(0);
145
150
  const isAtBottomRef = useRef(true);
146
151
  const scrollDebounceRef = useRef<NodeJS.Timeout | undefined>(undefined);
152
+ // Current keyboard height, and the trigger for the re-anchor effect
153
+ // below — see the keyboard listener for how each platform uses it.
154
+ const [keyboardInset, setKeyboardInset] = useState(0);
155
+ const hasSeenFirstKeyboardInsetRef = useRef(false);
147
156
  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);
157
+ // Set on a real touch-drag (`onScrollBeginDrag`), before onStartReached/
158
+ // onEndReached's threshold could fire. `onMomentumScrollBegin` was tried
159
+ // and reverted: it only fires after the finger lifts, so the first
160
+ // gesture's mid-drag threshold-crossing beats it, needing a wasted
161
+ // second gesture to actually unlock. See
162
+ // project_chatlistlegend_scroll_debugging for the full history.
163
+ const hasUserScrolledRef = useRef(false);
164
+ // Old Architecture defers recomputing `alignItemsAtEndPadding` when
165
+ // content shrinks while `initialScrollAtEnd` is active. `estimatedItemSize`
166
+ // below is a deliberate lower bound to mostly avoid that; this ref
167
+ // backstops the rest via one forced `scrollToEnd` on mount (`handleListLoad`).
168
+ const hasCorrectedInitialPaddingRef = useRef(false);
169
+ // `messages` starts empty, so `lastMessageId`'s first real value looks
170
+ // identical to a new message — tracking the previous id (not a one-shot
171
+ // flag) lets the effect below tell the two apart.
172
+ const previousLastMessageIdRef = useRef<string | null>(null);
173
+ // Read via ref (not as a dependency) by the `lastMessageId` effect below —
174
+ // a loading-state flip alone shouldn't re-run it.
175
+ const moreOldLoadingRef = useRef(moreOldLoading);
176
+ moreOldLoadingRef.current = moreOldLoading;
177
+ // Read via ref (not as a dependency) by the ScrollToMessage subscription
178
+ // below — the whole array changes on every new message and every old-
179
+ // history page, which would otherwise tear down and re-add both
180
+ // DeviceEventEmitter listeners on every one of those.
181
+ const messagesRef = useRef(messages);
182
+ messagesRef.current = messages;
152
183
  const [showBadge, setShowBadge] = useState(false);
153
184
  const [highlightedId, setHighlightedId] = useState<string | null>(null);
154
185
  const highlightTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
@@ -190,7 +221,7 @@ export const ChatListLegend = memo(
190
221
  }, []);
191
222
 
192
223
  const renderItem = useCallback(
193
- ({ item, index }: any) => {
224
+ ({ item, index }: { item: MessageItem; index: number }) => {
194
225
  try {
195
226
  if (!item) return null;
196
227
 
@@ -247,17 +278,14 @@ export const ChatListLegend = memo(
247
278
  >
248
279
  <LegendChatMessage
249
280
  message={message}
281
+ messageId={messageId}
250
282
  isOutgoing={isOutgoing}
251
283
  createdAtTime={createdAt}
252
284
  onMediaPress={onMediaPress}
253
285
  onPressUrl={onPressUrl}
254
286
  onRetry={retryMessage}
255
- onPress={() => handleMessagePress(messageId)}
256
- onLongPress={
257
- onLongPress
258
- ? () => handleMessageLongPress(messageId, message)
259
- : undefined
260
- }
287
+ onPress={handleMessagePress}
288
+ onLongPress={onLongPress ? handleMessageLongPress : undefined}
261
289
  customMessageItemFactory={customMessageItemFactory}
262
290
  isHighlighted={highlightedId === messageId}
263
291
  isTimeRevealed={revealedMessageId === messageId}
@@ -310,33 +338,21 @@ export const ChatListLegend = memo(
310
338
  ]
311
339
  );
312
340
 
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.
341
+ // `maintainVisibleContentPosition` anchors a prepend to whatever native
342
+ // view sits at the top. Default `drawDistance` (250px) doesn't survive a
343
+ // ~1-1.5s history fetch if the user keeps scrolling away — the anchor
344
+ // unmounts and the native compensation silently drops (the "list drifts
345
+ // after loading old history" bug). `drawDistance` below is raised to
346
+ // outlast a realistic scroll during that wait (measured ~300-350px).
347
+ // `recycleItems` is safe again since @legendapp/list 3.3.7 fixed
348
+ // recycled rows flashing wrong content on prepend.
349
+ const maintainVisiblePositionConfig: MaintainVisibleContentPositionConfig =
350
+ useMemo(() => ({ data: true }), []);
351
+
352
+ // Buckets LegendList's size-averaging per content type so a batch of
353
+ // images landing early doesn't pollute the estimate for still-unmeasured
354
+ // text rows. `customMessageItemFactory` nodes get their own bucket per
355
+ // type too, so a custom node's real height doesn't pollute that type's average.
340
356
  const getItemType = useCallback(
341
357
  (item: MessageItem) => {
342
358
  const isCustom = !!customMessageItemFactory?.(item);
@@ -345,14 +361,12 @@ export const ChatListLegend = memo(
345
361
  [customMessageItemFactory]
346
362
  );
347
363
 
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.
364
+ // Exact size for content types knowable ahead of render (see
365
+ // `plans/260728-1400-chatlist-legend-fixed-item-size/plan.md`). Returns
366
+ // `undefined` (defer to measurement) for anything unpredictable, or
367
+ // carrying chrome this function doesn't account for (day-divider, flow-
368
+ // input-buttons row) — a resolved number is never re-measured, so
369
+ // guessing wrong here is permanent, not just approximate.
356
370
  const getFixedItemSize = useCallback(
357
371
  (item: MessageItem, index: number): number | undefined => {
358
372
  const message = item;
@@ -432,13 +446,10 @@ export const ChatListLegend = memo(
432
446
  ]
433
447
  );
434
448
 
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.
449
+ // LegendList only re-renders an item when [itemKey, item, extraData]
450
+ // changes. Cluster position depends on a message's NEIGHBOURS, which can
451
+ // shift (prepending history can turn a 'first' into a 'middle') without
452
+ // the message itself changing — messageDataMap here is what catches that.
442
453
  const listExtraData = useMemo(
443
454
  () => ({
444
455
  messageDataMap,
@@ -457,51 +468,25 @@ export const ChatListLegend = memo(
457
468
  );
458
469
 
459
470
  const handleStartReached = useCallback(() => {
460
- const willLoad = hasMoreOld && !moreOldLoading;
461
- 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
- );
477
- }
478
- if (willLoad) {
471
+ // Both thresholds are checked continuously, including at mount before
472
+ // any gesture — `hasUserScrolledRef` is what keeps that from firing a
473
+ // load-more the user never asked for.
474
+ if (!hasUserScrolledRef.current) return;
475
+ if (hasMoreOld && !useMessageStore.getState().moreOldLoading) {
479
476
  onLoadMoreOld?.(true);
480
477
  }
481
- }, [conversationId, messages, hasMoreOld, moreOldLoading, onLoadMoreOld]);
478
+ }, [hasMoreOld, onLoadMoreOld]);
482
479
 
483
480
  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
- );
500
- }
501
- if (willLoad) {
481
+ if (!hasUserScrolledRef.current) return;
482
+ if (hasMoreNew && !useMessageStore.getState().moreNewLoading) {
502
483
  onLoadMoreNew?.(true);
503
484
  }
504
- }, [conversationId, messages, hasMoreNew, moreNewLoading, onLoadMoreNew]);
485
+ }, [hasMoreNew, onLoadMoreNew]);
486
+
487
+ const handleScrollBeginDrag = useCallback(() => {
488
+ hasUserScrolledRef.current = true;
489
+ }, []);
505
490
 
506
491
  const handleScroll = useCallback(
507
492
  (e: any) => {
@@ -518,10 +503,10 @@ export const ChatListLegend = memo(
518
503
  scrollDebounceRef.current = setTimeout(() => {
519
504
  if (isAtBottomRef.current !== isAtBottom) {
520
505
  isAtBottomRef.current = isAtBottom;
521
- buttonOpacity.value = withTiming(isAtBottom ? 0 : 1, {
522
- duration: 200,
523
- easing: Easing.out(Easing.ease),
524
- });
506
+ buttonOpacity.value = withTiming(
507
+ isAtBottom ? 0 : 1,
508
+ BUTTON_FADE_CONFIG
509
+ );
525
510
  }
526
511
  if (isAtBottom) {
527
512
  ChatMessageAPI.markConversationAsRead(conversationId || '');
@@ -534,15 +519,17 @@ export const ChatListLegend = memo(
534
519
  const handleScrollToBottom = useCallback(() => {
535
520
  if (hasMoreNew) {
536
521
  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);
522
+ return;
545
523
  }
524
+ // `ChatEvent.ScrollToBottom` fires after every self-sent message, but
525
+ // the `lastMessageId` effect below already animates to it — re-scrolling
526
+ // here raced it and flickered. Only act when not already at bottom
527
+ // (FAB tap, or a send while scrolled away).
528
+ if (isAtBottomRef.current) return;
529
+ scrollMessageToEnd({ animated: true, closeKeyboard: false });
530
+ buttonOpacity.value = withTiming(0, BUTTON_FADE_CONFIG);
531
+ isAtBottomRef.current = true;
532
+ setShowBadge(false);
546
533
  }, [hasMoreNew, onLoadMoreOld, scrollMessageToEnd, buttonOpacity]);
547
534
 
548
535
  const animatedButtonStyle = useAnimatedStyle(() => ({
@@ -559,14 +546,113 @@ export const ChatListLegend = memo(
559
546
  };
560
547
  }, []);
561
548
 
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.
549
+ // Tracks the keyboard's height as the trigger for the re-anchor below.
550
+ // The `willHide` side is debounced because Android's event stream flaps
551
+ // willShow→willHide→willShow roughly every 1.3s for as long as the
552
+ // keyboard stays open (measured on device) — without this, every flap
553
+ // would fire another scroll. A real close still lands, just one tick
554
+ // later. Setting the same height twice is a no-op for the effect below,
555
+ // so a stable open only ever re-anchors once.
556
+ //
557
+ // JS-thread only (no worklet), so none of the reentrant-lock crash risk
558
+ // that reaching into `useKeyboardHandler`'s worklets carries.
566
559
  useEffect(() => {
567
- hasSettledRef.current = false;
560
+ let pendingHide: ReturnType<typeof setTimeout> | undefined;
561
+ const clearPendingHide = () => {
562
+ if (pendingHide) {
563
+ clearTimeout(pendingHide);
564
+ pendingHide = undefined;
565
+ }
566
+ };
567
+ const applyInset = (nextInset: number) => {
568
+ // Android only: the bottom inset the library applies there is a
569
+ // synthetic prop that `onScroll` does not report (its own
570
+ // `ScrollViewWithBottomPadding` says as much), so LegendList never
571
+ // learns about it, `alignItemsAtEndPadding` never shrinks, and the
572
+ // re-anchor below has no room to scroll into. Reporting it closes
573
+ // that gap. iOS needs none of this — there the inset is real and
574
+ // arrives through `onScroll` on its own, and overriding it here
575
+ // would double-count.
576
+ if (Platform.OS === 'android') {
577
+ listRef.current?.reportContentInset(
578
+ nextInset > 0 ? { bottom: nextInset } : null
579
+ );
580
+ }
581
+ setKeyboardInset(nextInset);
582
+ };
583
+
584
+ const willShow = KeyboardEvents.addListener('keyboardWillShow', (e) => {
585
+ clearPendingHide();
586
+ applyInset(Math.max(e.height - keyboardOffset, 0));
587
+ });
588
+ const willHide = KeyboardEvents.addListener('keyboardWillHide', () => {
589
+ clearPendingHide();
590
+ pendingHide = setTimeout(
591
+ () => applyInset(0),
592
+ KEYBOARD_HIDE_DEBOUNCE_MS
593
+ );
594
+ });
595
+ return () => {
596
+ clearPendingHide();
597
+ willShow.remove();
598
+ willHide.remove();
599
+ };
600
+ }, [keyboardOffset, listRef]);
601
+
602
+ // Re-anchors to the end once the keyboard's inset has landed, which puts
603
+ // the last message at exactly the spacing the closed-keyboard layout has
604
+ // (verified on device against both a 7-message and a long conversation).
605
+ // Skipped when the user has scrolled away from the bottom — opening the
606
+ // keyboard shouldn't yank them back down.
607
+ useEffect(() => {
608
+ if (!hasSeenFirstKeyboardInsetRef.current) {
609
+ hasSeenFirstKeyboardInsetRef.current = true;
610
+ return;
611
+ }
612
+ if (!isAtBottomRef.current) return;
613
+ scrollMessageToEnd({ animated: true, closeKeyboard: false });
614
+ }, [keyboardInset, scrollMessageToEnd]);
615
+
616
+ // This component can stay mounted across a conversationId change (e.g.
617
+ // navigation.replace reusing the same screen instance), so this ref must
618
+ // reset per-conversation — otherwise a leftover "user scrolled" flag from
619
+ // the previous conversation would wrongly let load-more fire on the
620
+ // newly opened one before any real scroll there.
621
+ useEffect(() => {
622
+ hasUserScrolledRef.current = false;
623
+ hasCorrectedInitialPaddingRef.current = false;
624
+ previousLastMessageIdRef.current = null;
568
625
  }, [conversationId]);
569
626
 
627
+ // Single source of truth for "scroll to bottom on a new message" (self-
628
+ // sent or received). Skipped while prepending old history or scrolled
629
+ // away from the bottom. `moreOldLoading` is read via ref, not a
630
+ // dependency, so its own start/finish toggle doesn't re-run this.
631
+ useEffect(() => {
632
+ const previousLastMessageId = previousLastMessageIdRef.current;
633
+ previousLastMessageIdRef.current = lastMessageId;
634
+
635
+ // null → a real id is the initial fetch landing (initialScrollAtEnd +
636
+ // handleListLoad's job), not a new message; a real id → a different
637
+ // one is.
638
+ const shouldFire =
639
+ previousLastMessageId !== null &&
640
+ !!lastMessageId &&
641
+ !moreOldLoadingRef.current &&
642
+ isAtBottomRef.current;
643
+
644
+ if (!shouldFire) return;
645
+
646
+ scrollMessageToEnd({ animated: true, closeKeyboard: false });
647
+ }, [lastMessageId, scrollMessageToEnd]);
648
+
649
+ const handleListLoad = useCallback(() => {
650
+ if (hasCorrectedInitialPaddingRef.current) return;
651
+ hasCorrectedInitialPaddingRef.current = true;
652
+
653
+ scrollMessageToEnd({ animated: false, closeKeyboard: false });
654
+ }, [scrollMessageToEnd]);
655
+
570
656
  useEffect(() => {
571
657
  const bottomSubscription = DeviceEventEmitter.addListener(
572
658
  ChatEvent.ScrollToBottom,
@@ -576,7 +662,7 @@ export const ChatListLegend = memo(
576
662
  const subscription = DeviceEventEmitter.addListener(
577
663
  ChatEvent.ScrollToMessage,
578
664
  ({ clientMessageId }: ScrollToMessagePayload) => {
579
- const index = messages.findIndex(
665
+ const index = messagesRef.current.findIndex(
580
666
  (m) => m.clientMsgID === clientMessageId
581
667
  );
582
668
 
@@ -621,11 +707,9 @@ export const ChatListLegend = memo(
621
707
  subscription.remove();
622
708
  };
623
709
  }, [
624
- messages,
625
710
  applicationType,
626
711
  searchMessages,
627
712
  conversationId,
628
- buttonOpacity,
629
713
  handleScrollToBottom,
630
714
  listRef,
631
715
  ]);
@@ -638,46 +722,62 @@ export const ChatListLegend = memo(
638
722
  );
639
723
  }
640
724
 
641
- console.log('messages length: ', +messages.length);
642
725
  return (
643
726
  <KContainer.View flex background={KColors.white} style={styles.container}>
644
- <ListComponent
727
+ <KeyboardAwareLegendList
645
728
  ref={listRef}
646
729
  data={messages}
647
730
  renderItem={renderItem}
648
- estimatedItemSize={200}
731
+ // Deliberate lower bound (not a best-guess average) — see the
732
+ // comment above `hasCorrectedInitialPaddingRef`. A single-line
733
+ // bubble floors out around 38-44px, so this stays safely under it.
734
+ estimatedItemSize={36}
649
735
  getItemType={getItemType}
650
736
  getFixedItemSize={getFixedItemSize}
651
737
  keyExtractor={(item) => String(item.clientMsgID || item.serverMsgID)}
652
- recycleItems={false}
738
+ recycleItems
653
739
  extraData={listExtraData}
654
740
  alignItemsAtEnd
741
+ alwaysRender={{
742
+ top: 10,
743
+ }}
744
+ maintainScrollAtEnd
655
745
  maintainVisibleContentPosition={maintainVisiblePositionConfig}
746
+ // See the comment above `maintainVisiblePositionConfig` — this is
747
+ // what keeps a history prepend's anchor item mounted through a slow
748
+ // fetch instead of the default 250px window losing it.
749
+ drawDistance={800}
656
750
  freeze={freeze}
657
751
  keyboardOffset={keyboardOffset}
752
+ // The list's own keyboard compensation is off on both platforms —
753
+ // measured, it overshoots badly either way. On iOS it scrolls on
754
+ // top of React Native's `automaticallyAdjustContentInsets` (which
755
+ // already insets the scroll view by the full keyboard height), and
756
+ // that double lift is the blank gap. On Android it overshot by
757
+ // ~690px against a 128px target. What both platforms do keep from
758
+ // the library is the bottom inset it sets, which is the room the
759
+ // re-anchor above scrolls into.
760
+ keyboardLiftBehavior="never"
658
761
  scrollEventThrottle={16}
659
762
  onScroll={handleScroll}
763
+ onScrollBeginDrag={handleScrollBeginDrag}
764
+ onLoad={handleListLoad}
660
765
  contentContainerStyle={styles.content}
661
766
  scrollIndicatorInsets={{ right: 1 }}
662
767
  ListHeaderComponent={
663
768
  <LegendChatLoadEarlier isLoading={moreOldLoading} />
664
769
  }
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
770
  onStartReached={handleStartReached}
676
- onStartReachedThreshold={0.5}
771
+ // LegendList only re-fires onStartReached after scrolling away from
772
+ // the threshold by 1.3x and back. At 0.5 that distance exceeded a
773
+ // short conversation's whole scrollable range, so the mount-time
774
+ // firing we suppress in handleStartReached "used up" the only
775
+ // trigger. 0.3 keeps that reset distance safely under a
776
+ // ~1.5-screen conversation; shorter ones can still hit it.
777
+ onStartReachedThreshold={0.2}
677
778
  onEndReached={handleEndReached}
678
779
  onEndReachedThreshold={0.2}
679
- showsVerticalScrollIndicator={false}
680
- initialScrollIndex={messages.length - 1}
780
+ initialScrollAtEnd
681
781
  />
682
782
  <Animated.View
683
783
  style={[styles.scrollToBottomButton, animatedButtonStyle]}
@@ -52,7 +52,11 @@ const PinnedMessagesSheetContent = memo(
52
52
  );
53
53
 
54
54
  const handlePressMessage = useCallback(
55
- (message: MessageItem) => {
55
+ (messageId: string) => {
56
+ const message = usePinnedMessageStore
57
+ .getState()
58
+ .messages.find((m) => (m.clientMsgID || m.serverMsgID) === messageId);
59
+ if (!message) return;
56
60
  UIUtils.bottomSheet.dismiss();
57
61
  onPressMessage?.(message);
58
62
  },
@@ -117,6 +121,7 @@ const PinnedMessagesSheetContent = memo(
117
121
 
118
122
  <LegendChatMessage
119
123
  message={message}
124
+ messageId={messageId}
120
125
  isOutgoing={isOutgoing}
121
126
  createdAtTime={createdAtTime}
122
127
  onPress={handlePressMessage}
@@ -90,6 +90,7 @@ const PinnedMessageToolboxOverlay = memo(
90
90
  message={
91
91
  <LegendChatMessage
92
92
  message={message}
93
+ messageId={message.clientMsgID || message.serverMsgID || ''}
93
94
  isOutgoing={isOutgoing}
94
95
  createdAtTime={message.sendTime || message.createTime}
95
96
  customMessageItemFactory={customMessageItemFactory}
@@ -191,9 +192,10 @@ const PinnedMessagesSheetContent = memo(
191
192
  >
192
193
  <LegendChatMessage
193
194
  message={message}
195
+ messageId={messageId}
194
196
  isOutgoing={isOutgoing}
195
197
  createdAtTime={createdAtTime}
196
- onLongPress={() => handleLongPress(messageId, message)}
198
+ onLongPress={handleLongPress}
197
199
  customMessageItemFactory={customMessageItemFactory}
198
200
  />
199
201
  {!!timeLabel && (
@@ -38,13 +38,15 @@ type MessageProps = BaseLegendMessageProps & {
38
38
 
39
39
  interface LegendChatMessageProps {
40
40
  message: MessageItem;
41
+ /** Stable per-row id (see getMessageRenderId) — bound into onPress/onLongPress locally so those callbacks stay referentially stable across unrelated list re-renders. */
42
+ messageId: string;
41
43
  isOutgoing: boolean;
42
44
  createdAtTime: number;
43
45
  onMediaPress?: (items: MediaItem[], index?: number) => void;
44
46
  onPressUrl?: (url: string) => void;
45
47
  onRetry?: (message: MessageItem) => void;
46
- onPress?: (message: MessageItem) => void;
47
- onLongPress?: () => void;
48
+ onPress?: (messageId: string) => void;
49
+ onLongPress?: (messageId: string, message: MessageItem) => void;
48
50
  customMessageItemFactory?: (m: MessageItem) => ReactNode;
49
51
  isHighlighted?: boolean;
50
52
  isTimeRevealed?: boolean;
@@ -99,6 +101,7 @@ const TIME_REVEAL_EXCLUDED_TYPES: number[] = [
99
101
  export const LegendChatMessage = memo(
100
102
  ({
101
103
  message,
104
+ messageId,
102
105
  isOutgoing,
103
106
  createdAtTime,
104
107
  onMediaPress,
@@ -115,12 +118,12 @@ export const LegendChatMessage = memo(
115
118
  groupPosition,
116
119
  }: LegendChatMessageProps) => {
117
120
  const handlePress = useCallback(() => {
118
- onPress?.(message);
119
- }, [onPress, message]);
121
+ onPress?.(messageId);
122
+ }, [onPress, messageId]);
120
123
 
121
124
  const handleLongPress = useCallback(() => {
122
- onLongPress?.();
123
- }, [onLongPress]);
125
+ onLongPress?.(messageId, message);
126
+ }, [onLongPress, messageId, message]);
124
127
 
125
128
  const customNode = customMessageItemFactory?.(message);
126
129
 
@@ -147,14 +150,16 @@ export const LegendChatMessage = memo(
147
150
 
148
151
  const hasMediaPress =
149
152
  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.
153
+ // AtTextMessage: mention+URL combo attaches its link-preview card via
154
+ // ex.urlMetadata (see TextMessage's doc comment) and/or the native
155
+ // AtTextElem.urls (SDK rc39+) for in-text link highlighting — either
156
+ // way the card/text needs a tap handler. QuoteMessage: same for a
157
+ // reply's own text via the native QuoteElem.urls (see QuotedMessage).
154
158
  const hasUrlPress =
155
159
  onPressUrl &&
156
160
  (message?.contentType === MessageType.UrlTextMessage ||
157
- message?.contentType === MessageType.AtTextMessage);
161
+ message?.contentType === MessageType.AtTextMessage ||
162
+ message?.contentType === MessageType.QuoteMessage);
158
163
  const canRevealTime = !TIME_REVEAL_EXCLUDED_TYPES.includes(
159
164
  message?.contentType
160
165
  );