@droppii-org/chat-mobile 0.2.89 → 0.2.91

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 (61) hide show
  1. package/lib/module/context/ChatContext.js +5 -3
  2. package/lib/module/context/ChatContext.js.map +1 -1
  3. package/lib/module/index.js +1 -0
  4. package/lib/module/index.js.map +1 -1
  5. package/lib/module/screens/chat-detail/ChatDetail.js +64 -5
  6. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  7. package/lib/module/screens/chat-detail/ChatListLegend.js +93 -123
  8. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  9. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js +4 -1
  10. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js.map +1 -1
  11. package/lib/module/screens/chat-detail/PinnedMessagesSheetBackup.js +3 -1
  12. package/lib/module/screens/chat-detail/PinnedMessagesSheetBackup.js.map +1 -1
  13. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +5 -4
  14. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  15. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +18 -7
  16. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
  17. package/lib/module/screens/chat-detail/messageToolbox/messageToolbox.utils.js +3 -5
  18. package/lib/module/screens/chat-detail/messageToolbox/messageToolbox.utils.js.map +1 -1
  19. package/lib/module/services/apis.js +2 -1
  20. package/lib/module/services/apis.js.map +1 -1
  21. package/lib/module/store/message.js +1 -1
  22. package/lib/module/store/message.js.map +1 -1
  23. package/lib/module/store/user.js +6 -0
  24. package/lib/module/store/user.js.map +1 -1
  25. package/lib/module/translation/resources/i18n.js +1 -0
  26. package/lib/module/translation/resources/i18n.js.map +1 -1
  27. package/lib/module/types/chat.js.map +1 -1
  28. package/lib/typescript/src/context/ChatContext.d.ts.map +1 -1
  29. package/lib/typescript/src/index.d.ts +1 -0
  30. package/lib/typescript/src/index.d.ts.map +1 -1
  31. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts +2 -2
  32. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  33. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  34. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheet.d.ts.map +1 -1
  35. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheetBackup.d.ts.map +1 -1
  36. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +5 -3
  37. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  38. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
  39. package/lib/typescript/src/screens/chat-detail/messageToolbox/messageToolbox.utils.d.ts +2 -0
  40. package/lib/typescript/src/screens/chat-detail/messageToolbox/messageToolbox.utils.d.ts.map +1 -1
  41. package/lib/typescript/src/services/apis.d.ts.map +1 -1
  42. package/lib/typescript/src/store/user.d.ts +1 -0
  43. package/lib/typescript/src/store/user.d.ts.map +1 -1
  44. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  45. package/lib/typescript/src/types/chat.d.ts +4 -0
  46. package/lib/typescript/src/types/chat.d.ts.map +1 -1
  47. package/package.json +1 -1
  48. package/src/context/ChatContext.tsx +3 -0
  49. package/src/index.tsx +1 -0
  50. package/src/screens/chat-detail/ChatDetail.tsx +63 -5
  51. package/src/screens/chat-detail/ChatListLegend.tsx +94 -156
  52. package/src/screens/chat-detail/PinnedMessagesSheet.tsx +6 -1
  53. package/src/screens/chat-detail/PinnedMessagesSheetBackup.tsx +3 -1
  54. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +9 -6
  55. package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +20 -7
  56. package/src/screens/chat-detail/messageToolbox/messageToolbox.utils.ts +4 -4
  57. package/src/services/apis.ts +2 -1
  58. package/src/store/message.ts +1 -1
  59. package/src/store/user.ts +7 -0
  60. package/src/translation/resources/i18n.ts +2 -0
  61. package/src/types/chat.ts +14 -0
@@ -76,12 +76,13 @@ import ChatImageDimens, {
76
76
  const ROW_MARGIN_CLUSTER_END = KSpacingValue['0.5rem'];
77
77
  const ROW_MARGIN_IN_CLUSTER = KSpacingValue['0.25rem'];
78
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
79
  const BUTTON_FADE_CONFIG = { duration: 200, easing: Easing.out(Easing.ease) };
84
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
+
85
86
  const getRowMarginBottom = (position: GroupPosition): number =>
86
87
  position === 'last' || position === 'solo'
87
88
  ? ROW_MARGIN_CLUSTER_END
@@ -142,31 +143,16 @@ export const ChatListLegend = memo(
142
143
  const last = messages[messages.length - 1];
143
144
  return last?.clientMsgID || last?.serverMsgID || null;
144
145
  }, [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;
155
146
  const { mutate: runFlow } = useRunFlow();
156
147
  const { mutate: resetFlow } = useResetFlow();
157
148
 
158
149
  const buttonOpacity = useSharedValue(0);
159
150
  const isAtBottomRef = useRef(true);
160
151
  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);
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);
170
156
  const rowRefsMap = useRef<Map<string, View | null>>(new Map());
