@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
@@ -10,35 +10,37 @@ import { LinkPreviewCard } from './LinkPreviewCard';
10
10
  import { getTailCornerStyle } from '../bubbleTail.utils';
11
11
  import { BubbleTailRow } from '../BubbleTailRow';
12
12
  import { LINK_URL_COLOR } from '../../../utils/theme';
13
+ import { ExpandableMessageText } from '../ExpandableMessageText';
13
14
 
15
+ // Returns the inner runs only — the wrapping <Text> comes from
16
+ // ExpandableMessageText, which is the node the line clamp has to land on.
14
17
  const renderTextWithLinks = (
15
18
  content: string,
16
19
  urls: string[],
17
20
  onPressUrl?: (url: string) => void
18
- ) => (
19
- <KLabel.Text typo="TextMdNormal">
20
- {splitTextByUrls(content, urls).map((part, i) =>
21
- part.isUrl ? (
22
- <KLabel.Text
23
- key={i}
24
- onPress={() => onPressUrl?.(part.text)}
25
- color={LINK_URL_COLOR}
26
- typo="TextMdMedium"
27
- >
28
- {part.text}
29
- </KLabel.Text>
30
- ) : (
31
- <KLabel.Text typo="TextMdNormal" key={i}>
32
- {part.text}
33
- </KLabel.Text>
34
- )
35
- )}
36
- </KLabel.Text>
37
- );
21
+ ) =>
22
+ splitTextByUrls(content, urls).map((part, i) =>
23
+ part.isUrl ? (
24
+ <KLabel.Text
25
+ key={i}
26
+ onPress={() => onPressUrl?.(part.text)}
27
+ color={LINK_URL_COLOR}
28
+ typo="TextMdMedium"
29
+ >
30
+ {part.text}
31
+ </KLabel.Text>
32
+ ) : (
33
+ <KLabel.Text typo="TextMdNormal" key={i}>
34
+ {part.text}
35
+ </KLabel.Text>
36
+ )
37
+ );
38
38
 
