@droppii-org/chat-mobile 0.2.107 → 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 (123) 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/fileMessage/index.js +9 -2
  4. package/lib/module/components/messages/fileMessage/index.js.map +1 -1
  5. package/lib/module/components/messages/quotedMessage/index.js +6 -1
  6. package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
  7. package/lib/module/context/TimelinePressGuardContext.js +14 -0
  8. package/lib/module/context/TimelinePressGuardContext.js.map +1 -0
  9. package/lib/module/hooks/useConversationList.js +4 -0
  10. package/lib/module/hooks/useConversationList.js.map +1 -1
  11. package/lib/module/hooks/useDismissKeyboardFirst.js +19 -0
  12. package/lib/module/hooks/useDismissKeyboardFirst.js.map +1 -0
  13. package/lib/module/hooks/useExpandableText.js +6 -1
  14. package/lib/module/hooks/useExpandableText.js.map +1 -1
  15. package/lib/module/hooks/useGuardedPress.js +19 -0
  16. package/lib/module/hooks/useGuardedPress.js.map +1 -0
  17. package/lib/module/screens/chat-detail/ChatComposer.js +89 -498
  18. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  19. package/lib/module/screens/chat-detail/ChatDetail.js +38 -20
  20. package/lib/module/screens/chat-detail/ChatDetail.js.map +1 -1
  21. package/lib/module/screens/chat-detail/ChatListLegend.js +113 -76
  22. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  23. package/lib/module/screens/chat-detail/components/ChatComposerAccessorySlot.js +47 -0
  24. package/lib/module/screens/chat-detail/components/ChatComposerAccessorySlot.js.map +1 -0
  25. package/lib/module/screens/chat-detail/components/ChatComposerInputArea.js +149 -0
  26. package/lib/module/screens/chat-detail/components/ChatComposerInputArea.js.map +1 -0
  27. package/lib/module/screens/chat-detail/components/ChatComposerStatusBanner.js +104 -0
  28. package/lib/module/screens/chat-detail/components/ChatComposerStatusBanner.js.map +1 -0
  29. package/lib/module/screens/chat-detail/hooks/useChatComposerAnimation.js +0 -17
  30. package/lib/module/screens/chat-detail/hooks/useChatComposerAnimation.js.map +1 -1
  31. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js +16 -2
  32. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js.map +1 -1
  33. package/lib/module/screens/chat-detail/hooks/useComposerBotMenu.js +122 -0
  34. package/lib/module/screens/chat-detail/hooks/useComposerBotMenu.js.map +1 -0
  35. package/lib/module/screens/chat-detail/hooks/useComposerPanelActions.js +46 -0
  36. package/lib/module/screens/chat-detail/hooks/useComposerPanelActions.js.map +1 -0
  37. package/lib/module/screens/chat-detail/hooks/useComposerPanelDismiss.js +75 -0
  38. package/lib/module/screens/chat-detail/hooks/useComposerPanelDismiss.js.map +1 -0
  39. package/lib/module/screens/chat-detail/hooks/useComposerPanels.js +136 -0
  40. package/lib/module/screens/chat-detail/hooks/useComposerPanels.js.map +1 -0
  41. package/lib/module/screens/chat-detail/hooks/useComposerSend.js +74 -0
  42. package/lib/module/screens/chat-detail/hooks/useComposerSend.js.map +1 -0
  43. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +2 -0
  44. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  45. package/lib/module/screens/chat-detail/types.js +5 -0
  46. package/lib/module/screens/chat-detail/types.js.map +1 -1
  47. package/lib/module/store/message.js +14 -0
  48. package/lib/module/store/message.js.map +1 -1
  49. package/lib/module/utils/composerPanelDismissGesture.js +52 -0
  50. package/lib/module/utils/composerPanelDismissGesture.js.map +1 -0
  51. package/lib/module/utils/perf-marker.js +44 -0
  52. package/lib/module/utils/perf-marker.js.map +1 -0
  53. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  54. package/lib/typescript/src/components/messages/fileMessage/index.d.ts.map +1 -1
  55. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
  56. package/lib/typescript/src/context/TimelinePressGuardContext.d.ts +3 -0
  57. package/lib/typescript/src/context/TimelinePressGuardContext.d.ts.map +1 -0
  58. package/lib/typescript/src/hooks/useConversationList.d.ts.map +1 -1
  59. package/lib/typescript/src/hooks/useDismissKeyboardFirst.d.ts +8 -0
  60. package/lib/typescript/src/hooks/useDismissKeyboardFirst.d.ts.map +1 -0
  61. package/lib/typescript/src/hooks/useExpandableText.d.ts.map +1 -1
  62. package/lib/typescript/src/hooks/useGuardedPress.d.ts +9 -0
  63. package/lib/typescript/src/hooks/useGuardedPress.d.ts.map +1 -0
  64. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts +7 -3
  65. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  66. package/lib/typescript/src/screens/chat-detail/ChatDetail.d.ts.map +1 -1
  67. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts +5 -1
  68. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  69. package/lib/typescript/src/screens/chat-detail/components/ChatComposerAccessorySlot.d.ts +12 -0
  70. package/lib/typescript/src/screens/chat-detail/components/ChatComposerAccessorySlot.d.ts.map +1 -0
  71. package/lib/typescript/src/screens/chat-detail/components/ChatComposerInputArea.d.ts +30 -0
  72. package/lib/typescript/src/screens/chat-detail/components/ChatComposerInputArea.d.ts.map +1 -0
  73. package/lib/typescript/src/screens/chat-detail/components/ChatComposerStatusBanner.d.ts +19 -0
  74. package/lib/typescript/src/screens/chat-detail/components/ChatComposerStatusBanner.d.ts.map +1 -0
  75. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerAnimation.d.ts +0 -1
  76. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerAnimation.d.ts.map +1 -1
  77. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts +7 -0
  78. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts.map +1 -1
  79. package/lib/typescript/src/screens/chat-detail/hooks/useComposerBotMenu.d.ts +7 -0
  80. package/lib/typescript/src/screens/chat-detail/hooks/useComposerBotMenu.d.ts.map +1 -0
  81. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelActions.d.ts +17 -0
  82. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelActions.d.ts.map +1 -0
  83. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelDismiss.d.ts +12 -0
  84. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanelDismiss.d.ts.map +1 -0
  85. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanels.d.ts +25 -0
  86. package/lib/typescript/src/screens/chat-detail/hooks/useComposerPanels.d.ts.map +1 -0
  87. package/lib/typescript/src/screens/chat-detail/hooks/useComposerSend.d.ts +16 -0
  88. package/lib/typescript/src/screens/chat-detail/hooks/useComposerSend.d.ts.map +1 -0
  89. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  90. package/lib/typescript/src/screens/chat-detail/types.d.ts +8 -0
  91. package/lib/typescript/src/screens/chat-detail/types.d.ts.map +1 -1
  92. package/lib/typescript/src/store/message.d.ts.map +1 -1
  93. package/lib/typescript/src/utils/composerPanelDismissGesture.d.ts +33 -0
  94. package/lib/typescript/src/utils/composerPanelDismissGesture.d.ts.map +1 -0
  95. package/lib/typescript/src/utils/perf-marker.d.ts +8 -0
  96. package/lib/typescript/src/utils/perf-marker.d.ts.map +1 -0
  97. package/package.json +2 -1
  98. package/src/components/ThreadCard/ThreadCard.tsx +2 -0
  99. package/src/components/messages/fileMessage/index.tsx +17 -3
  100. package/src/components/messages/quotedMessage/index.tsx +5 -1
  101. package/src/context/TimelinePressGuardContext.tsx +15 -0
  102. package/src/hooks/useConversationList.ts +2 -0
  103. package/src/hooks/useDismissKeyboardFirst.ts +19 -0
  104. package/src/hooks/useExpandableText.ts +5 -1
  105. package/src/hooks/useGuardedPress.ts +20 -0
  106. package/src/screens/chat-detail/ChatComposer.tsx +105 -631
  107. package/src/screens/chat-detail/ChatDetail.tsx +38 -20
  108. package/src/screens/chat-detail/ChatListLegend.tsx +122 -71
  109. package/src/screens/chat-detail/components/ChatComposerAccessorySlot.tsx +61 -0
  110. package/src/screens/chat-detail/components/ChatComposerInputArea.tsx +198 -0
  111. package/src/screens/chat-detail/components/ChatComposerStatusBanner.tsx +116 -0
  112. package/src/screens/chat-detail/hooks/useChatComposerAnimation.ts +0 -29
  113. package/src/screens/chat-detail/hooks/useChatComposerState.ts +25 -2
  114. package/src/screens/chat-detail/hooks/useComposerBotMenu.tsx +140 -0
  115. package/src/screens/chat-detail/hooks/useComposerPanelActions.ts +57 -0
  116. package/src/screens/chat-detail/hooks/useComposerPanelDismiss.ts +111 -0
  117. package/src/screens/chat-detail/hooks/useComposerPanels.ts +197 -0
  118. package/src/screens/chat-detail/hooks/useComposerSend.ts +98 -0
  119. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +2 -0
  120. package/src/screens/chat-detail/types.ts +9 -0
  121. package/src/store/message.ts +14 -0
  122. package/src/utils/composerPanelDismissGesture.ts +72 -0
  123. package/src/utils/perf-marker.ts +39 -0
