@droppii-org/chat-mobile 0.2.93 → 0.2.95

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 (28) hide show
  1. package/lib/module/screens/chat-detail/ChatDetail.js +15 -11
  2. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  3. package/lib/module/screens/chat-detail/ChatListLegend.js +233 -74
  4. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  5. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +16 -9
  6. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
  7. package/lib/module/store/message.js +9 -5
  8. package/lib/module/store/message.js.map +1 -1
  9. package/lib/module/types/events.js +1 -0
  10. package/lib/module/types/events.js.map +1 -1
  11. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  12. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  13. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
  14. package/lib/typescript/src/screens/chat-detail/messageToolbox/types.d.ts +3 -1
  15. package/lib/typescript/src/screens/chat-detail/messageToolbox/types.d.ts.map +1 -1
  16. package/lib/typescript/src/screens/chat-detail/types.d.ts +2 -2
  17. package/lib/typescript/src/screens/chat-detail/types.d.ts.map +1 -1
  18. package/lib/typescript/src/store/message.d.ts.map +1 -1
  19. package/lib/typescript/src/types/events.d.ts +2 -1
  20. package/lib/typescript/src/types/events.d.ts.map +1 -1
  21. package/package.json +1 -1
  22. package/src/screens/chat-detail/ChatDetail.tsx +23 -14
  23. package/src/screens/chat-detail/ChatListLegend.tsx +249 -73
  24. package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +26 -7
  25. package/src/screens/chat-detail/messageToolbox/types.ts +8 -1
  26. package/src/screens/chat-detail/types.ts +2 -2
  27. package/src/store/message.ts +14 -5
  28. package/src/types/events.ts +1 -0
