@droppii-org/chat-mobile 0.2.106 → 0.2.108

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/lib/module/components/ThreadCard/ThreadCard.js +2 -0
  2. package/lib/module/components/ThreadCard/ThreadCard.js.map +1 -1
  3. package/lib/module/components/messages/ExpandableMessageText.js +112 -0
  4. package/lib/module/components/messages/ExpandableMessageText.js.map +1 -0
  5. package/lib/module/components/messages/PreviewScrollableText.js +80 -0
  6. package/lib/module/components/messages/PreviewScrollableText.js.map +1 -0
  7. package/lib/module/components/messages/fileMessage/index.js +9 -2
  8. package/lib/module/components/messages/fileMessage/index.js.map +1 -1
  9. package/lib/module/components/messages/linkMessage/index.js +21 -12
  10. package/lib/module/components/messages/linkMessage/index.js.map +1 -1
  11. package/lib/module/components/messages/quotedMessage/index.js +18 -3
  12. package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
  13. package/lib/module/components/messages/textMessage/index.js +7 -1
  14. package/lib/module/components/messages/textMessage/index.js.map +1 -1
  15. package/lib/module/components/messages/types.js.map +1 -1
  16. package/lib/module/context/TimelinePressGuardContext.js +14 -0
  17. package/lib/module/context/TimelinePressGuardContext.js.map +1 -0
  18. package/lib/module/hooks/useConversationList.js +4 -0
  19. package/lib/module/hooks/useConversationList.js.map +1 -1
  20. package/lib/module/hooks/useDismissKeyboardFirst.js +19 -0
  21. package/lib/module/hooks/useDismissKeyboardFirst.js.map +1 -0
  22. package/lib/module/hooks/useExpandableText.js +125 -0
  23. package/lib/module/hooks/useExpandableText.js.map +1 -0
  24. package/lib/module/hooks/useGuardedPress.js +19 -0
  25. package/lib/module/hooks/useGuardedPress.js.map +1 -0
  26. package/lib/module/screens/chat-detail/ChatComposer.js +89 -498
  27. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  28. package/lib/module/screens/chat-detail/ChatDetail.js +49 -20
  29. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  30. package/lib/module/screens/chat-detail/ChatListLegend.js +113 -76
  31. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  32. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js +7 -0
  33. package/lib/module/screens/chat-detail/PinnedMessagesSheet.js.map +1 -1
  34. package/lib/module/screens/chat-detail/components/ChatComposerAccessorySlot.js +47 -0
  35. package/lib/module/screens/chat-detail/components/ChatComposerAccessorySlot.js.map +1 -0
  36. package/lib/module/screens/chat-detail/components/ChatComposerInputArea.js +149 -0
  37. package/lib/module/screens/chat-detail/components/ChatComposerInputArea.js.map +1 -0
  38. package/lib/module/screens/chat-detail/components/ChatComposerStatusBanner.js +104 -0
  39. package/lib/module/screens/chat-detail/components/ChatComposerStatusBanner.js.map +1 -0
  40. package/lib/module/screens/chat-detail/hooks/useChatComposerAnimation.js +0 -17
  41. package/lib/module/screens/chat-detail/hooks/useChatComposerAnimation.js.map +1 -1
  42. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js +16 -2
  43. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js.map +1 -1
  44. package/lib/module/screens/chat-detail/hooks/useComposerBotMenu.js +122 -0
  45. package/lib/module/screens/chat-detail/hooks/useComposerBotMenu.js.map +1 -0
  46. package/lib/module/screens/chat-detail/hooks/useComposerPanelActions.js +46 -0
  47. package/lib/module/screens/chat-detail/hooks/useComposerPanelActions.js.map +1 -0
  48. package/lib/module/screens/chat-detail/hooks/useComposerPanelDismiss.js +75 -0
  49. package/lib/module/screens/chat-detail/hooks/useComposerPanelDismiss.js.map +1 -0
  50. package/lib/module/screens/chat-detail/hooks/useComposerPanels.js +136 -0
  51. package/lib/module/screens/chat-detail/hooks/useComposerPanels.js.map +1 -0
  52. package/lib/module/screens/chat-detail/hooks/useComposerSend.js +74 -0
  53. package/lib/module/screens/chat-detail/hooks/useComposerSend.js.map +1 -0
  54. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +12 -3
  55. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  56. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js +11 -2
  57. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxOverlay.js.map +1 -1
  58. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js +79 -6
  59. package/lib/module/screens/chat-detail/messageToolbox/MessageToolboxWrapper.js.map +1 -1
  60. package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js +30 -0
  61. package/lib/module/screens/chat-detail/messageToolbox/useClonePreview.js.map +1 -0
  62. package/lib/module/screens/chat-detail/types.js +5 -0
  63. package/lib/module/screens/chat-detail/types.js.map +1 -1
  64. package/lib/module/store/expandedMessage.js +28 -0
  65. package/lib/module/store/expandedMessage.js.map +1 -0
  66. package/lib/module/store/message.js +14 -0
  67. package/lib/module/store/message.js.map +1 -1
  68. package/lib/module/utils/composerPanelDismissGesture.js +52 -0
  69. package/lib/module/utils/composerPanelDismissGesture.js.map +1 -0
  70. package/lib/module/utils/expandableText.js +41 -0
  71. package/lib/module/utils/expandableText.js.map +1 -0
  72. package/lib/module/utils/inlineTruncation.js +51 -0
  73. package/lib/module/utils/inlineTruncation.js.map +1 -0
  74. package/lib/module/utils/perf-marker.js +44 -0
  75. package/lib/module/utils/perf-marker.js.map +1 -0
  76. package/lib/module/utils/truncateTextChildren.js +38 -0
  77. package/lib/module/utils/truncateTextChildren.js.map +1 -0
  78. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  79. package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts +25 -0
  80. package/lib/typescript/src/components/messages/ExpandableMessageText.d.ts.map +1 -0
  81. package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts +29 -0
  82. package/lib/typescript/src/components/messages/PreviewScrollableText.d.ts.map +1 -0
  83. package/lib/typescript/src/components/messages/fileMessage/index.d.ts.map +1 -1
  84. package/lib/typescript/src/components/messages/linkMessage/index.d.ts +1 -1
  85. package/lib/typescript/src/components/messages/linkMessage/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
  87. package/lib/typescript/src/components/messages/textMessage/index.d.ts +1 -1
  88. package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/messages/types.d.ts +18 -0
  90. package/lib/typescript/src/components/messages/types.d.ts.map +1 -1
  91. package/lib/typescript/src/context/TimelinePressGuardContext.d.ts +3 -0
  92. package/lib/typescript/src/context/TimelinePressGuardContext.d.ts.map +1 -0
  93. package/lib/typescript/src/hooks/useConversationList.d.ts.map +1 -1
  94. package/lib/typescript/src/hooks/useDismissKeyboardFirst.d.ts +8 -0
  95. package/lib/typescript/src/hooks/useDismissKeyboardFirst.d.ts.map +1 -0
  96. package/lib/typescript/src/hooks/useExpandableText.d.ts +13 -0
  97. package/lib/typescript/src/hooks/useExpandableText.d.ts.map +1 -0
  98. package/lib/typescript/src/hooks/useGuardedPress.d.ts +9 -0
  99. package/lib/typescript/src/hooks/useGuardedPress.d.ts.map +1 -0
  100. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts +7 -3
  101. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  102. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  103. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +5 -1
  104. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  105. package/lib/typescript/src/screens/chat-detail/PinnedMessagesSheet.d.ts.map +1 -1
  106. package/lib/typescript/src/screens/chat-detail/components/ChatComposerAccessorySlot.d.ts +12 -0
  107. package/lib/typescript/src/screens/chat-detail/components/ChatComposerAccessorySlot.d.ts.map +1 -0
  108. package/lib/typescript/src/screens/chat-detail/components/ChatComposerInputArea.d.ts +30 -0
  109. package/lib/typescript/src/screens/chat-detail/components/ChatComposerInputArea.d.ts.map +1 -0
  110. package/lib/typescript/src/screens/chat-detail/components/ChatComposerStatusBanner.d.ts +19 -0
  111. package/lib/typescript/src/screens/chat-detail/components/ChatComposerStatusBanner.d.ts.map +1 -0
  112. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerAnimation.d.ts +0 -1
  113. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerAnimation.d.ts.map +1 -1
  114. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts +7 -0
  115. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts.map +1 -1
  116. package/lib/typescript/src/screens/chat-detail/hooks/useComposerBotMenu.d.ts +7 -0
  117. package/lib/typescript/src/screens/chat-detail/hooks/useComposerBotMenu.d.ts.map +1 -0
  118. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelActions.d.ts +17 -0
  119. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelActions.d.ts.map +1 -0
  120. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelDismiss.d.ts +12 -0
  121. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelDismiss.d.ts.map +1 -0
  122. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanels.d.ts +25 -0
  123. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanels.d.ts.map +1 -0
  124. package/lib/typescript/src/screens/chat-detail/hooks/useComposerSend.d.ts +16 -0
  125. package/lib/typescript/src/screens/chat-detail/hooks/useComposerSend.d.ts.map +1 -0
  126. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +11 -1
  127. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  128. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.d.ts.map +1 -1
  129. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts +14 -1
  130. package/lib/typescript/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.d.ts.map +1 -1
  131. package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts +18 -0
  132. package/lib/typescript/src/screens/chat-detail/messageToolbox/useClonePreview.d.ts.map +1 -0
  133. package/lib/typescript/src/screens/chat-detail/types.d.ts +8 -0
  134. package/lib/typescript/src/screens/chat-detail/types.d.ts.map +1 -1
  135. package/lib/typescript/src/store/expandedMessage.d.ts +15 -0
  136. package/lib/typescript/src/store/expandedMessage.d.ts.map +1 -0
  137. package/lib/typescript/src/store/message.d.ts.map +1 -1
  138. package/lib/typescript/src/utils/composerPanelDismissGesture.d.ts +33 -0
  139. package/lib/typescript/src/utils/composerPanelDismissGesture.d.ts.map +1 -0
  140. package/lib/typescript/src/utils/expandableText.d.ts +18 -0
  141. package/lib/typescript/src/utils/expandableText.d.ts.map +1 -0
  142. package/lib/typescript/src/utils/inlineTruncation.d.ts +24 -0
  143. package/lib/typescript/src/utils/inlineTruncation.d.ts.map +1 -0
  144. package/lib/typescript/src/utils/perf-marker.d.ts +8 -0
  145. package/lib/typescript/src/utils/perf-marker.d.ts.map +1 -0
  146. package/lib/typescript/src/utils/truncateTextChildren.d.ts +3 -0
  147. package/lib/typescript/src/utils/truncateTextChildren.d.ts.map +1 -0
  148. package/package.json +2 -1
  149. package/src/components/ThreadCard/ThreadCard.tsx +2 -0
  150. package/src/components/messages/ExpandableMessageText.tsx +145 -0
  151. package/src/components/messages/PreviewScrollableText.tsx +98 -0
  152. package/src/components/messages/fileMessage/index.tsx +17 -3
  153. package/src/components/messages/linkMessage/index.tsx +30 -21
  154. package/src/components/messages/quotedMessage/index.tsx +19 -3
  155. package/src/components/messages/textMessage/index.tsx +11 -2
  156. package/src/components/messages/types.ts +18 -0
  157. package/src/context/TimelinePressGuardContext.tsx +15 -0
  158. package/src/hooks/useConversationList.ts +2 -0
  159. package/src/hooks/useDismissKeyboardFirst.ts +19 -0
  160. package/src/hooks/useExpandableText.ts +143 -0
  161. package/src/hooks/useGuardedPress.ts +20 -0
  162. package/src/screens/chat-detail/ChatComposer.tsx +105 -631
  163. package/src/screens/chat-detail/ChatDetail.tsx +49 -20
  164. package/src/screens/chat-detail/ChatListLegend.tsx +122 -71
  165. package/src/screens/chat-detail/PinnedMessagesSheet.tsx +8 -0
  166. package/src/screens/chat-detail/components/ChatComposerAccessorySlot.tsx +61 -0
  167. package/src/screens/chat-detail/components/ChatComposerInputArea.tsx +198 -0
  168. package/src/screens/chat-detail/components/ChatComposerStatusBanner.tsx +116 -0
  169. package/src/screens/chat-detail/hooks/useChatComposerAnimation.ts +0 -29
  170. package/src/screens/chat-detail/hooks/useChatComposerState.ts +25 -2
  171. package/src/screens/chat-detail/hooks/useComposerBotMenu.tsx +140 -0
  172. package/src/screens/chat-detail/hooks/useComposerPanelActions.ts +57 -0
  173. package/src/screens/chat-detail/hooks/useComposerPanelDismiss.ts +111 -0
  174. package/src/screens/chat-detail/hooks/useComposerPanels.ts +197 -0
  175. package/src/screens/chat-detail/hooks/useComposerSend.ts +98 -0
  176. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +24 -2
  177. package/src/screens/chat-detail/messageToolbox/MessageToolboxOverlay.tsx +20 -1
  178. package/src/screens/chat-detail/messageToolbox/MessageToolboxWrapper.tsx +111 -5
  179. package/src/screens/chat-detail/messageToolbox/useClonePreview.ts +34 -0
  180. package/src/screens/chat-detail/types.ts +9 -0
  181. package/src/store/expandedMessage.ts +42 -0
  182. package/src/store/message.ts +14 -0
  183. package/src/utils/composerPanelDismissGesture.ts +72 -0
  184. package/src/utils/expandableText.ts +51 -0
  185. package/src/utils/inlineTruncation.ts +65 -0
  186. package/src/utils/perf-marker.ts +39 -0
  187. package/src/utils/truncateTextChildren.ts +46 -0