@@ -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
+ ]);
@@ -31,6 +31,7 @@ import type { GroupPosition } from '../../../utils/legendListMessage';
31
31
  import type { MessageSurface } from '../../../store/expandedMessage';
32
32
  import { Avatar } from '../../../components/Avatar';
33
33
  import { TAIL_WIDTH } from '../../../components/messages/BubbleTail';
34
+ import { perfCountRender } from '../../../utils/perf-marker';
34
35
 
35
36
  type MessageProps = BaseLegendMessageProps & {
36
37
  onPressUrl?: (url: string) => void;
@@ -142,6 +143,7 @@ export const LegendChatMessage = memo(
142
143
  surface = 'timeline',
143
144
  isPreview,
144
145
  }: LegendChatMessageProps) => {
146
+ perfCountRender('LegendChatMessage');
145
147
  const handlePress = useCallback(() => {
146
148
  onPress?.(messageId);
147
149
  }, [onPress, messageId]);
@@ -107,3 +107,12 @@ export interface ChatDetailProps extends Omit<
107
107
  groupJoinSource?: GroupJoinSource;
108
108
  renderConversationViewHeader?: () => ReactNode;
109
109
  }
110
+
111
+ /**
112
+ * @internal Imperative handle to the composer's attachment/sticker panel,
113
+ * used by ChatDetail for tap-outside dismiss. Not part of the public API.
114
+ */
115
+ export interface ComposerPanelController {
116
+ isOpen: () => boolean;
117
+ close: () => void;
118
+ }
@@ -14,6 +14,7 @@ import type {
14
14
  import { mergeMessages } from '../utils/message';
15
15
  import { isMessageRevoked } from '../utils/messageUtils';
16
16
  import { useConversationStore } from './conversation';
17
+ import { perfMark } from '../utils/perf-marker';
17
18
 
18
19
  // Smaller than a typical "load more" page size elsewhere in the app on
19
20
  // purpose: each page becomes a single prepend batch in ChatListLegend, and a
@@ -77,7 +78,16 @@ export const useMessageStore = create<MessageStore>()((set, get) => ({
77
78
  viewType: ViewType.History,
78
79
  applicationType: applicationType,
79
80
  };
81
+ perfMark('history_page_request', {
82
+ loadMore: !!loadMore,
83
+ loaded: get().messages.length,
84
+ });
80
85
  const res = await OpenIMSDK.getAdvancedHistoryMessageListApp(params);
86
+ perfMark('history_page_response', {
87
+ loadMore: !!loadMore,
88
+ received: res.messageList?.length ?? 0,
89
+ isEnd: !!res.isEnd,
90
+ });
81
91
  if (
82
92
  conversationId === useConversationStore.getState().currentConversationId
83
93
  ) {
@@ -89,6 +99,10 @@ export const useMessageStore = create<MessageStore>()((set, get) => ({
89
99
  initLoading: false,
90
100
  moreOldLoading: false,
91
101
  }));
102
+ perfMark('history_list_update', {
103
+ loadMore: !!loadMore,
104
+ total: get().messages.length,
105
+ });
92
106
  }
93
107
  } catch (error) {
94
108
  console.log('Failed to get more old messages', error);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Per-touch state for dismissing the composer's attachment/sticker panel by
3
+ * tapping the chat timeline: a touch that began while the panel was open and
4
+ * ended without dragging is a dismiss tap. Drag detection is explicit — a
5
+ * movement threshold or the list's scroll-begin — since the touch is only
6
+ * observed (touch events), never claimed as the JS responder.
7
+ */
8
+ export const DISMISS_DRAG_THRESHOLD_PX = 10;
9
+
10
+ export interface DismissTouch {
11
+ panelOpenAtStart: boolean;
12
+ dragged: boolean;
13
+ startX: number;
14
+ startY: number;
15
+ }
16
+
17
+ export const IDLE_DISMISS_TOUCH: DismissTouch = {
18
+ panelOpenAtStart: false,
19
+ dragged: false,
20
+ startX: 0,
21
+ startY: 0,
22
+ };
23
+
24
+ export const startDismissTouch = (
25
+ panelOpen: boolean,
26
+ x: number,
27
+ y: number
28
+ ): DismissTouch => ({
29
+ panelOpenAtStart: panelOpen,
30
+ dragged: false,
31
+ startX: x,
32
+ startY: y,
33
+ });
34
+
35
+ export const moveDismissTouch = (
36
+ touch: DismissTouch,
37
+ x: number,
38
+ y: number
39
+ ): DismissTouch => {
40
+ if (touch.dragged) return touch;
41
+ const movedBeyondThreshold =
42
+ Math.abs(x - touch.startX) > DISMISS_DRAG_THRESHOLD_PX ||
43
+ Math.abs(y - touch.startY) > DISMISS_DRAG_THRESHOLD_PX;
44
+ return movedBeyondThreshold ? { ...touch, dragged: true } : touch;
45
+ };
46
+
47
+ export const markDismissTouchDragged = (touch: DismissTouch): DismissTouch =>
48
+ touch.dragged ? touch : { ...touch, dragged: true };
49
+
50
+ /** Whether releasing this touch should close the panel. */
51
+ export const shouldDismissOnRelease = (touch: DismissTouch): boolean =>
52
+ touch.panelOpenAtStart && !touch.dragged;
53
+
54
+ /**
55
+ * How long after the finger lifts a press still belongs to the dismissing
56
+ * touch. RNGH Tap and JS onPress callbacks land right after touchEnd; a later
57
+ * press (e.g. a screen-reader activation, which sends no touch events) must
58
+ * not be swallowed by a stale flag.
59
+ */
60
+ export const SWALLOW_AFTER_RELEASE_MS = 500;
61
+
62
+ /**
63
+ * Whether a timeline press must be swallowed. `releasedAt` is null while the
64
+ * dismissing touch is still down.
65
+ */
66
+ export const isPressSwallowed = (
67
+ swallow: boolean,
68
+ releasedAt: number | null,
69
+ now: number
70
+ ): boolean =>
71
+ swallow &&
72
+ (releasedAt === null || now - releasedAt < SWALLOW_AFTER_RELEASE_MS);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Opt-in performance markers for the local perf framework (`performance/`).
3
+ *
4
+ * Internal, not exported from the package: only exampleLegacy (the perf harness) imports `enablePerfMarkers` directly.
5
+ * Disabled by default, so every call is a no-op in consumer apps. When enabled, markers are written as
6
+ * `[PERF] {"name":..,"t":<epoch ms>,...}` through console.log, which the perf runner reads from the device log.
7
+ * Durations are computed between markers on the device clock, so no host/device clock sync is needed.
8
+ */
9
+ let enabled = false;
10
+ const FLUSH_INTERVAL_MS = 500;
11
+ let renderCounts: Record<string, number> = {};
12
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
13
+
14
+ export const enablePerfMarkers = (on = true) => {
15
+ enabled = on;
16
+ };
17
+
18
+ export const perfMark = (name: string, data?: Record<string, unknown>) => {
19
+ if (!enabled) return;
20
+ console.log(`[PERF] ${JSON.stringify({ name, t: Date.now(), ...data })}`);
21
+ };
22
+
23
+ const flushRenderCounts = () => {
24
+ flushTimer = null;
25
+ const counts = renderCounts;
26
+ renderCounts = {};
27
+ perfMark('render_counts', { counts });
28
+ };
29
+
30
+ /**
31
+ * Counts render-function invocations of `component`, including renders React later discards.
32
+ * Counts are batched and emitted as a delta every ~500 ms, so logging never happens per render.
33
+ */
34
+ export const perfCountRender = (component: string) => {
35
+ if (!enabled) return;
36
+ renderCounts[component] = (renderCounts[component] ?? 0) + 1;
37
+ if (!flushTimer)
38
+ flushTimer = setTimeout(flushRenderCounts, FLUSH_INTERVAL_MS);
39
+ };