@@ -10,7 +10,6 @@ import {
10
10
  import {
11
11
  ActivityIndicator,
12
12
  DeviceEventEmitter,
13
- Platform,
14
13
  StyleSheet,
15
14
  useWindowDimensions,
16
15
  type View,
@@ -23,7 +22,10 @@ import Animated, {
23
22
  type SharedValue,
24
23
  } from 'react-native-reanimated';
25
24
  import { KeyboardAwareLegendList } from '@legendapp/list/keyboard';
26
- import { KeyboardEvents } from 'react-native-keyboard-controller';
25
+ import {
26
+ KeyboardEvents,
27
+ useGenericKeyboardHandler,
28
+ } from 'react-native-keyboard-controller';
27
29
  import {
28
30
  type LegendListRef,
29
31
  type MaintainVisibleContentPositionConfig,
@@ -78,10 +80,36 @@ const ROW_MARGIN_IN_CLUSTER = KSpacingValue['0.25rem'];
78
80
 
79
81
  const BUTTON_FADE_CONFIG = { duration: 200, easing: Easing.out(Easing.ease) };
80
82
 
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;
83
+ const CONTENT_PADDING_VERTICAL = KSpacingValue['0.75rem'];
84
+
85
+ // How long past the duration the keyboard event reported the following loop
86
+ // keeps waiting for the live height to actually reach its target before giving
87
+ // up and snapping to it. Only a fallback: normally the loop ends the frame the
88
+ // keyboard does.
89
+ const FOLLOW_FALLBACK_MS = 120;
90
+
91
+ // How long the keyboard-following loop keeps claiming the list after its last
92
+ // frame. That frame's `setState` renders, lays out and emits its scroll event
93
+ // after the loop has returned, and that trailing event carries the same
94
+ // half-applied geometry the rest of the transition does.
95
+ const FOLLOW_SETTLE_MS = 200;
96
+
97
+ // Frames the keyboard-following loop keeps pinning after the reserved room has
98
+ // reached its target. The pin in the frame that sets the final padding runs
99
+ // before React has rendered it and before the native layout has applied, so it
100
+ // finds no room to scroll into. On iOS that goes unnoticed — the loop has many
101
+ // frames and a later one does the work — but Android's first loop frame
102
+ // already reports the finished height, so without a tail nothing ever scrolls
103
+ // and the last message stays behind the keyboard.
104
+ const FOLLOW_TAIL_FRAMES = 3;
105
+
106
+ // Passed as the list's `keyboardOffset` to switch KeyboardChatScrollView's own
107
+ // native `contentInset` off while leaving the rest of the component in place.
108
+ // Its `getEffectiveHeight` interpolates the live keyboard height into
109
+ // `[0, max(keyboardHeight - offset, 0)]`, so an offset no keyboard can exceed
110
+ // pins the inset at 0 for every frame of the transition. The keyboard's room is
111
+ // reserved as real content padding instead — see the keyboard effect below.
112
+ const DISABLE_NATIVE_KEYBOARD_INSET = 1_000_000;
85
113
 
86
114
  const getRowMarginBottom = (position: GroupPosition): number =>
87
115
  position === 'last' || position === 'solo'
@@ -149,10 +177,37 @@ export const ChatListLegend = memo(
149
177
  const buttonOpacity = useSharedValue(0);
150
178
  const isAtBottomRef = useRef(true);
151
179
  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.
180
+ // The state drives the content's bottom padding; the ref is the last
181
+ // value acted on, read synchronously inside the keyboard listener to
182
+ // drop Android's repeat events before they reach React.
183
+ const keyboardInsetRef = useRef(0);
154
184
  const [keyboardInset, setKeyboardInset] = useState(0);
155
- const hasSeenFirstKeyboardInsetRef = useRef(false);
185
+ // The keyboard's live height, written on the UI thread and sampled from
186
+ // the JS thread by the tween below, so the content tracks the keyboard's
187
+ // real position instead of a guessed easing curve.
188
+ //
189
+ // `useGenericKeyboardHandler` rather than `useKeyboardHandler` /
190
+ // `useReanimatedKeyboardAnimation`: those call `useResizeMode()`, which
191
+ // reaches in and sets Android's soft input mode — `KeyboardProvider`
192
+ // already owns that here. The worklet only writes a SharedValue; calling
193
+ // `runOnJS` (or anything that reaches JS) from a keyboard worklet
194
+ // reliably crashes Reanimated with a reentrant-lock assertion.
195
+ // True while the keyboard-following loop below is driving the list.
196
+ const isFollowingKeyboardRef = useRef(false);
197
+ const liveKeyboardHeight = useSharedValue(0);
198
+ useGenericKeyboardHandler(
199
+ {
200
+ onMove: (e) => {
201
+ 'worklet';
202
+ liveKeyboardHeight.value = e.height;
203
+ },
204
+ onEnd: (e) => {
205
+ 'worklet';
206
+ liveKeyboardHeight.value = e.height;
207
+ },
208
+ },
209
+ []
210
+ );
156
211
  const rowRefsMap = useRef<Map<string, View | null>>(new Map());
157
212
  // Set on a real touch-drag (`onScrollBeginDrag`), before onStartReached/
158
213
  // onEndReached's threshold could fire. `onMomentumScrollBegin` was tried
@@ -199,10 +254,11 @@ export const ChatListLegend = memo(
199
254
  const node = rowRefsMap.current.get(messageId);
200
255
  node?.measureInWindow((x, y, width, height) => {
201
256
  if (x == null || y == null || width == null || height == null) return;
202
- onLongPress?.(message, { x, y, width, height });
257
+ const groupPosition = messageDataMap.get(messageId)?.groupPosition;
258
+ onLongPress?.(message, { x, y, width, height }, groupPosition);
203
259
  });
204
260
  },
205
- [onLongPress]
261
+ [onLongPress, messageDataMap]
206
262
  );
207
263
 
208
264
  const onRunFlow = useCallback(
@@ -490,6 +546,15 @@ export const ChatListLegend = memo(
490
546
 
491
547
  const handleScroll = useCallback(
492
548
  (e: any) => {
549
+ // The keyboard-following loop re-pins to the end on every frame, so
550
+ // the list is at the bottom for the whole transition by construction.
551
+ // The intermediate events it produces are not: content size and
552
+ // offset move a frame apart, and a reading taken between them says
553
+ // "not at bottom" and latches the scroll-to-bottom button on. The
554
+ // loop only runs when the list was already at the bottom, so there is
555
+ // nothing here to detect while it does.
556
+ if (isFollowingKeyboardRef.current) return;
557
+
493
558
  const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
494
559
  const isAtBottom =
495
560
  contentOffset.y + layoutMeasurement.height >=
@@ -532,6 +597,17 @@ export const ChatListLegend = memo(
532
597
  setShowBadge(false);
533
598
  }, [hasMoreNew, onLoadMoreOld, scrollMessageToEnd, buttonOpacity]);
534
599
 
600
+ // `paddingBottom` here replaces the one `styles.content` sets via
601
+ // `paddingVertical`, so the base spacing has to be added back or the last
602
+ // message ends up flush against the composer.
603
+ const contentContainerStyle = useMemo(
604
+ () => [
605
+ styles.content,
606
+ { paddingBottom: CONTENT_PADDING_VERTICAL + keyboardInset },
607
+ ],
608
+ [keyboardInset]
609
+ );
610
+
535
611
  const animatedButtonStyle = useAnimatedStyle(() => ({
536
612
  opacity: buttonOpacity.value,
537
613
  pointerEvents: buttonOpacity.value === 0 ? 'none' : 'auto',
@@ -546,72 +622,167 @@ export const ChatListLegend = memo(
546
622
  };
547
623
  }, []);
548
624
 
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.
625
+ // Reserves the keyboard's room on the content and keeps the list pinned
626
+ // to the end for the length of each keyboard transition.
556
627
  //
557
- // JS-thread only (no worklet), so none of the reentrant-lock crash risk
558
- // that reaching into `useKeyboardHandler`'s worklets carries.
628
+ // JS thread only: no worklet, so none of the reentrant-lock crash risk
629
+ // that reaching into `useKeyboardHandler`'s worklets carries. The one
630
+ // worklet involved only writes `liveKeyboardHeight`, which this reads.
559
631
  useEffect(() => {
560
- let pendingHide: ReturnType<typeof setTimeout> | undefined;
561
- const clearPendingHide = () => {
562
- if (pendingHide) {
563
- clearTimeout(pendingHide);
564
- pendingHide = undefined;
632
+ let pendingSettle: ReturnType<typeof setTimeout> | undefined;
633
+ let pendingFrame: number | undefined;
634
+ const clearPending = () => {
635
+ if (pendingSettle) {
636
+ clearTimeout(pendingSettle);
637
+ pendingSettle = undefined;
638
+ }
639
+ if (pendingFrame !== undefined) {
640
+ cancelAnimationFrame(pendingFrame);
641
+ pendingFrame = undefined;
565
642
  }
643
+ isFollowingKeyboardRef.current = false;
566
644
  };
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
645
+
646
+ // Follows the keyboard frame by frame instead of setting the reserved
647
+ // room in one step.
648
+ //
649
+ // Set in one step the layout lift lands in a single frame — measured,
650
+ // 72 of the 302px the content had to travel arrived as an instant jump
651
+ // while only the remaining 230 were animated, and `scrollToEnd`'s own
652
+ // ~324ms curve then finished about 100ms after the keyboard had already
653
+ // stopped. Sampling `liveKeyboardHeight` each frame instead means the
654
+ // padding, and with it the content, is wherever the keyboard actually
655
+ // is — no easing curve to guess at, and the two cannot drift apart.
656
+ //
657
+ // `duration` is only a bound, for a transition whose reported height
658
+ // never quite reaches the event's target.
659
+ const followKeyboard = (to: number, duration: number) => {
660
+ const startedAt = Date.now();
661
+ const startedFrom = Math.max(
662
+ Math.abs(liveKeyboardHeight.value) - keyboardOffset,
663
+ 0
664
+ );
665
+ let tailFrames = 0;
666
+ isFollowingKeyboardRef.current = true;
667
+ const step = () => {
668
+ const live = Math.max(
669
+ Math.abs(liveKeyboardHeight.value) - keyboardOffset,
670
+ 0
579
671
  );
672
+ // Tracks the keyboard rather than a timer, so the content never
673
+ // keeps travelling after the keyboard has stopped. The elapsed
674
+ // check is only a fallback for a transition whose reported height
675
+ // never quite reaches the target.
676
+ const reached = Math.abs(live - to) <= 1;
677
+ const timedOut =
678
+ !reached && Date.now() - startedAt >= duration + FOLLOW_FALLBACK_MS;
679
+ // Whether the keyboard moved at all decides what a timeout means. A
680
+ // `keyboardWillHide` the keyboard does not act on leaves the live
681
+ // height exactly where it was, and forcing the target there would
682
+ // collapse the room out from under a keyboard that is still up —
683
+ // which is what the old hide debounce was guarding against, at the
684
+ // cost of the list dropping a beat after the keyboard had gone. A
685
+ // real transition that merely stops a few px short of the height
686
+ // the event reported (iOS, measured ~4px) still has to land exactly
687
+ // on the target.
688
+ const moved = Math.abs(live - startedFrom) > 1;
689
+ if (reached) {
690
+ tailFrames += 1;
691
+ }
692
+ const done = timedOut || tailFrames >= FOLLOW_TAIL_FRAMES;
693
+ const settleTo = reached || (timedOut && moved) ? to : live;
694
+
695
+ setKeyboardInset(settleTo);
696
+ if (settleTo !== to) {
697
+ // Record what the room actually ended up as, or the dedupe in
698
+ // `applyInset` would swallow the next real transition to `to`.
699
+ keyboardInsetRef.current = settleTo;
700
+ }
701
+ // Re-pin every frame. Growing the padding only moves the content on
702
+ // its own while there is `alignItemsAtEndPadding` left to absorb
703
+ // it; once that is gone — any conversation taller than the viewport
704
+ // — the padding just extends the range and nothing moves until
705
+ // something scrolls, and `maintainScrollAtEnd` does not follow a
706
+ // content size change frame by frame (measured: one scroll event,
707
+ // at the very end). Unanimated on purpose: the keyboard is the
708
+ // animation, and an animated scroll here would start a second curve
709
+ // on top of it every frame.
710
+ listRef.current?.scrollToEnd({ animated: false });
711
+
712
+ if (done) {
713
+ pendingFrame = undefined;
714
+ // The loop only runs from a list that was at the bottom and it
715
+ // has just pinned it there again, so record that rather than
716
+ // waiting to re-derive it from a scroll event.
717
+ isAtBottomRef.current = true;
718
+ buttonOpacity.value = withTiming(0, BUTTON_FADE_CONFIG);
719
+ setShowBadge(false);
720
+ pendingSettle = setTimeout(() => {
721
+ pendingSettle = undefined;
722
+ isFollowingKeyboardRef.current = false;
723
+ }, FOLLOW_SETTLE_MS);
724
+ return;
725
+ }
726
+ pendingFrame = requestAnimationFrame(step);
727
+ };
728
+ pendingFrame = requestAnimationFrame(step);
729
+ };
730
+
731
+ const applyInset = (nextInset: number, duration: number) => {
732
+ // Android's event stream flaps willShow -> willHide -> willShow every
733
+ // ~1.3s for as long as the keyboard stays open (measured), so a
734
+ // repeated height must not restart the follow loop.
735
+ if (keyboardInsetRef.current === nextInset) return;
736
+ keyboardInsetRef.current = nextInset;
737
+
738
+ // Reserve the keyboard's room as real bottom padding on the content
739
+ // and let LegendList own it end to end. The native `contentInset` is
740
+ // switched off (see `keyboardOffset` on the list below), so this is
741
+ // the only thing compensating for the keyboard.
742
+ //
743
+ // The point is an invariant, not an offset: the scroll view's
744
+ // maximum content offset has to BE the visually correct position.
745
+ // Otherwise there is a band of legal resting offsets that all look
746
+ // wrong, and a drag settles somewhere inside it — which is the blank
747
+ // gap above the composer.
748
+ //
749
+ // A native inset cannot hold that invariant here. It widens the
750
+ // scroll range by the full keyboard height, but LegendList
751
+ // independently shrinks `alignItemsAtEndPadding` by the same amount
752
+ // and lifts the content through layout as well. Measured on a short
753
+ // conversation: the layout lift covered 258 of the 302 needed, so
754
+ // only 44 of scrolling was left to do while the range still ran to
755
+ // 302 — 258px of pure slack. As content padding the same 302 goes
756
+ // into `contentSize`, so `alignItemsAtEndPadding` falls out to zero,
757
+ // the range is exactly the lift required, and `scrollToEnd` (which
758
+ // reads LegendList's own metrics, and already offsets itself by
759
+ // `stylePaddingBottom`) lands on it.
760
+ //
761
+ // Skipped when the user has scrolled away from the bottom — opening
762
+ // the keyboard shouldn't yank them back down — but the padding still
763
+ // has to change, or the range would be wrong for them too.
764
+ if (!isAtBottomRef.current) {
765
+ setKeyboardInset(nextInset);
766
+ return;
580
767
  }
581
- setKeyboardInset(nextInset);
768
+ followKeyboard(nextInset, duration);
582
769
  };
583
770
 
584
771
  const willShow = KeyboardEvents.addListener('keyboardWillShow', (e) => {
585
- clearPendingHide();
586
- applyInset(Math.max(e.height - keyboardOffset, 0));
772
+ clearPending();
773
+ applyInset(Math.max(e.height - keyboardOffset, 0), e.duration);
587
774
  });
588
- const willHide = KeyboardEvents.addListener('keyboardWillHide', () => {
589
- clearPendingHide();
590
- pendingHide = setTimeout(
591
- () => applyInset(0),
592
- KEYBOARD_HIDE_DEBOUNCE_MS
593
- );
775
+ const willHide = KeyboardEvents.addListener('keyboardWillHide', (e) => {
776
+ clearPending();
777
+ applyInset(0, e.duration);
594
778
  });
779
+
595
780
  return () => {
596
- clearPendingHide();
781
+ clearPending();
597
782
  willShow.remove();
598
783
  willHide.remove();
599
784
  };
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]);
785
+ }, [buttonOpacity, keyboardOffset, listRef, liveKeyboardHeight]);
615
786
 
616
787
  // This component can stay mounted across a conversationId change (e.g.
617
788
  // navigation.replace reusing the same screen instance), so this ref must
@@ -748,21 +919,26 @@ export const ChatListLegend = memo(
748
919
  // fetch instead of the default 250px window losing it.
749
920
  drawDistance={800}
750
921
  freeze={freeze}
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.
922
+ keyboardOffset={DISABLE_NATIVE_KEYBOARD_INSET}
923
+ // iOS only. Left on, React Native recomputes and applies its own
924
+ // `contentInset` every time the keyboard moves — measured here as
925
+ // {top: 59, bottom: keyboardHeight}, both wrong. The bottom value
926
+ // over-insets by the safe area, and the phantom top one is worse:
927
+ // iOS rests a scroll view at `-contentInset.top`.
928
+ // That resting offset is what put the content 59pt low and hid the
929
+ // last message behind the composer the moment a touch let UIKit
930
+ // re-clamp. legend-list's own KeyboardAvoidingLegendList disables
931
+ // this for the same reason.
932
+ automaticallyAdjustContentInsets={false}
933
+ // No scroll shift from the library either — the content padding
934
+ // above is the single compensator. Measured with it on: a 302px
935
+ // keyboard produced a 604px lift, exactly double.
760
936
  keyboardLiftBehavior="never"
761
937
  scrollEventThrottle={16}
762
938
  onScroll={handleScroll}
763
939
  onScrollBeginDrag={handleScrollBeginDrag}
764
940
  onLoad={handleListLoad}
765
- contentContainerStyle={styles.content}
941
+ contentContainerStyle={contentContainerStyle}
766
942
  scrollIndicatorInsets={{ right: 1 }}
767
943
  ListHeaderComponent={
768
944
  <LegendChatLoadEarlier isLoading={moreOldLoading} />
@@ -810,7 +986,7 @@ const styles = StyleSheet.create({
810
986
  position: 'relative',
811
987
  },
812
988
  content: {
813
- paddingVertical: KSpacingValue['0.75rem'],
989
+ paddingVertical: CONTENT_PADDING_VERTICAL,
814
990
  flexGrow: 1,
815
991
  },
816
992
  scrollToBottomButton: {
@@ -17,7 +17,11 @@ import {
17
17
  getCopyableMessageText,
18
18
  getMessageToolboxItems,
19
19
  } from './messageToolbox.utils';
20
- import type { MeasuredRect, MessageToolboxRef } from './types';
20
+ import type {
21
+ MeasuredRect,
22
+ MessageToolboxRef,
23
+ MessageToolboxShowFn,
24
+ } from './types';
21
25
  import { MessageToolboxWrapper } from './MessageToolboxWrapper';
22
26
  import type { ReactNode } from 'react';
23
27
  import type { SharedValue } from 'react-native-reanimated';
@@ -28,6 +32,7 @@ import {
28
32
  type MessageItem,
29
33
  } from '@droppii/openim-rn-client-sdk';
30
34
  import { useHasGroupPermission } from '../../../hooks/useGroupPermission';
35
+ import type { GroupPosition } from '../../../utils/legendListMessage';
31
36
 
32
37
  // Android only: this Modal's native window is added/removed asynchronously
33
38
  // relative to its React state, and each of those transitions makes
@@ -64,11 +69,15 @@ export const MessageToolboxOverlay = forwardRef<
64
69
  },
65
70
  ref
66
71
  ) => {
67
- const { setQuotedMessage, conversation } = useChatDetailContext();
72
+ const { setQuotedMessage, conversation, isGroupConversation } =
73
+ useChatDetailContext();
68
74
  const { generateChatDetailLink } = useChatContext();
69
75
  const [visible, setVisible] = useState(false);
70
76
  const [message, setMessage] = useState<MessageItem | undefined>(undefined);
71
77
  const [rect, setRect] = useState<MeasuredRect | null>(null);
78
+ const [groupPosition, setGroupPosition] = useState<
79
+ GroupPosition | undefined
80
+ >(undefined);
72
81
  const toolboxItems = useMemo(
73
82
  () => getMessageToolboxItems(message, currentUserId),
74
83
  [message, currentUserId]
@@ -92,6 +101,7 @@ export const MessageToolboxOverlay = forwardRef<
92
101
  setVisible(false);
93
102
  setMessage(undefined);
94
103
  setRect(null);
104
+ setGroupPosition(undefined);
95
105
  if (freeze) {
96
106
  clearPendingUnfreeze();
97
107
  unfreezeTimeoutRef.current = setTimeout(() => {
@@ -101,8 +111,8 @@ export const MessageToolboxOverlay = forwardRef<
101
111
  }
102
112
  }, [freeze, clearPendingUnfreeze]);
103
113
 
104
- const show = useCallback(
105
- (msg: MessageItem, msgRect: MeasuredRect) => {
114
+ const show = useCallback<MessageToolboxShowFn>(
115
+ (msg, msgRect, msgGroupPosition) => {
106
116
  const toolboxNotAvailable =
107
117
  Object.values(getMessageToolboxItems(msg, currentUserId)).filter(
108
118
  Boolean
@@ -119,6 +129,7 @@ export const MessageToolboxOverlay = forwardRef<
119
129
 
120
130
  setMessage(msg);
121
131
  setRect(msgRect);
132
+ setGroupPosition(msgGroupPosition);
122
133
  setVisible(true);
123
134
  },
124
135
  [currentUserId, freeze, clearPendingUnfreeze]
@@ -145,9 +156,6 @@ export const MessageToolboxOverlay = forwardRef<
145
156
  const link = generateChatDetailLink({
146
157
  targetConversationId: conversation?.conversationID,
147
158
  targetMessageId: message.clientMsgID,
148
- targetGroupId: conversation?.groupID,
149
- targetUserId: conversation?.userID,
150
- // targetBotId: '',
151
159
  });
152
160
  Clipboard.setString(link);
153
161
 
@@ -206,6 +214,13 @@ export const MessageToolboxOverlay = forwardRef<
206
214
  }
207
215
 
208
216
  const isOutgoing = message.sendID === currentUserId;
217
+ const showSenderGutter = isGroupConversation && !isOutgoing;
218
+ const showName =
219
+ showSenderGutter &&
220
+ (groupPosition === 'first' || groupPosition === 'solo');
221
+ const showAvatar =
222
+ showSenderGutter &&
223
+ (groupPosition === 'last' || groupPosition === 'solo');
209
224
 
210
225
  return (
211
226
  <Modal
@@ -227,6 +242,10 @@ export const MessageToolboxOverlay = forwardRef<
227
242
  isOutgoing={isOutgoing}
228
243
  createdAtTime={message.sendTime || message.createTime}
229
244
  customMessageItemFactory={customMessageItemFactory}
245
+ showSenderGutter={showSenderGutter}
246
+ showAvatar={showAvatar}
247
+ showName={showName}
248
+ groupPosition={groupPosition}
230
249
  />
231
250
  }
232
251
  toolbox={
@@ -1,4 +1,5 @@
1
1
  import type { MessageItem } from '@droppii/openim-rn-client-sdk';
2
+ import type { GroupPosition } from '../../../utils/legendListMessage';
2
3
 
3
4
  export interface MeasuredRect {
4
5
  x: number;
@@ -7,7 +8,13 @@ export interface MeasuredRect {
7
8
  height: number;
8
9
  }
9
10
 
11
+ export type MessageToolboxShowFn = (
12
+ message: MessageItem,
13
+ rect: MeasuredRect,
14
+ groupPosition?: GroupPosition
15
+ ) => void;
16
+
10
17
  export interface MessageToolboxRef {
11
- show: (message: MessageItem, rect: MeasuredRect) => void;
18
+ show: MessageToolboxShowFn;
12
19
  hide: () => void;
13
20
  }
@@ -1,6 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { MediaItem } from '../../hooks/useMediaViewer';
3
- import type { MeasuredRect } from './messageToolbox/types';
3
+ import type { MessageToolboxShowFn } from './messageToolbox/types';
4
4
  import type {
5
5
  GroupJoinSource,
6
6
  MessageItem,
@@ -86,7 +86,7 @@ export interface ChatListProps {
86
86
  hasMoreNew?: boolean;
87
87
  onMediaPress?: (items: MediaItem[], index?: number) => void;
88
88
  onPressUrl?: (url: string) => void;
89
- onLongPress?: (message: MessageItem, rect: MeasuredRect) => void;
89
+ onLongPress?: MessageToolboxShowFn;
90
90
  }
91
91
 
92
92
  export interface ChatDetailProps extends Omit<
@@ -12,6 +12,7 @@ import type {
12
12
  MessageStore,
13
13
  } from '../types/chat';
14
14
  import { mergeMessages } from '../utils/message';
15
+ import { isMessageRevoked } from '../utils/messageUtils';
15
16
  import { useConversationStore } from './conversation';
16
17
 
17
18
  // Smaller than a typical "load more" page size elsewhere in the app on
@@ -191,12 +192,20 @@ export const useMessageStore = create<MessageStore>()((set, get) => ({
191
192
  hasMoreNew: !searchNextRes.isEnd,
192
193
  initLoading: false,
193
194
  });
194
- if (newMessages?.length > 0) {
195
- await new Promise((resolve) => setTimeout(resolve, 300));
196
- DeviceEventEmitter.emit(ChatEvent.ScrollToMessage, {
197
- clientMessageId: startClientMsgId,
198
- });
195
+
196
+ const targetMessage = currentMessages.find(
197
+ (msg) => msg.clientMsgID === startClientMsgId
198
+ );
199
+
200
+ if (!targetMessage || isMessageRevoked(targetMessage)) {
201
+ DeviceEventEmitter.emit(ChatEvent.TargetMessageUnavailable);
202
+ return;
199
203
  }
204
+
205
+ await new Promise((resolve) => setTimeout(resolve, 300));
206
+ DeviceEventEmitter.emit(ChatEvent.ScrollToMessage, {
207
+ clientMessageId: startClientMsgId,
208
+ });
200
209
  }
201
210
  } catch (error) {
202
211
  console.error('Failed to search messages', error);
@@ -6,6 +6,7 @@ export enum ChatEvent {
6
6
  MessageRevoked = 'chat:message_revoked',
7
7
  GroupPermissionUpdated = 'chat:group_permission_updated',
8
8
  GroupRemoved = 'chat:group_removed',
9
+ TargetMessageUnavailable = 'chat:target_message_unavailable',
9
10
  }
10
11
 
11
12
  export interface ScrollToMessagePayload {