@@ -0,0 +1,111 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
+ import type { GestureResponderEvent, ViewProps } from 'react-native';
3
+
4
+ import type { ComposerPanelController } from '../types';
5
+ import {
6
+ IDLE_DISMISS_TOUCH,
7
+ isPressSwallowed,
8
+ markDismissTouchDragged,
9
+ moveDismissTouch,
10
+ shouldDismissOnRelease,
11
+ startDismissTouch,
12
+ type DismissTouch,
13
+ } from '../../../utils/composerPanelDismissGesture';
14
+
15
+ export type DismissAreaTouchProps = Pick<
16
+ ViewProps,
17
+ 'onTouchStart' | 'onTouchMove' | 'onTouchEnd' | 'onTouchCancel'
18
+ >;
19
+
20
+ // Tap-outside dismiss for the composer's attachment/sticker panel
21
+ // (DROPPII-30577).
22
+ //
23
+ // While a panel is open the timeline's content is made non-hittable
24
+ // (`isComposerPanelOpen` → `pointerEvents: 'none'` on the list content), so a
25
+ // tap can never reach a bubble — JS touchables and RNGH gestures alike — yet
26
+ // the scroll view itself still receives the touch and scrolls. Tap vs drag is
27
+ // read from plain touch events on a View wrapping the list; they observe the
28
+ // touch without claiming the JS responder, which on iOS would disable the
29
+ // scroll view's pan (RCTScrollView disables it while an ancestor holds the
30
+ // responder).
31
+ //
32
+ // `pointerEvents` only lands after a React round-trip (open → effect →
33
+ // setState → list re-render), so a tap right after opening a heavy panel can
34
+ // still reach a bubble. `shouldSwallowPress` closes that gap synchronously: it
35
+ // is true for every press belonging to a touch that began with the panel
36
+ // open — while it is down and for SWALLOW_AFTER_RELEASE_MS after it lifts,
37
+ // since RNGH gesture callbacks arrive after touchEnd.
38
+ export const useComposerPanelDismiss = () => {
39
+ const panelControllerRef = useRef<ComposerPanelController | null>(null);
40
+ const touchRef = useRef<DismissTouch>(IDLE_DISMISS_TOUCH);
41
+ const swallowPressRef = useRef(false);
42
+ // null while the tracked touch is down.
43
+ const releasedAtRef = useRef<number | null>(null);
44
+ const [isComposerPanelOpen, setIsComposerPanelOpen] = useState(false);
45
+
46
+ const dismissAreaTouchProps = useMemo<DismissAreaTouchProps>(
47
+ () => ({
48
+ onTouchStart: (event: GestureResponderEvent) => {
49
+ const panelOpen = !!panelControllerRef.current?.isOpen();
50
+ // A second finger must not restart the tracked touch, but a press it
51
+ // makes while the panel is open is still swallowed.
52
+ if (event.nativeEvent.touches.length > 1) {
53
+ if (panelOpen) swallowPressRef.current = true;
54
+ return;
55
+ }
56
+ touchRef.current = startDismissTouch(
57
+ panelOpen,
58
+ event.nativeEvent.pageX,
59
+ event.nativeEvent.pageY
60
+ );
61
+ swallowPressRef.current = panelOpen;
62
+ releasedAtRef.current = null;
63
+ },
64
+ onTouchMove: (event: GestureResponderEvent) => {
65
+ touchRef.current = moveDismissTouch(
66
+ touchRef.current,
67
+ event.nativeEvent.pageX,
68
+ event.nativeEvent.pageY
69
+ );
70
+ },
71
+ onTouchEnd: (event: GestureResponderEvent) => {
72
+ if (event.nativeEvent.touches.length > 0) return;
73
+ if (shouldDismissOnRelease(touchRef.current)) {
74
+ panelControllerRef.current?.close();
75
+ }
76
+ touchRef.current = IDLE_DISMISS_TOUCH;
77
+ releasedAtRef.current = Date.now();
78
+ },
79
+ // The scroll view took the touch over (pan began) — not a tap.
80
+ onTouchCancel: () => {
81
+ touchRef.current = IDLE_DISMISS_TOUCH;
82
+ releasedAtRef.current = Date.now();
83
+ },
84
+ }),
85
+ []
86
+ );
87
+
88
+ // The list's onScrollBeginDrag — a drag signal on both platforms.
89
+ const notifyScrollBeginDrag = useCallback(() => {
90
+ touchRef.current = markDismissTouchDragged(touchRef.current);
91
+ }, []);
92
+
93
+ const shouldSwallowPress = useCallback(
94
+ () =>
95
+ isPressSwallowed(
96
+ swallowPressRef.current,
97
+ releasedAtRef.current,
98
+ Date.now()
99
+ ),
100
+ []
101
+ );
102
+
103
+ return {
104
+ panelControllerRef,
105
+ isComposerPanelOpen,
106
+ onComposerPanelOpenChange: setIsComposerPanelOpen,
107
+ dismissAreaTouchProps,
108
+ notifyScrollBeginDrag,
109
+ shouldSwallowPress,
110
+ };
111
+ };
@@ -0,0 +1,197 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useImperativeHandle,
5
+ useMemo,
6
+ useRef,
7
+ type RefObject,
8
+ } from 'react';
9
+ import { Keyboard } from 'react-native';
10
+
11
+ import { useChatComposerAnimation } from './useChatComposerAnimation';
12
+ import type { ActivePanelType } from './useChatComposerState';
13
+ import type { ComposerPanelController } from '../types';
14
+ import { getAttachmentPanelHeight, DEFAULT_PANNEL_ACTIONS } from '../constants';
15
+ import { STICKER_PICKER_HEIGHT } from '../../../components/StickerPicker/constants';
16
+
17
+ // 'immediate' — no animation (sticker yielding to the keyboard);
18
+ // 'fast' — half-duration (attachment yielding to the keyboard);
19
+ // 'animated' — full duration, same as closing via the toggle icon.
20
+ export type ClosePanelMode = 'immediate' | 'fast' | 'animated';
21
+
22
+ interface UseComposerPanelsProps {
23
+ activePanelType: ActivePanelType;
24
+ setActivePanelType: (type: ActivePanelType) => void;
25
+ isAttachmentOpen: boolean;
26
+ setIsAttachmentOpen: (open: boolean) => void;
27
+ isMentionPanelOpen: boolean;
28
+ closeMentionPanel: () => void;
29
+ isKeyboardVisible: boolean;
30
+ // False while the accessory slot is unmounted or the input row is replaced
31
+ // by a status banner — a panel there is invisible and cannot be toggled.
32
+ isSlotVisible: boolean;
33
+ panelControllerRef?: RefObject<ComposerPanelController | null>;
34
+ // Effective open state (open AND visible), for the timeline's dismiss area.
35
+ onPanelOpenChange?: (open: boolean) => void;
36
+ }
37
+
38
+ export const useComposerPanels = ({
39
+ activePanelType,
40
+ setActivePanelType,
41
+ isAttachmentOpen,
42
+ setIsAttachmentOpen,
43
+ isMentionPanelOpen,
44
+ closeMentionPanel,
45
+ isKeyboardVisible,
46
+ isSlotVisible,
47
+ panelControllerRef,
48
+ onPanelOpenChange,
49
+ }: UseComposerPanelsProps) => {
50
+ const attachmentPanelHeight = useMemo(
51
+ () => getAttachmentPanelHeight(DEFAULT_PANNEL_ACTIONS.length),
52
+ []
53
+ );
54
+
55
+ const panelHeight =
56
+ activePanelType === 'sticker'
57
+ ? STICKER_PICKER_HEIGHT
58
+ : attachmentPanelHeight;
59
+
60
+ // Mirrors isAttachmentOpen synchronously (written before React commits),
61
+ // so imperative readers — the timeline's tap-outside dismiss — never see
62
+ // a stale value between an open/close and the next render.
63
+ const isAttachmentOpenRef = useRef(false);
64
+ const handleAttachmentOpenChange = useCallback(
65
+ (open: boolean) => {
66
+ isAttachmentOpenRef.current = open;
67
+ setIsAttachmentOpen(open);
68
+ },
69
+ [setIsAttachmentOpen]
70
+ );
71
+
72
+ const { accessorySlotStyle, openWithHeight, closeAttachment } =
73
+ useChatComposerAnimation({
74
+ panelHeight,
75
+ onAttachmentOpenChange: handleAttachmentOpenChange,
76
+ });
77
+
78
+ const closeActivePanel = useCallback(
79
+ (mode: ClosePanelMode) => {
80
+ closeAttachment(mode === 'fast', mode === 'immediate');
81
+ setActivePanelType(null);
82
+ },
83
+ [closeAttachment, setActivePanelType]
84
+ );
85
+
86
+ // Close mode when yielding to the keyboard or the mention picker: the
87
+ // sticker panel closes immediately to avoid a layout conflict between the
88
+ // panel closing and the keyboard appearing; the attachment panel is small
89
+ // enough that animation is fine (speedUp=true).
90
+ const yieldingCloseMode: ClosePanelMode =
91
+ activePanelType === 'sticker' ? 'immediate' : 'fast';
92
+
93
+ // Mutual exclusion (finding #5 in the mention plan): the mention picker
94
+ // never renders through the attachment/sticker panel slot, but opening
95
+ // one must still close the other — starting a mention query while the
96
+ // attachment/sticker panel is open closes that panel.
97
+ useEffect(() => {
98
+ if (isMentionPanelOpen && isAttachmentOpen) {
99
+ closeActivePanel(yieldingCloseMode);
100
+ }
101
+ // eslint-disable-next-line react-hooks/exhaustive-deps
102
+ }, [isMentionPanelOpen]);
103
+
104
+ // A panel left open when the slot disappears (composer disabled, bot
105
+ // restarted, removed from group) would otherwise stay "open" invisibly.
106
+ const isSlotVisibleRef = useRef(isSlotVisible);
107
+ isSlotVisibleRef.current = isSlotVisible;
108
+ useEffect(() => {
109
+ if (!isSlotVisible && isAttachmentOpenRef.current) {
110
+ closeActivePanel('immediate');
111
+ }
112
+ // eslint-disable-next-line react-hooks/exhaustive-deps
113
+ }, [isSlotVisible]);
114
+
115
+ const isPanelEffectivelyOpen = isAttachmentOpen && isSlotVisible;
116
+ useEffect(() => {
117
+ onPanelOpenChange?.(isPanelEffectivelyOpen);
118
+ }, [isPanelEffectivelyOpen, onPanelOpenChange]);
119
+
120
+ useImperativeHandle(
121
+ panelControllerRef,
122
+ () => ({
123
+ isOpen: () => isAttachmentOpenRef.current && isSlotVisibleRef.current,
124
+ close: () => {
125
+ if (isAttachmentOpenRef.current) closeActivePanel('animated');
126
+ },
127
+ }),
128
+ [closeActivePanel]
129
+ );
130
+
131
+ const handleToggleAttachment = useCallback(() => {
132
+ closeMentionPanel();
133
+ if (activePanelType === 'sticker') {
134
+ // switch from sticker to attachment
135
+ setActivePanelType('attachment');
136
+ openWithHeight(attachmentPanelHeight);
137
+ return;
138
+ }
139
+ // Open/close decided on the same source of truth as the icon: a
140
+ // toggle keyed on the animated height could close a panel that is
141
+ // still collapsing from a tap-outside dismiss while the icon shows it
142
+ // opening.
143
+ if (isAttachmentOpenRef.current) {
144
+ closeActivePanel('animated');
145
+ return;
146
+ }
147
+ if (isKeyboardVisible) {
148
+ Keyboard.dismiss();
149
+ }
150
+ setActivePanelType('attachment');
151
+ openWithHeight(attachmentPanelHeight);
152
+ // Deliberately depend on `closeMentionPanel` (stable — [] deps in
153
+ // useMentionComposer), NOT the whole `mentionComposer` object, which is
154
+ // a fresh literal every render (useMentionComposer doesn't memoize its
155
+ // return value). Depending on the whole object recreated this callback
156
+ // — and therefore every prop built from it, e.g. ChatMessageInput's
157
+ // onEmojiPress — on every single render, which contributed to a real
158
+ // "Maximum update depth exceeded" loop found in testing.
159
+ }, [
160
+ activePanelType,
161
+ attachmentPanelHeight,
162
+ isKeyboardVisible,
163
+ openWithHeight,
164
+ closeActivePanel,
165
+ setActivePanelType,
166
+ closeMentionPanel,
167
+ ]);
168
+
169
+ const handleToggleSticker = useCallback(() => {
170
+ closeMentionPanel();
171
+ if (activePanelType === 'sticker') {
172
+ closeActivePanel('animated');
173
+ return;
174
+ }
175
+ if (isKeyboardVisible) {
176
+ Keyboard.dismiss();
177
+ }
178
+ setActivePanelType('sticker');
179
+ openWithHeight(STICKER_PICKER_HEIGHT);
180
+ // Same reasoning as handleToggleAttachment above — see that comment.
181
+ }, [
182
+ activePanelType,
183
+ isKeyboardVisible,
184
+ closeMentionPanel,
185
+ closeActivePanel,
186
+ openWithHeight,
187
+ setActivePanelType,
188
+ ]);
189
+
190
+ return {
191
+ accessorySlotStyle,
192
+ closeActivePanel,
193
+ yieldingCloseMode,
194
+ handleToggleAttachment,
195
+ handleToggleSticker,
196
+ };
197
+ };
@@ -0,0 +1,98 @@
1
+ import { useCallback, type RefObject } from 'react';
2
+
3
+ import { trans } from '../../../translation';
4
+ import { UIUtils } from '../../../utils/UIUtils';
5
+ import {
6
+ serializeMentionsForSend,
7
+ MAX_AT_USER_COUNT,
8
+ } from '../../../utils/mentionSerializer';
9
+ import type { useChatCompose } from '../../../hooks/useChatCompose';
10
+ import type { useMentionComposer } from './useMentionComposer';
11
+
12
+ type Mentions = ReturnType<typeof useMentionComposer>['mentions'];
13
+
14
+ interface UseComposerSendProps {
15
+ compose: ReturnType<typeof useChatCompose>;
16
+ isSendMessageAllowed: boolean;
17
+ sendDeniedReason?: string;
18
+ mentions: Mentions;
19
+ clearMentions: () => void;
20
+ notifyStoppedTyping: () => void;
21
+ inputRef: RefObject<any>;
22
+ }
23
+
24
+ export const useComposerSend = ({
25
+ compose,
26
+ isSendMessageAllowed,
27
+ sendDeniedReason,
28
+ mentions,
29
+ clearMentions,
30
+ notifyStoppedTyping,
31
+ inputRef,
32
+ }: UseComposerSendProps) =>
33
+ useCallback(async () => {
34
+ if (!isSendMessageAllowed) {
35
+ UIUtils.toast.open({
36
+ title: sendDeniedReason,
37
+ theme: 'info',
38
+ });
39
+ return;
40
+ }
41
+
42
+ if (compose.isUploading || !compose.canSend) return;
43
+ const mentionsToSend = mentions;
44
+
45
+ // OpenIM's Go core hard-rejects an AtTextMessage tagging more than
46
+ // MAX_AT_USER_COUNT users, so serializeMentionsForSend caps
47
+ // atUserIDList to the first MAX_AT_USER_COUNT (mentionSerializer.ts's
48
+ // doc comment) — the message still sends, with every mention still
49
+ // highlighted in the text, just only the first MAX_AT_USER_COUNT
50
+ // actually get natively tagged/notified. This is a heads-up, not a
51
+ // block, so no `return` here.
52
+ if (mentionsToSend.length > 0) {
53
+ const { truncated } = serializeMentionsForSend(mentionsToSend);
54
+ if (truncated) {
55
+ UIUtils.toast.open({
56
+ title: trans('group:tag_limit_exceeded', {
57
+ max: String(MAX_AT_USER_COUNT),
58
+ }),
59
+ theme: 'info',
60
+ });
61
+ }
62
+ }
63
+
64
+ // Clear mention state synchronously, in the same tick as
65
+ // compose.send() (which clears `text`) — otherwise there's a real
66
+ // transient render where `value` is already '' but `mentions` still
67
+ // holds the just-sent entries, and the composer can visibly fail to
68
+ // clear (ChatMessageInput stays in children-controlled mode with a
69
+ // "ghost" mention anchored past the end of an empty string). See
70
+ // useMentionComposer.clear()'s doc comment.
71
+ clearMentions();
72
+ // Stop before awaiting the send: the peer should lose the indicator as
73
+ // the message goes out, not once it round-trips.
74
+ notifyStoppedTyping();
75
+ try {
76
+ await compose.send(mentionsToSend);
77
+ } catch (error) {
78
+ if (__DEV__) console.error('[ChatComposer] Failed to send:', error);
79
+ } finally {
80
+ // Belt-and-suspenders: force the native view to actually clear,
81
+ // regardless of whether the value/children mode-switch (triggered
82
+ // by mentions going from non-empty to empty) synced correctly on
83
+ // its own — the underlying nested-Text-in-TextInput technique is
84
+ // known to have platform quirks around this kind of transition.
85
+ inputRef.current?.clear();
86
+ }
87
+ // Depend on mentionComposer.mentions/.clear specifically (both
88
+ // stable-per-value / [] deps), not the whole mentionComposer object —
89
+ // same reasoning as handleToggleAttachment in useComposerPanels.
90
+ }, [
91
+ compose,
92
+ isSendMessageAllowed,
93
+ sendDeniedReason,
94
+ mentions,
95
+ clearMentions,
96
+ notifyStoppedTyping,
97
+ inputRef,
98
+ ]);
@@ -28,8 +28,10 @@ import QuotedMessage from '../../../components/messages/quotedMessage';
28
28
  import { StickerMessage } from '../../../components/messages/stickerMessage';