39
39
  export const LinkMessage = memo(
40
40
  ({
41
41
  message,
42
+ messageId,
43
+ surface,
42
44
  isOutgoing,
43
45
  createdAtTime,
44
46
  onPressUrl,
@@ -64,7 +66,14 @@ export const LinkMessage = memo(
64
66
  >
65
67
  {!!content?.trim() && (
66
68
  <KContainer.View style={styles.textRow}>
67
- {renderTextWithLinks(content, urls, onPressUrl)}
69
+ <ExpandableMessageText
70
+ messageId={messageId}
71
+ surface={surface}
72
+ text={content}
73
+ typo="TextMdNormal"
74
+ >
75
+ {renderTextWithLinks(content, urls, onPressUrl)}
76
+ </ExpandableMessageText>
68
77
  </KContainer.View>
69
78
  )}
70
79
 
@@ -22,8 +22,10 @@ import {
22
22
  import { getFileIcon, getMimeType } from '../../../utils/imageUtils';
23
23
  import { extractUrls } from '../../../utils/url';
24
24
  import { renderLinkifiedText } from '../LinkifiedText';
25
+ import { ExpandableMessageText } from '../ExpandableMessageText';
25
26
  import { useUserStore } from '../../../store';
26
27
  import { useChatDetailContext } from '../../../context';
28
+ import { useTimelinePressGuard } from '../../../context/TimelinePressGuardContext';
27
29
  import { ChatEvent } from '../../../types/events';
28
30
 
29
31
  interface QuotedMessageProps extends BaseLegendMessageProps {
@@ -33,6 +35,9 @@ interface QuotedMessageProps extends BaseLegendMessageProps {
33
35
  const QuotedMessage = memo((props: QuotedMessageProps) => {
34
36
  const {
35
37
  message,
38
+ messageId,
39
+ surface,
40
+ isPreview,
36
41
  isOutgoing,
37
42
  createdAtTime,
38
43
  isHighlighted,
@@ -81,11 +86,14 @@ const QuotedMessage = memo((props: QuotedMessageProps) => {
81
86
  return getFileIcon(mimeType);
82
87
  }, [quotedMessage]);
83
88
 
89
+ // Skipped for the tap that dismisses a composer panel (timeline only).
90
+ const shouldSwallowPress = useTimelinePressGuard();
84
91
  const handlePressQuotedMessage = useCallback(() => {
92
+ if (shouldSwallowPress()) return;
85
93
  DeviceEventEmitter.emit(ChatEvent.ScrollToMessage, {
86
94
  clientMessageId: quotedMessage?.clientMsgID || '',
87
95
  });
88
- }, [quotedMessage]);
96
+ }, [quotedMessage, shouldSwallowPress]);
89
97
 
90
98
  return (
91
99
  <>
@@ -104,6 +112,8 @@ const QuotedMessage = memo((props: QuotedMessageProps) => {
104
112
  <KContainer.Touchable
105
113
  style={styles.quotedContainer}
106
114
  onPress={handlePressQuotedMessage}
115
+ // Would scroll the timeline hidden behind the overlay.
116
+ disabled={isPreview}
107
117
  >
108
118
  <KContainer.View style={styles.dot} />
109
119
  <KContainer.VisibleView visible={!!imageUrl}>
@@ -152,9 +162,15 @@ const QuotedMessage = memo((props: QuotedMessageProps) => {
152
162
  </KLabel.Text>
153
163
  </KContainer.View>
154
164
  </KContainer.Touchable>
155
- <KLabel.Text typo="TextMdNormal" color={CHAT_BUBBLE_COLORS.text}>
165
+ <ExpandableMessageText
166
+ messageId={messageId}
167
+ surface={surface}
168
+ text={replyText}
169
+ typo="TextMdNormal"
170
+ color={CHAT_BUBBLE_COLORS.text}
171
+ >
156
172
  {renderLinkifiedText(replyText, urls, onPressUrl, 'reply')}
157
- </KLabel.Text>
173
+ </ExpandableMessageText>
158
174
  </KContainer.View>
159
175
  </BubbleTailRow>
160
176
 
@@ -15,6 +15,7 @@ import { QuotedMessagePreviewStrip } from '../quotedMessage/QuotedMessagePreview
15
15
  import { LinkPreviewCard } from '../linkMessage/LinkPreviewCard';
16
16
  import { extractUrls } from '../../../utils/url';
17
17
  import { renderLinkifiedText } from '../LinkifiedText';
18
+ import { ExpandableMessageText } from '../ExpandableMessageText';
18
19
 
19
20
  const parseMessageEx = (ex?: string): IMessageItemEx | undefined => {
20
21
  if (!ex) return undefined;
@@ -28,6 +29,8 @@ const parseMessageEx = (ex?: string): IMessageItemEx | undefined => {
28
29
  export const TextMessage = memo(
29
30
  ({
30
31
  message,
32
+ messageId,
33
+ surface,
31
34
  isOutgoing,
32
35
  createdAtTime,
33
36
  isHighlighted,
@@ -121,7 +124,13 @@ export const TextMessage = memo(
121
124
  {quotedMessage && (
122
125
  <QuotedMessagePreviewStrip quotedMessage={quotedMessage} />
123
126
  )}
124
- <KLabel.Text typo="TextMdNormal" color={CHAT_BUBBLE_COLORS.text}>
127
+ <ExpandableMessageText
128
+ messageId={messageId}
129
+ surface={surface}
130
+ text={messageText}
131
+ typo="TextMdNormal"
132
+ color={CHAT_BUBBLE_COLORS.text}
133
+ >
125
134
  {segments
126
135
  ? segments.map((segment, index) =>
127
136
  segment.type === 'mention' ? (
@@ -133,7 +142,7 @@ export const TextMessage = memo(
133
142
  )
134
143
  )
135
144
  : messageText}
136
- </KLabel.Text>
145
+ </ExpandableMessageText>
137
146
  {!!urlMetadata && (
138
147
  <LinkPreviewCard metadata={urlMetadata} onPressUrl={onPressUrl} />
139
148
  )}
@@ -1,9 +1,27 @@
1
1
  import type { MediaItem } from '../../hooks/useMediaViewer';
2
2
  import type { MessageItem } from '@droppii/openim-rn-client-sdk';
3
3
  import type { GroupPosition } from '../../utils/legendListMessage';
4
+ import type { MessageSurface } from '../../store/expandedMessage';
4
5
 
5
6
  export interface BaseLegendMessageProps {
6
7
  message: MessageItem;
8
+ /**
9
+ * Resolved once by the caller and threaded down — never re-derived here.
10
+ * The timeline, the pin sheet and the toolbox overlay each compute a
11
+ * message's id differently, and a mismatch silently splits its expansion
12
+ * state across surfaces.
13
+ */
14
+ messageId: string;
15
+ /** Expansion is scoped per surface (BR-003): the same message collapsed in
16
+ * Pin Management while expanded in the timeline, and vice versa. */
17
+ surface: MessageSurface;
18
+ /**
19
+ * Rendered as the toolbox overlay's clone. The clone is touchable so that
20
+ * `Xem thêm` works there (D1), which makes every other press target in the
21
+ * bubble live too — components with a self-contained handler must disable
22
+ * it when this is set.
23
+ */
24
+ isPreview?: boolean;
7
25
  isOutgoing: boolean;
8
26
  createdAtTime: number;
9
27
  isHighlighted?: boolean;
@@ -0,0 +1,15 @@
1
+ import { createContext, useContext } from 'react';
2
+
3
+ const neverSwallow = () => false;
4
+
5
+ /**
6
+ * Timeline-only press guard. ChatListLegend provides the tap-outside panel
7
+ * dismiss's `shouldSwallowPress` (useComposerPanelDismiss); every other
8
+ * surface (Pin Management, toolbox overlay) gets the no-op default.
9
+ */
10
+ const TimelinePressGuardContext = createContext<() => boolean>(neverSwallow);
11
+
12
+ export const TimelinePressGuardProvider = TimelinePressGuardContext.Provider;
13
+
14
+ export const useTimelinePressGuard = () =>
15
+ useContext(TimelinePressGuardContext);
@@ -4,6 +4,7 @@ import OpenIMSDK from '@droppii/openim-rn-client-sdk';
4
4
  import { conversationQueryKeys } from './query-keys';
5
5
  import { useConversationStore } from '../store';
6
6
  import type { DChatApplicationType } from '../types/chat';
7
+ import { perfMark } from '../utils/perf-marker';
7
8
 
8
9
  type Params = {
9
10
  applicationType: DChatApplicationType;
@@ -33,6 +34,7 @@ export function useConversationList({
33
34
  });
34
35
 
35
36
  useConversationStore.getState().updateConversations(conversations);
37
+ perfMark('conversation_list_loaded', { count: conversations.length });
36
38
  return conversations;
37
39
  },
38
40
  });
@@ -0,0 +1,19 @@
1
+ import { useMemo } from 'react';
2
+ import { Keyboard } from 'react-native';
3
+
4
+ /**
5
+ * Wraps a press that leaves the timeline (media viewer, link, file viewer) so
6
+ * the keyboard is dismissed first. The timeline uses
7
+ * `keyboardShouldPersistTaps="handled"`, so the keyboard would otherwise stay
8
+ * up over the screen the press opens. An absent handler stays absent.
9
+ */
10
+ export const useDismissKeyboardFirst = <A extends unknown[]>(
11
+ handler: ((...args: A) => unknown) | undefined
12
+ ): ((...args: A) => void) | undefined =>
13
+ useMemo(() => {
14
+ if (!handler) return handler;
15
+ return (...args: A) => {
16
+ Keyboard.dismiss();
17
+ handler(...args);
18
+ };
19
+ }, [handler]);
@@ -0,0 +1,143 @@
1
+ import { useCallback, useState } from 'react';
2
+ import { useWindowDimensions, type TextLayoutEvent } from 'react-native';
3
+ import { useExpandedMessageStore } from '../store/expandedMessage';
4
+ import { useTimelinePressGuard } from '../context/TimelinePressGuardContext';
5
+ import {
6
+ COLLAPSED_LINES,
7
+ expandedKey,
8
+ isMeasurementCandidate,
9
+ type MessageSurface,
10
+ } from '../utils/expandableText';
11
+ import { initialCut, joinLines, shrinkCut } from '../utils/inlineTruncation';
12
+
13
+ export { COLLAPSED_LINES };
14
+
15
+ type Verdict = { truncated: false } | { truncated: true; cut: number };
16
+
17
+ // Each pass is one extra layout; in practice the suffix fits by the second.
18
+ // Past this the suffix may sit on a sixth line — still visible and tappable.
19
+ const MAX_FIT_PASSES = 4;
20
+
21
+ /**
22
+ * Truncation is a pure function of (text, available width, typography), so it
23
+ * belongs to the message — not to the list row that happens to render it.
24
+ * `recycleItems` reuses row instances, so a verdict held in component state
25
+ * bleeds onto the next message in that slot. Keyed here, a recycled row reads
26
+ * the right answer during render and pays no measurement for a message seen.
27
+ */
28
+ const verdictCache = new Map<string, Verdict>();
29
+
30
+ export const clearExpandableTextCache = () => verdictCache.clear();
31
+
32
+ // Window width, not the bubble's: the bubble shrinks to its content, and the
33
+ // content changes when the suffix is appended, so keying on it would re-measure
34
+ // in a loop. Surface is in the key because the pin sheet is a different width.
35
+ // Text length makes a body that mutates in place (TextMessage re-derives its
36
+ // runs when `ex` arrives) miss the cache rather than reuse a stale cut.
37
+ const verdictKey = (
38
+ surface: MessageSurface,
39
+ messageId: string,
40
+ windowWidth: number,
41
+ text: string
42
+ ) => `${surface}:${messageId}:${Math.round(windowWidth)}:${text.length}`;
43
+
44
+ interface Fit {
45
+ key: string | null;
46
+ full?: string;
47
+ cut?: number;
48
+ passes: number;
49
+ verdict?: Verdict;
50
+ }
51
+
52
+ export type ExpandableTextMode =
53
+ | 'plain'
54
+ | 'measuring'
55
+ | 'fitting'
56
+ | 'collapsed';
57
+
58
+ export function useExpandableText(
59
+ messageId: string,
60
+ text: string,
61
+ surface: MessageSurface
62
+ ) {
63
+ const expanded = useExpandedMessageStore(
64
+ (s) => !!s.expanded[expandedKey(surface, messageId)]
65
+ );
66
+ const expandInStore = useExpandedMessageStore((s) => s.expand);
67
+ const { width: windowWidth } = useWindowDimensions();
68
+
69
+ const key = messageId
70
+ ? verdictKey(surface, messageId, windowWidth, text)
71
+ : null;
72
+
73
+ // Reset during render rather than in an effect: an effect lands after paint,
74
+ // so a recycled row would show one frame with the previous message's cut.
75
+ const [fit, setFit] = useState<Fit>({ key, passes: 0 });
76
+ const current = fit.key === key ? fit : { key, passes: 0 };
77
+ if (fit.key !== key) setFit(current);
78
+
79
+ // Without an id there is nowhere to record an expansion, so the control
80
+ // would do nothing. Never collapse those.
81
+ const isCandidate = !!messageId && !expanded && isMeasurementCandidate(text);
82
+ const verdict = (key && verdictCache.get(key)) || current.verdict;
83
+
84
+ let mode: ExpandableTextMode = 'plain';
85
+ if (isCandidate) {
86
+ if (verdict) mode = verdict.truncated ? 'collapsed' : 'plain';
87
+ else mode = current.cut == null ? 'measuring' : 'fitting';
88
+ }
89
+ const cut = verdict?.truncated ? verdict.cut : (current.cut ?? 0);
90
+
91
+ const settle = useCallback(
92
+ (next: Verdict) => {
93
+ if (key) verdictCache.set(key, next);
94
+ setFit((prev) => (prev.key === key ? { ...prev, verdict: next } : prev));
95
+ },
96
+ [key]
97
+ );
98
+
99
+ // Full, unclamped copy: how many lines, and where line N ends.
100
+ const onMeasureTextLayout = useCallback(
101
+ ({ nativeEvent: { lines } }: TextLayoutEvent) => {
102
+ if (lines.length <= COLLAPSED_LINES) {
103
+ settle({ truncated: false });
104
+ return;
105
+ }
106
+ const full = joinLines(lines);
107
+ const first = initialCut(lines, COLLAPSED_LINES);
108
+ setFit((prev) =>
109
+ prev.key === key ? { ...prev, full, cut: first, passes: 0 } : prev
110
+ );
111
+ },
112
+ [key, settle]
113
+ );
114
+
115
+ // The visible copy with the suffix appended: accept once it fits in N lines.
116
+ const onFitTextLayout = useCallback(
117
+ ({ nativeEvent: { lines } }: TextLayoutEvent) => {
118
+ if (fit.key !== key || fit.full == null || fit.cut == null) return;
119
+ if (lines.length <= COLLAPSED_LINES || fit.passes >= MAX_FIT_PASSES) {
120
+ settle({ truncated: true, cut: fit.cut });
121
+ return;
122
+ }
123
+ const next = shrinkCut(fit.full, fit.cut, lines, COLLAPSED_LINES);
124
+ setFit({ ...fit, cut: next, passes: fit.passes + 1 });
125
+ },
126
+ [fit, key, settle]
127
+ );
128
+
129
+ // Skipped for the tap that dismisses a composer panel (timeline only).
130
+ const shouldSwallowPress = useTimelinePressGuard();
131
+ const expand = useCallback(() => {
132
+ if (shouldSwallowPress()) return;
133
+ expandInStore(surface, messageId);
134
+ }, [expandInStore, surface, messageId, shouldSwallowPress]);
135
+
136
+ return {
137
+ mode,
138
+ cut,
139
+ expand,
140
+ onMeasureTextLayout,
141
+ onFitTextLayout,
142
+ };
143
+ }
@@ -0,0 +1,20 @@
1
+ import { useMemo } from 'react';
2
+
3
+ /**
4
+ * Wraps a timeline press handler so it is skipped while `shouldSwallow()` is
5
+ * true — the tap-outside panel dismiss (useComposerPanelDismiss) swallows the
6
+ * press of a touch that began with a composer panel open. An absent handler
7
+ * stays absent, so "not tappable" rows keep behaving as before. The wrapper's
8
+ * return value is dropped: press handlers are fire-and-forget.
9
+ */
10
+ export const useGuardedPress = <A extends unknown[]>(
11
+ handler: ((...args: A) => unknown) | undefined,
12
+ shouldSwallow: (() => boolean) | undefined
13
+ ): ((...args: A) => void) | undefined =>
14
+ useMemo(() => {
15
+ if (!handler || !shouldSwallow) return handler;
16
+ return (...args: A) => {
17
+ if (shouldSwallow()) return;
18
+ handler(...args);
19
+ };
20
+ }, [handler, shouldSwallow]);