171
157
  // Set on a real touch-drag (`onScrollBeginDrag`), before onStartReached/
172
158
  // onEndReached's threshold could fire. `onMomentumScrollBegin` was tried
@@ -292,17 +278,14 @@ export const ChatListLegend = memo(
292
278
  >
293
279
  <LegendChatMessage
294
280
  message={message}
281
+ messageId={messageId}
295
282
  isOutgoing={isOutgoing}
296
283
  createdAtTime={createdAt}
297
284
  onMediaPress={onMediaPress}
298
285
  onPressUrl={onPressUrl}
299
286
  onRetry={retryMessage}
300
- onPress={() => handleMessagePress(messageId)}
301
- onLongPress={
302
- onLongPress
303
- ? () => handleMessageLongPress(messageId, message)
304
- : undefined
305
- }
287
+ onPress={handleMessagePress}
288
+ onLongPress={onLongPress ? handleMessageLongPress : undefined}
306
289
  customMessageItemFactory={customMessageItemFactory}
307
290
  isHighlighted={highlightedId === messageId}
308
291
  isTimeRevealed={revealedMessageId === messageId}
@@ -485,47 +468,21 @@ export const ChatListLegend = memo(
485
468
  );
486
469
 
487
470
  const handleStartReached = useCallback(() => {
488
- if (__DEV__) {
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;
506
- }
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;
507
475
  if (hasMoreOld && !useMessageStore.getState().moreOldLoading) {
508
476
  onLoadMoreOld?.(true);
509
477
  }
510
- }, [conversationId, hasMoreOld, onLoadMoreOld]);
478
+ }, [hasMoreOld, onLoadMoreOld]);
511
479
 
512
480
  const handleEndReached = useCallback(() => {
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;
523
- }
481
+ if (!hasUserScrolledRef.current) return;
524
482
  if (hasMoreNew && !useMessageStore.getState().moreNewLoading) {
525
- if (__DEV__) console.log(`${LOG_TAG} handleEndReached`);
526
483
  onLoadMoreNew?.(true);
527
484
  }
528
- }, [conversationId, hasMoreNew, onLoadMoreNew]);
485
+ }, [hasMoreNew, onLoadMoreNew]);
529
486
 
530
487
  const handleScrollBeginDrag = useCallback(() => {
531
488
  hasUserScrolledRef.current = true;
@@ -533,41 +490,11 @@ export const ChatListLegend = memo(
533
490
 
534
491
  const handleScroll = useCallback(
535
492
  (e: any) => {
536
- const { contentOffset, contentSize, layoutMeasurement, contentInset } =
537
- e.nativeEvent;
493
+ const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
538
494
  const isAtBottom =
539
495
  contentOffset.y + layoutMeasurement.height >=
540
496
  contentSize.height - 100;
541
497
 
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
-
571
498
  // Debounce animation updates
572
499
  if (scrollDebounceRef.current) {
573
500
  clearTimeout(scrollDebounceRef.current);
@@ -619,26 +546,72 @@ export const ChatListLegend = memo(
619
546
  };
620
547
  }, []);
621
548
 
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.
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.
626
559
  useEffect(() => {
627
- if (!__DEV__) return;
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
+
628
584
  const willShow = KeyboardEvents.addListener('keyboardWillShow', (e) => {
629
- expectedKeyboardInsetRef.current = Math.max(
630
- e.height - keyboardOffset,
631
- 0
632
- );
585
+ clearPendingHide();
586
+ applyInset(Math.max(e.height - keyboardOffset, 0));
633
587
  });
634
588
  const willHide = KeyboardEvents.addListener('keyboardWillHide', () => {
635
- expectedKeyboardInsetRef.current = 0;
589
+ clearPendingHide();
590
+ pendingHide = setTimeout(
591
+ () => applyInset(0),
592
+ KEYBOARD_HIDE_DEBOUNCE_MS
593
+ );
636
594
  });
637
595
  return () => {
596
+ clearPendingHide();
638
597
  willShow.remove();
639
598
  willHide.remove();
640
599
  };
641
- }, [keyboardOffset]);
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]);
642
615
 
643
616
  // This component can stay mounted across a conversationId change (e.g.
644
617
  // navigation.replace reusing the same screen instance), so this ref must
@@ -668,54 +641,17 @@ export const ChatListLegend = memo(
668
641
  !moreOldLoadingRef.current &&
669
642
  isAtBottomRef.current;
670
643
 
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
644
  if (!shouldFire) return;
685
645
 
686
646
  scrollMessageToEnd({ animated: true, closeKeyboard: false });
687
- }, [lastMessageId, scrollMessageToEnd, conversationId]);
647
+ }, [lastMessageId, scrollMessageToEnd]);
688
648
 