29
29
  import { GROUP_NOTIFICATION_TYPES } from '../../../utils/messageUtils';
30
30
  import type { GroupPosition } from '../../../utils/legendListMessage';
31
+ import type { MessageSurface } from '../../../store/expandedMessage';
31
32
  import { Avatar } from '../../../components/Avatar';
32
33
  import { TAIL_WIDTH } from '../../../components/messages/BubbleTail';
34
+ import { perfCountRender } from '../../../utils/perf-marker';
33
35
 
34
36
  type MessageProps = BaseLegendMessageProps & {
35
37
  onPressUrl?: (url: string) => void;
@@ -72,6 +74,15 @@ interface LegendChatMessageProps {
72
74
  showSenderGutter?: boolean;
73
75
  /** Position within its same-sender cluster — drives the bubble tail. */
74
76
  groupPosition?: GroupPosition;
77
+ /**
78
+ * Which surface is rendering this message. Expansion state is scoped by it
79
+ * (BR-003), so Pin Management must pass `'pin'` explicitly — the default
80
+ * keeps the timeline and the toolbox overlay sharing one scope, which is
81
+ * what makes the overlay clone mirror the row it lifted from.
82
+ */
83
+ surface?: MessageSurface;
84
+ /** Toolbox overlay clone — see `BaseLegendMessageProps.isPreview`. */
85
+ isPreview?: boolean;
75
86
  }
76
87
 
77
88
  const messageComponentMap: Partial<
@@ -129,7 +140,10 @@ export const LegendChatMessage = memo(
129
140
  showAvatar = false,
130
141
  showSenderGutter = false,
131
142
  groupPosition,
143
+ surface = 'timeline',
144
+ isPreview,
132
145
  }: LegendChatMessageProps) => {
146
+ perfCountRender('LegendChatMessage');
133
147
  const handlePress = useCallback(() => {
134
148
  onPress?.(messageId);
135
149
  }, [onPress, messageId]);
@@ -151,6 +165,8 @@ export const LegendChatMessage = memo(
151
165
  <KContainer.View center style={styles.systemMessageWrapper}>
152
166
  <SystemMessageComponent
153
167
  message={message}
168
+ messageId={messageId}
169
+ surface={surface}
154
170
  isOutgoing={isOutgoing}
155
171
  createdAtTime={createdAtTime}
156
172
  />
@@ -210,12 +226,18 @@ export const LegendChatMessage = memo(
210
226
  {message.senderNickname}
211
227
  </KLabel.Text>
212
228
  )}
229
+ {/* No gestures on the overlay clone: a Tap here would race
230
+ `Xem thêm` for the same touch, and the overlay has its own
231
+ tap-to-dismiss around the clone. */}
213
232
  <LongPressWrapper
214
- onPress={shouldHandlePress ? handlePress : undefined}
215
- onLongPress={handleLongPress}
233
+ onPress={shouldHandlePress && !isPreview ? handlePress : undefined}
234
+ onLongPress={isPreview ? undefined : handleLongPress}
216
235
  >
217
236
  <MessageComponent
218
237
  message={message}
238
+ messageId={messageId}
239
+ surface={surface}
240
+ isPreview={isPreview}
219
241
  isOutgoing={isOutgoing}
220
242
  createdAtTime={createdAtTime}
221
243
  onMediaPress={hasMediaPress ? effectiveOnMediaPress : undefined}
@@ -29,6 +29,7 @@ import { useSendMessage } from '../../../hooks/message/useSendMessage';
29
29
  import { useChatContext, useChatDetailContext } from '../../../context';
30
30
  import {
31
31
  GroupPermission,
32
+ MessageType,
32
33
  type MessageItem,
33
34
  } from '@droppii/openim-rn-client-sdk';
34
35
  import { useHasGroupPermission } from '../../../hooks/useGroupPermission';
@@ -44,6 +45,13 @@ import type { GroupPosition } from '../../../utils/legendListMessage';
44
45
  // scrollTo. See `show`/`hide` below for the observed sequence.
45
46
  const UNFREEZE_DELAY_MS = 300;
46
47
 
48
+ const EXPANDABLE_TYPES: number[] = [
49
+ MessageType.TextMessage,
50
+ MessageType.AtTextMessage,
51
+ MessageType.UrlTextMessage,
52
+ MessageType.QuoteMessage,
53
+ ];
54
+
47
55
  interface MessageToolboxOverlayProps {
48
56
  currentUserId?: string;
49
57
  customMessageItemFactory?: (item: MessageItem) => ReactNode;
@@ -209,11 +217,19 @@ export const MessageToolboxOverlay = forwardRef<
209
217
  hide();
210
218
  }, [message, hide, setQuotedMessage]);