689
- const handleListLoad = useCallback(
690
- (info: { elapsedTimeInMs: number }) => {
691
- if (hasCorrectedInitialPaddingRef.current) return;
692
- hasCorrectedInitialPaddingRef.current = true;
649
+ const handleListLoad = useCallback(() => {
650
+ if (hasCorrectedInitialPaddingRef.current) return;
651
+ hasCorrectedInitialPaddingRef.current = true;
693
652
 
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
- );
653
+ scrollMessageToEnd({ animated: false, closeKeyboard: false });
654
+ }, [scrollMessageToEnd]);
719
655
 
720
656
  useEffect(() => {
721
657
  const bottomSubscription = DeviceEventEmitter.addListener(
@@ -801,7 +737,7 @@ export const ChatListLegend = memo(
801
737
  keyExtractor={(item) => String(item.clientMsgID || item.serverMsgID)}
802
738
  recycleItems
803
739
  extraData={listExtraData}
804
- alignItemsAtEnd={!isVeryShortConversation}
740
+ alignItemsAtEnd
805
741
  alwaysRender={{
806
742
  top: 10,
807
743
  }}
@@ -813,15 +749,20 @@ export const ChatListLegend = memo(
813
749
  drawDistance={800}
814
750
  freeze={freeze}
815
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"
816
761
  scrollEventThrottle={16}
817
762
  onScroll={handleScroll}
818
763
  onScrollBeginDrag={handleScrollBeginDrag}
819
764
  onLoad={handleListLoad}
820
- contentContainerStyle={
821
- isVeryShortConversation
822
- ? [styles.content, styles.contentBottomAnchored]
823
- : styles.content
824
- }
765
+ contentContainerStyle={styles.content}
825
766
  scrollIndicatorInsets={{ right: 1 }}
826
767
  ListHeaderComponent={
827
768
  <LegendChatLoadEarlier isLoading={moreOldLoading} />
@@ -872,9 +813,6 @@ const styles = StyleSheet.create({
872
813
  paddingVertical: KSpacingValue['0.75rem'],
873
814
  flexGrow: 1,
874
815
  },
875
- contentBottomAnchored: {
876
- justifyContent: 'flex-end',
877
- },
878
816
  scrollToBottomButton: {
879
817
  position: 'absolute',
880
818
  bottom: 20,
@@ -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
 
@@ -22,7 +22,7 @@ import { MessageToolboxWrapper } from './MessageToolboxWrapper';
22
22
  import type { ReactNode } from 'react';
23
23
  import type { SharedValue } from 'react-native-reanimated';
24
24
  import { useSendMessage } from '../../../hooks/message/useSendMessage';
25
- import { useChatDetailContext } from '../../../context';
25
+ import { useChatContext, useChatDetailContext } from '../../../context';
26
26
  import {
27
27
  GroupPermission,
28
28
  type MessageItem,
@@ -64,7 +64,8 @@ export const MessageToolboxOverlay = forwardRef<
64
64
  },
65
65
  ref
66
66
  ) => {
67
- const { setQuotedMessage } = useChatDetailContext();
67
+ const { setQuotedMessage, conversation } = useChatDetailContext();
68
+ const { generateChatDetailLink } = useChatContext();
68
69
  const [visible, setVisible] = useState(false);
69
70
  const [message, setMessage] = useState<MessageItem | undefined>(undefined);
70
71
  const [rect, setRect] = useState<MeasuredRect | null>(null);
@@ -139,16 +140,24 @@ export const MessageToolboxOverlay = forwardRef<
139
140
  }, [message, hide]);
140
141
 
141
142
  const handleCopyMessageLink = useCallback(() => {
142
- if (!message) return;
143
- // UI-only for now: real shareable deeplink format (BR-001) is not decided yet.
144
- const placeholderLink = `${message.recvID || message.groupID}:${message.clientMsgID ?? message.serverMsgID ?? ''}`;
145
- Clipboard.setString(placeholderLink);
143
+ if (!message || !generateChatDetailLink) return;
144
+
145
+ const link = generateChatDetailLink({
146
+ targetConversationId: conversation?.conversationID,
147
+ targetMessageId: message.clientMsgID,
148
+ targetGroupId: conversation?.groupID,
149
+ targetUserId: conversation?.userID,
150
+ // targetBotId: '',
151
+ });
152
+ Clipboard.setString(link);
153
+
146
154
  UIUtils.toast.open({
147
155
  title: trans('chat:copy_message_link_success'),
148
156
  theme: 'success',
149
157
  });
158
+
150
159
  hide();
151
- }, [message, hide]);
160
+ }, [message, hide, generateChatDetailLink, conversation]);
152
161
 
153
162
  const handleRetryMessage = useCallback(() => {
154
163
  if (!message) return;
@@ -214,6 +223,7 @@ export const MessageToolboxOverlay = forwardRef<
214
223
  message={
215
224
  <LegendChatMessage
216
225
  message={message}
226
+ messageId={message.clientMsgID || message.serverMsgID || ''}
217
227
  isOutgoing={isOutgoing}
218
228
  createdAtTime={message.sendTime || message.createTime}
219
229
  customMessageItemFactory={customMessageItemFactory}
@@ -222,6 +232,9 @@ export const MessageToolboxOverlay = forwardRef<
222
232
  toolbox={
223
233
  <MessageToolboxMenu
224
234
  {...toolboxItems}
235
+ showCopyMessageLink={
236
+ toolboxItems.showCopyMessageLink && !!generateChatDetailLink
237
+ }
225
238
  showPinMessage={toolboxItems.showPinMessage && isPinAllowed}
226
239
  showUnpinMessage={toolboxItems.showUnpinMessage && isPinAllowed}
227
240
  onCopyMessage={handleCopyMessage}
@@ -38,8 +38,8 @@ const isCopyMessageSupported = (message: MessageItem): boolean =>
38
38
  COPYABLE_TEXT_TYPES.includes(message.contentType);
39
39
 
40
40
  // All message types are supported, as long as the message is eligible.
41
- // const isCopyMessageLinkSupported = (message: MessageItem): boolean =>
42
- // isEligibleForToolbox(message);
41
+ const isCopyMessageLinkSupported = (message: MessageItem): boolean =>
42
+ isEligibleForToolbox(message);
43
43
 
44
44
  const isRetrySupported = (message: MessageItem): boolean =>
45
45
  message.status === MessageStatus.Failed;
@@ -92,7 +92,7 @@ export const getMessageToolboxItems = (
92
92
  }
93
93
 
94
94
  const showCopyMessage = isCopyMessageSupported(message);
95
- // const showCopyMessageLink = isCopyMessageLinkSupported(message);
95
+ const showCopyMessageLink = isCopyMessageLinkSupported(message);
96
96
  const showRetryMessage = isRetrySupported(message);
97
97
  const showRevokeMessage = isRevokeMessageSupported(message, currentUserId);
98
98
  const showQuoteMessage = isQuoteMessageSupported(message);
@@ -103,7 +103,7 @@ export const getMessageToolboxItems = (
103
103
 
104
104
  return {
105
105
  showCopyMessage,
106
- // showCopyMessageLink,
106
+ showCopyMessageLink,
107
107
  showRetryMessage,
108
108
  showRevokeMessage,
109
109
  showPinMessage,
@@ -1,6 +1,6 @@
1
1
  import { type AxiosInstance } from 'axios';
2
2
  import OpenIMSDK, { LoginStatus } from '@droppii/openim-rn-client-sdk';
3
- import { useConversationStore } from '../store';
3
+ import { useConversationStore, useUserStore } from '../store';
4
4
 
5
5
  export let apiInstance: AxiosInstance | null = null;
6
6
 
@@ -15,6 +15,7 @@ export namespace ChatAPI {
15
15
 
16
16
  export const logout = async (operationID?: string) => {
17
17
  useConversationStore.getState().reset();
18
+ useUserStore.getState().reset();
18
19
  return OpenIMSDK.logout(operationID);
19
20
  };
20
21
  }
@@ -191,7 +191,7 @@ export const useMessageStore = create<MessageStore>()((set, get) => ({
191
191
  hasMoreNew: !searchNextRes.isEnd,
192
192
  initLoading: false,
193
193
  });
194
- if (currentMessages?.length > 0) {
194
+ if (newMessages?.length > 0) {
195
195
  await new Promise((resolve) => setTimeout(resolve, 300));
196
196
  DeviceEventEmitter.emit(ChatEvent.ScrollToMessage, {
197
197
  clientMessageId: startClientMsgId,
package/src/store/user.ts CHANGED
@@ -11,6 +11,7 @@ export interface UserStore {
11
11
  updateConnectStatus: (status: ConnectStatus) => void;
12
12
  syncStatus: SyncStatus;
13
13
  updateSyncStatus: (status: SyncStatus) => void;
14
+ reset: () => void;
14
15
  }
15
16
 
16
17
  export const useUserStore = create<UserStore>()((set) => ({
@@ -28,4 +29,10 @@ export const useUserStore = create<UserStore>()((set) => ({
28
29
  updateSyncStatus(syncStatus) {
29
30
  set({ syncStatus });
30
31
  },
32
+ reset() {
33
+ set({
34
+ user: undefined,
35
+ token: undefined,
36
+ });
37
+ },
31
38
  }));
@@ -35,6 +35,8 @@ const i18nKey: Record<string, string> = {
35
35
  'chat:copy_message_link': 'Sao chép đường dẫn tin nhắn',
36
36
  'chat:copy_message_success': 'Sao chép tin nhắn thành công',
37
37
  'chat:copy_message_link_success': 'Sao chép đường dẫn tin nhắn thành công',
38
+ 'chat:target_message_unavailable':
39
+ 'Tin nhắn có thể đã bị xoá và không còn tồn tại',
38
40
  'chat:retry_message': 'Gửi lại',
39
41
  'chat:revoke_message': 'Thu hồi',
40
42
  'chat:pin_message': 'Ghim',
package/src/types/chat.ts CHANGED
@@ -15,6 +15,7 @@ import type {
15
15
  IRunConversationFlowPayload,
16
16
  } from './flows';
17
17
  import type { GetOpenIMTokenResponse } from './auth';
18
+ import type { ChatDetailProps } from '../screens/chat-detail';
18
19
 
19
20
  export const EventProvider = {
20
21
  ga: 'ga',
@@ -35,6 +36,17 @@ export interface ChatUIAdapter {
35
36
  bottomSheet?: { open: (params: any) => void; dismiss: () => void };
36
37
  }
37
38
 
39
+ export type GenerateChatDetailLinkFn = (
40
+ params: Pick<
41
+ ChatDetailProps,
42
+ | 'targetConversationId'
43
+ | 'targetUserId'
44
+ | 'targetGroupId'
45
+ | 'targetBotId'
46
+ | 'targetMessageId'
47
+ >
48
+ ) => string;
49
+
38
50
  export interface ChatContextType {
39
51
  logGA?: LogGA;
40
52
  applicationType: DChatApplicationType;
@@ -42,6 +54,7 @@ export interface ChatContextType {
42
54
  isShowFLashMessage: boolean;
43
55
  setIsShowFLashMessage: (value: boolean) => void;
44
56
  handleLoginSdk: () => void;
57
+ generateChatDetailLink?: GenerateChatDetailLinkFn;
45
58
  }
46
59
 
47
60
  export type ChatProviderProps = PropsWithChildren<{
@@ -49,6 +62,7 @@ export type ChatProviderProps = PropsWithChildren<{
49
62
  applicationType: DChatApplicationType;
50
63
  getChatToken: () => Promise<GetOpenIMTokenResponse | null>;
51
64
  onFlashMessagePress?: (c: ConversationItem) => void;
65
+ generateChatDetailLink?: GenerateChatDetailLinkFn;
52
66
  enabled?: boolean;
53
67
  }>;
54
68