211
219
 
220
+ const overlayMessageId = message?.clientMsgID || message?.serverMsgID || '';
221
+
212
222
  if (!visible || !message || !rect) {
213
223
  return null;
214
224
  }
215
225
 
216
226
  const isOutgoing = message.sendID === currentUserId;
227
+ // Only the types that can show `Xem thêm` get a touchable clone. File
228
+ // download and consumer custom nodes carry their own handlers and stay
229
+ // inert.
230
+ const isCloneInteractive =
231
+ EXPANDABLE_TYPES.includes(message.contentType) &&
232
+ !customMessageItemFactory?.(message);
217
233
  const showSenderGutter = isGroupConversation && !isOutgoing;
218
234
  const showName =
219
235
  showSenderGutter &&
@@ -235,10 +251,13 @@ export const MessageToolboxOverlay = forwardRef<
235
251
  <MessageToolboxWrapper
236
252
  rect={rect}
237
253
  isOutgoing={isOutgoing}
254
+ isCloneInteractive={isCloneInteractive}
255
+ onDismiss={hide}
238
256
  message={
239
257
  <LegendChatMessage
240
258
  message={message}
241
- messageId={message.clientMsgID || message.serverMsgID || ''}
259
+ messageId={overlayMessageId}
260
+ isPreview
242
261
  isOutgoing={isOutgoing}
243
262
  createdAtTime={message.sendTime || message.createTime}
244
263
  customMessageItemFactory={customMessageItemFactory}