@droppii-org/chat-mobile 0.2.81 → 0.2.82

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 (151) hide show
  1. package/lib/module/components/ThreadCard/MentionBadge.js +24 -0
  2. package/lib/module/components/ThreadCard/MentionBadge.js.map +1 -0
  3. package/lib/module/components/ThreadCard/ThreadCard.js +5 -1
  4. package/lib/module/components/ThreadCard/ThreadCard.js.map +1 -1
  5. package/lib/module/components/messages/linkMessage/LinkPreviewCard.js +67 -0
  6. package/lib/module/components/messages/linkMessage/LinkPreviewCard.js.map +1 -0
  7. package/lib/module/components/messages/linkMessage/index.js +21 -68
  8. package/lib/module/components/messages/linkMessage/index.js.map +1 -1
  9. package/lib/module/components/messages/quotedMessage/QuotedMessagePreviewStrip.js +152 -0
  10. package/lib/module/components/messages/quotedMessage/QuotedMessagePreviewStrip.js.map +1 -0
  11. package/lib/module/components/messages/textMessage/index.js +102 -8
  12. package/lib/module/components/messages/textMessage/index.js.map +1 -1
  13. package/lib/module/core/useChatListener.js +1 -1
  14. package/lib/module/core/useChatListener.js.map +1 -1
  15. package/lib/module/hooks/message/useSendMessage.js +64 -3
  16. package/lib/module/hooks/message/useSendMessage.js.map +1 -1
  17. package/lib/module/hooks/query-keys.js +4 -0
  18. package/lib/module/hooks/query-keys.js.map +1 -1
  19. package/lib/module/hooks/useChatCompose.js +3 -2
  20. package/lib/module/hooks/useChatCompose.js.map +1 -1
  21. package/lib/module/screens/chat-detail/ChatComposer.js +170 -48
  22. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  23. package/lib/module/screens/chat-detail/ChatListLegend.js +1 -0
  24. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  25. package/lib/module/screens/chat-detail/components/ChatMessageInput.js +23 -2
  26. package/lib/module/screens/chat-detail/components/ChatMessageInput.js.map +1 -1
  27. package/lib/module/screens/chat-detail/components/MentionHighlightAdapter.js +51 -0
  28. package/lib/module/screens/chat-detail/components/MentionHighlightAdapter.js.map +1 -0
  29. package/lib/module/screens/chat-detail/components/MentionSuggestionItem.js +49 -0
  30. package/lib/module/screens/chat-detail/components/MentionSuggestionItem.js.map +1 -0
  31. package/lib/module/screens/chat-detail/components/MentionSuggestionList.js +114 -0
  32. package/lib/module/screens/chat-detail/components/MentionSuggestionList.js.map +1 -0
  33. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js +8 -0
  34. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js.map +1 -1
  35. package/lib/module/screens/chat-detail/hooks/useMentionComposer.js +251 -0
  36. package/lib/module/screens/chat-detail/hooks/useMentionComposer.js.map +1 -0
  37. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +5 -1
  38. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  39. package/lib/module/services/mentionSearch.js +49 -0
  40. package/lib/module/services/mentionSearch.js.map +1 -0
  41. package/lib/module/services/message.js +6 -1
  42. package/lib/module/services/message.js.map +1 -1
  43. package/lib/module/translation/resources/i18n.js +5 -0
  44. package/lib/module/translation/resources/i18n.js.map +1 -1
  45. package/lib/module/types/chat.js.map +1 -1
  46. package/lib/module/types/mention.js +2 -0
  47. package/lib/module/types/mention.js.map +1 -0
  48. package/lib/module/utils/mentionSerializer.js +113 -0
  49. package/lib/module/utils/mentionSerializer.js.map +1 -0
  50. package/lib/module/utils/mentions/index.js +7 -0
  51. package/lib/module/utils/mentions/index.js.map +1 -0
  52. package/lib/module/utils/mentions/mentionDetector.js +49 -0
  53. package/lib/module/utils/mentions/mentionDetector.js.map +1 -0
  54. package/lib/module/utils/mentions/mentionDiff.js +66 -0
  55. package/lib/module/utils/mentions/mentionDiff.js.map +1 -0
  56. package/lib/module/utils/mentions/mentionRange.js +188 -0
  57. package/lib/module/utils/mentions/mentionRange.js.map +1 -0
  58. package/lib/module/utils/mentions/mentionTokenize.js +50 -0
  59. package/lib/module/utils/mentions/mentionTokenize.js.map +1 -0
  60. package/lib/module/utils/messageUtils.js +6 -1
  61. package/lib/module/utils/messageUtils.js.map +1 -1
  62. package/lib/module/utils/url.js +33 -0
  63. package/lib/module/utils/url.js.map +1 -1
  64. package/lib/typescript/src/components/ThreadCard/MentionBadge.d.ts +7 -0
  65. package/lib/typescript/src/components/ThreadCard/MentionBadge.d.ts.map +1 -0
  66. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  67. package/lib/typescript/src/components/messages/linkMessage/LinkPreviewCard.d.ts +16 -0
  68. package/lib/typescript/src/components/messages/linkMessage/LinkPreviewCard.d.ts.map +1 -0
  69. package/lib/typescript/src/components/messages/linkMessage/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.d.ts +13 -0
  71. package/lib/typescript/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.d.ts.map +1 -0
  72. package/lib/typescript/src/components/messages/textMessage/index.d.ts +3 -1
  73. package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
  74. package/lib/typescript/src/hooks/message/useSendMessage.d.ts +5 -2
  75. package/lib/typescript/src/hooks/message/useSendMessage.d.ts.map +1 -1
  76. package/lib/typescript/src/hooks/query-keys.d.ts +4 -0
  77. package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
  78. package/lib/typescript/src/hooks/useChatCompose.d.ts +2 -1
  79. package/lib/typescript/src/hooks/useChatCompose.d.ts.map +1 -1
  80. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  81. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  82. package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts +10 -2
  83. package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts.map +1 -1
  84. package/lib/typescript/src/screens/chat-detail/components/MentionHighlightAdapter.d.ts +29 -0
  85. package/lib/typescript/src/screens/chat-detail/components/MentionHighlightAdapter.d.ts.map +1 -0
  86. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionItem.d.ts +8 -0
  87. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionItem.d.ts.map +1 -0
  88. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionList.d.ts +12 -0
  89. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionList.d.ts.map +1 -0
  90. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts +1 -1
  91. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts.map +1 -1
  92. package/lib/typescript/src/screens/chat-detail/hooks/useMentionComposer.d.ts +48 -0
  93. package/lib/typescript/src/screens/chat-detail/hooks/useMentionComposer.d.ts.map +1 -0
  94. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  95. package/lib/typescript/src/services/mentionSearch.d.ts +15 -0
  96. package/lib/typescript/src/services/mentionSearch.d.ts.map +1 -0
  97. package/lib/typescript/src/services/message.d.ts +1 -1
  98. package/lib/typescript/src/services/message.d.ts.map +1 -1
  99. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  100. package/lib/typescript/src/types/chat.d.ts +23 -0
  101. package/lib/typescript/src/types/chat.d.ts.map +1 -1
  102. package/lib/typescript/src/types/mention.d.ts +65 -0
  103. package/lib/typescript/src/types/mention.d.ts.map +1 -0
  104. package/lib/typescript/src/utils/mentionSerializer.d.ts +78 -0
  105. package/lib/typescript/src/utils/mentionSerializer.d.ts.map +1 -0
  106. package/lib/typescript/src/utils/mentions/index.d.ts +5 -0
  107. package/lib/typescript/src/utils/mentions/index.d.ts.map +1 -0
  108. package/lib/typescript/src/utils/mentions/mentionDetector.d.ts +18 -0
  109. package/lib/typescript/src/utils/mentions/mentionDetector.d.ts.map +1 -0
  110. package/lib/typescript/src/utils/mentions/mentionDiff.d.ts +20 -0
  111. package/lib/typescript/src/utils/mentions/mentionDiff.d.ts.map +1 -0
  112. package/lib/typescript/src/utils/mentions/mentionRange.d.ts +86 -0
  113. package/lib/typescript/src/utils/mentions/mentionRange.d.ts.map +1 -0
  114. package/lib/typescript/src/utils/mentions/mentionTokenize.d.ts +29 -0
  115. package/lib/typescript/src/utils/mentions/mentionTokenize.d.ts.map +1 -0
  116. package/lib/typescript/src/utils/messageUtils.d.ts.map +1 -1
  117. package/lib/typescript/src/utils/url.d.ts +12 -0
  118. package/lib/typescript/src/utils/url.d.ts.map +1 -1
  119. package/package.json +1 -1
  120. package/src/components/ThreadCard/MentionBadge.tsx +28 -0
  121. package/src/components/ThreadCard/ThreadCard.tsx +3 -0
  122. package/src/components/messages/linkMessage/LinkPreviewCard.tsx +92 -0
  123. package/src/components/messages/linkMessage/index.tsx +25 -89
  124. package/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.tsx +168 -0
  125. package/src/components/messages/textMessage/index.tsx +124 -4
  126. package/src/core/useChatListener.ts +1 -1
  127. package/src/hooks/message/useSendMessage.ts +60 -1
  128. package/src/hooks/query-keys.ts +6 -0
  129. package/src/hooks/useChatCompose.ts +115 -107
  130. package/src/screens/chat-detail/ChatComposer.tsx +201 -67
  131. package/src/screens/chat-detail/ChatListLegend.tsx +1 -0
  132. package/src/screens/chat-detail/components/ChatMessageInput.tsx +35 -2
  133. package/src/screens/chat-detail/components/MentionHighlightAdapter.tsx +57 -0
  134. package/src/screens/chat-detail/components/MentionSuggestionItem.tsx +53 -0
  135. package/src/screens/chat-detail/components/MentionSuggestionList.tsx +133 -0
  136. package/src/screens/chat-detail/hooks/useChatComposerState.ts +7 -1
  137. package/src/screens/chat-detail/hooks/useMentionComposer.ts +344 -0
  138. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +7 -1
  139. package/src/services/mentionSearch.ts +57 -0
  140. package/src/services/message.ts +9 -1
  141. package/src/translation/resources/i18n.ts +6 -0
  142. package/src/types/chat.ts +22 -0
  143. package/src/types/mention.ts +70 -0
  144. package/src/utils/mentionSerializer.ts +155 -0
  145. package/src/utils/mentions/index.ts +16 -0
  146. package/src/utils/mentions/mentionDetector.ts +58 -0
  147. package/src/utils/mentions/mentionDiff.ts +95 -0
  148. package/src/utils/mentions/mentionRange.ts +207 -0
  149. package/src/utils/mentions/mentionTokenize.ts +53 -0
  150. package/src/utils/messageUtils.ts +6 -1
  151. package/src/utils/url.ts +30 -0
@@ -13,6 +13,7 @@ import {
13
13
  generateAndUploadVideoThumbnails,
14
14
  type VideoWithUploadStatus,
15
15
  } from '../utils/videoThumbnailUpload';
16
+ import type { Mention } from '../types/mention';
16
17
 
17
18
  export function useChatCompose() {
18
19
  const [text, setText] = useState('');
@@ -45,123 +46,130 @@ export function useChatCompose() {
45
46
  ]
46
47
  );
47
48
 
48
- const send = useCallback(async () => {
49
- const { selectedImages, clearAllImages } = imageAttachment;
50
- const { selectedFiles, clearAllFiles } = fileAttachment;
51
- const urlMeta = linkPreview.isVisible ? linkPreview.metadata : undefined;
49
+ const send = useCallback(
50
+ async (mentions?: Mention[]) => {
51
+ const { selectedImages, clearAllImages } = imageAttachment;
52
+ const { selectedFiles, clearAllFiles } = fileAttachment;
53
+ const urlMeta = linkPreview.isVisible ? linkPreview.metadata : undefined;
52
54
 
53
- // --- Attachments ---
54
- if (selectedImages.length > 0 || selectedFiles.length > 0) {
55
- const actualImages = selectedImages.filter(
56
- (img: ImageWithUploadStatus) => !isVideoFromPicker(img.image)
57
- );
58
- const videos = selectedImages.filter((img: ImageWithUploadStatus) =>
59
- isVideoFromPicker(img.image)
60
- );
55
+ // --- Attachments ---
56
+ if (selectedImages.length > 0 || selectedFiles.length > 0) {
57
+ const actualImages = selectedImages.filter(
58
+ (img: ImageWithUploadStatus) => !isVideoFromPicker(img.image)
59
+ );
60
+ const videos = selectedImages.filter((img: ImageWithUploadStatus) =>
61
+ isVideoFromPicker(img.image)
62
+ );
61
63
 
62
- const uploadedImages = actualImages.filter(
63
- (img: ImageWithUploadStatus) => img.uploadedUrl
64
- );
65
- const uploadedVideos = videos.filter(
66
- (v) => v.uploadedUrl
67
- ) as unknown as VideoWithUploadStatus[];
64
+ const uploadedImages = actualImages.filter(
65
+ (img: ImageWithUploadStatus) => img.uploadedUrl
66
+ );
67
+ const uploadedVideos = videos.filter(
68
+ (v) => v.uploadedUrl
69
+ ) as unknown as VideoWithUploadStatus[];
68
70
 
69
- // Thumbnails are generated + uploaded at pick time (useImageAttachment).
70
- // Only fall back to generating here for videos that don't already have one
71
- // (e.g. thumbnail generation failed or hadn't finished before send).
72
- const videosMissingThumbnail = uploadedVideos.filter(
73
- (v) => !v.thumbnailUrl
74
- );
75
- let videoThumbnails: Record<string, { url: string; size: number }> = {};
76
- if (videosMissingThumbnail.length > 0) {
77
- videoThumbnails = await generateAndUploadVideoThumbnails(
78
- videosMissingThumbnail
71
+ // Thumbnails are generated + uploaded at pick time (useImageAttachment).
72
+ // Only fall back to generating here for videos that don't already have one
73
+ // (e.g. thumbnail generation failed or hadn't finished before send).
74
+ const videosMissingThumbnail = uploadedVideos.filter(
75
+ (v) => !v.thumbnailUrl
79
76
  );
80
- }
77
+ let videoThumbnails: Record<string, { url: string; size: number }> = {};
78
+ if (videosMissingThumbnail.length > 0) {
79
+ videoThumbnails = await generateAndUploadVideoThumbnails(
80
+ videosMissingThumbnail
81
+ );
82
+ }
81
83
 
82
- // Send images + videos
83
- if (uploadedImages.length > 0 || uploadedVideos.length > 0) {
84
- const attachmentItems = [
85
- ...uploadedImages.map((img: ImageWithUploadStatus) => ({
86
- ...img,
87
- type: 'image' as const,
88
- })),
89
- ...uploadedVideos.map(
90
- (vid: VideoWithUploadStatus, vidIdx: number) => {
91
- const videoId = vid.uploadedUrl || `video-${vidIdx}`;
92
- const thumb = videoThumbnails[videoId];
93
- return {
94
- type: 'video' as const,
95
- video: {
96
- path: vid.image.path,
97
- filename: vid.image.filename ?? undefined,
98
- size: vid.image.size ?? undefined,
99
- width: vid.image.width ?? undefined,
100
- height: vid.image.height ?? undefined,
101
- duration: vid.image.duration ?? undefined,
102
- },
103
- uploadProgress: vid.uploadProgress,
104
- error: vid.error,
105
- isUploading: vid.isUploading,
106
- uploadedUrl: vid.uploadedUrl,
107
- thumbnailUrl: vid.thumbnailUrl ?? thumb?.url,
108
- thumbnailSize: vid.thumbnailSize ?? thumb?.size,
109
- };
110
- }
111
- ),
112
- ];
84
+ // Send images + videos
85
+ if (uploadedImages.length > 0 || uploadedVideos.length > 0) {
86
+ const attachmentItems = [
87
+ ...uploadedImages.map((img: ImageWithUploadStatus) => ({
88
+ ...img,
89
+ type: 'image' as const,
90
+ })),
91
+ ...uploadedVideos.map(
92
+ (vid: VideoWithUploadStatus, vidIdx: number) => {
93
+ const videoId = vid.uploadedUrl || `video-${vidIdx}`;
94
+ const thumb = videoThumbnails[videoId];
95
+ return {
96
+ type: 'video' as const,
97
+ video: {
98
+ path: vid.image.path,
99
+ filename: vid.image.filename ?? undefined,
100
+ size: vid.image.size ?? undefined,
101
+ width: vid.image.width ?? undefined,
102
+ height: vid.image.height ?? undefined,
103
+ duration: vid.image.duration ?? undefined,
104
+ },
105
+ uploadProgress: vid.uploadProgress,
106
+ error: vid.error,
107
+ isUploading: vid.isUploading,
108
+ uploadedUrl: vid.uploadedUrl,
109
+ thumbnailUrl: vid.thumbnailUrl ?? thumb?.url,
110
+ thumbnailSize: vid.thumbnailSize ?? thumb?.size,
111
+ };
112
+ }
113
+ ),
114
+ ];
113
115
 
114
- // Clear composer immediately — message is already in chat list with Sending status.
115
- // User retries from the bubble, not the composer.
116
- ImagePicker.clean().catch(() => {});
117
- clearAllImages();
118
- // Awaited so files (lower priority per attachment-priority config)
119
- // only start sending after images/videos are done.
120
- await sendAttachments(attachmentItems, undefined);
121
- }
116
+ // Clear composer immediately — message is already in chat list with Sending status.
117
+ // User retries from the bubble, not the composer.
118
+ ImagePicker.clean().catch(() => {});
119
+ clearAllImages();
120
+ // Awaited so files (lower priority per attachment-priority config)
121
+ // only start sending after images/videos are done.
122
+ await sendAttachments(attachmentItems, undefined);
123
+ }
122
124
 
123
- // Send each file as its own message (never merged), in priority order
124
- // after images/videos, one at a time to preserve pick order.
125
- const uploadedFiles = selectedFiles.filter(
126
- (f) => f.uploadedUrl && !f.error
127
- );
128
- if (uploadedFiles.length > 0) {
129
- clearAllFiles();
130
- for (const file of uploadedFiles) {
131
- await sendAttachments(
132
- [
133
- {
134
- type: 'file' as const,
135
- file: { path: file.uri, filename: file.name, size: file.size },
136
- uploadProgress: 100,
137
- error: null,
138
- isUploading: false,
139
- uploadedUrl: file.uploadedUrl,
140
- },
141
- ],
142
- undefined
143
- );
125
+ // Send each file as its own message (never merged), in priority order
126
+ // after images/videos, one at a time to preserve pick order.
127
+ const uploadedFiles = selectedFiles.filter(
128
+ (f) => f.uploadedUrl && !f.error
129
+ );
130
+ if (uploadedFiles.length > 0) {
131
+ clearAllFiles();
132
+ for (const file of uploadedFiles) {
133
+ await sendAttachments(
134
+ [
135
+ {
136
+ type: 'file' as const,
137
+ file: {
138
+ path: file.uri,
139
+ filename: file.name,
140
+ size: file.size,
141
+ },
142
+ uploadProgress: 100,
143
+ error: null,
144
+ isUploading: false,
145
+ uploadedUrl: file.uploadedUrl,
146
+ },
147
+ ],
148
+ undefined
149
+ );
150
+ }
144
151
  }
145
152
  }
146
- }
147
153
 
148
- // --- Text / link preview ---
149
- if (text.trim() || urlMeta) {
150
- const plainText = text.trim();
151
- // Clear composer immediately — message is already in chat list with Sending status.
152
- // User retries from the bubble, not the composer.
153
- setText('');
154
- await sendMessage({ plainText, urlMetadata: urlMeta });
155
- }
156
- }, [
157
- imageAttachment,
158
- fileAttachment,
159
- linkPreview.isVisible,
160
- linkPreview.metadata,
161
- sendAttachments,
162
- sendMessage,
163
- text,
164
- ]);
154
+ // --- Text / link preview ---
155
+ if (text.trim() || urlMeta) {
156
+ const plainText = text.trim();
157
+ // Clear composer immediately — message is already in chat list with Sending status.
158
+ // User retries from the bubble, not the composer.
159
+ setText('');
160
+ await sendMessage({ plainText, urlMetadata: urlMeta, mentions });
161
+ }
162
+ },
163
+ [
164
+ imageAttachment,
165
+ fileAttachment,
166
+ linkPreview.isVisible,
167
+ linkPreview.metadata,
168
+ sendAttachments,
169
+ sendMessage,
170
+ text,
171
+ ]
172
+ );
165
173
 
166
174
  return {
167
175
  // Text
@@ -66,6 +66,13 @@ import { UIUtils } from '../../utils/UIUtils';
66
66
  import { PeerType } from '@droppii/openim-rn-client-sdk';
67
67
  import { useCanSendMessage } from '../../hooks/useGroupPermission';
68
68
  import { useIsJoinedGroup } from '../../hooks/useIsJoinedGroup';
69
+ import { useMentionComposer } from './hooks/useMentionComposer';
70
+ import { MentionSuggestionList } from './components/MentionSuggestionList';
71
+ import { createOpenIMMentionSearchSource } from '../../services/mentionSearch';
72
+ import {
73
+ serializeMentionsForSend,
74
+ MAX_AT_USER_COUNT,
75
+ } from '../../utils/mentionSerializer';
69
76
 
70
77
  interface ChatComposerInternalProps {
71
78
  isEmptyMessage?: boolean;
@@ -125,6 +132,28 @@ export const ChatComposer = memo(
125
132
  inputRef,
126
133
  } = useChatComposerState(compose.text);
127
134
 
135
+ const mentionSearchSource = useMemo(
136
+ () =>
137
+ conversation?.groupID
138
+ ? createOpenIMMentionSearchSource(conversation.groupID)
139
+ : null,
140
+ [conversation?.groupID]
141
+ );
142
+
143
+ const mentionComposer = useMentionComposer({
144
+ text: compose.text,
145
+ setText: compose.setText,
146
+ // Non-null asserted only where actually invoked — the composer isn't
147
+ // rendered for non-group conversations in the first place (isGroupConversation
148
+ // below), so groupID is always present by the time a search fires.
149
+ searchSource: mentionSearchSource ?? {
150
+ search: async () => ({ items: [], hasMore: false }),
151
+ },
152
+ groupID: conversation?.groupID,
153
+ enabled: isGroupConversation,
154
+ inputRef,
155
+ });
156
+
128
157
  const attachmentPanelHeight = useMemo(
129
158
  () => getAttachmentPanelHeight(DEFAULT_PANNEL_ACTIONS.length),
130
159
  []
@@ -145,6 +174,19 @@ export const ChatComposer = memo(
145
174
  onAttachmentOpenChange: setIsAttachmentOpen,
146
175
  });
147
176
 
177
+ // Mutual exclusion (finding #5 in the mention plan): the mention picker
178
+ // never renders through the attachment/sticker panel slot, but opening
179
+ // one must still close the other — starting a mention query while the
180
+ // attachment/sticker panel is open closes that panel.
181
+ useEffect(() => {
182
+ if (mentionComposer.isPanelOpen && isAttachmentOpen) {
183
+ const immediate = activePanelType === 'sticker';
184
+ closeAttachment(!immediate, immediate);
185
+ setActivePanelType(null);
186
+ }
187
+ // eslint-disable-next-line react-hooks/exhaustive-deps
188
+ }, [mentionComposer.isPanelOpen]);
189
+
148
190
  const handleSend = useCallback(async () => {
149
191
  if (!isSendMessageAllowed) {
150
192
  UIUtils.toast.open({
@@ -155,12 +197,59 @@ export const ChatComposer = memo(
155
197
  }
156
198
 
157
199
  if (compose.isUploading || !compose.canSend) return;
200
+ const mentionsToSend = mentionComposer.mentions;
201
+
202
+ // OpenIM's Go core hard-rejects an AtTextMessage tagging more than
203
+ // MAX_AT_USER_COUNT users, so serializeMentionsForSend caps
204
+ // atUserIDList to the first MAX_AT_USER_COUNT (mentionSerializer.ts's
205
+ // doc comment) — the message still sends, with every mention still
206
+ // highlighted in the text, just only the first MAX_AT_USER_COUNT
207
+ // actually get natively tagged/notified. This is a heads-up, not a
208
+ // block, so no `return` here.
209
+ if (mentionsToSend.length > 0) {
210
+ const { truncated } = serializeMentionsForSend(mentionsToSend);
211
+ if (truncated) {
212
+ UIUtils.toast.open({
213
+ title: trans('group:tag_limit_exceeded', {
214
+ max: String(MAX_AT_USER_COUNT),
215
+ }),
216
+ theme: 'info',
217
+ });
218
+ }
219
+ }
220
+
221
+ // Clear mention state synchronously, in the same tick as
222
+ // compose.send() (which clears `text`) — otherwise there's a real
223
+ // transient render where `value` is already '' but `mentions` still
224
+ // holds the just-sent entries, and the composer can visibly fail to
225
+ // clear (ChatMessageInput stays in children-controlled mode with a
226
+ // "ghost" mention anchored past the end of an empty string). See
227
+ // useMentionComposer.clear()'s doc comment.
228
+ mentionComposer.clear();
158
229
  try {
159
- await compose.send();
230
+ await compose.send(mentionsToSend);
160
231
  } catch (error) {
161
232
  if (__DEV__) console.error('[ChatComposer] Failed to send:', error);
233
+ } finally {
234
+ // Belt-and-suspenders: force the native view to actually clear,
235
+ // regardless of whether the value/children mode-switch (triggered
236
+ // by mentions going from non-empty to empty) synced correctly on
237
+ // its own — the underlying nested-Text-in-TextInput technique is
238
+ // known to have platform quirks around this kind of transition.
239
+ inputRef.current?.clear();
162
240
  }
163
- }, [compose, isSendMessageAllowed, sendDeniedReason]);
241
+ // Depend on mentionComposer.mentions/.clear specifically (both
242
+ // stable-per-value / [] deps), not the whole mentionComposer object —
243
+ // same reasoning as handleToggleAttachment above.
244
+ // eslint-disable-next-line react-hooks/exhaustive-deps
245
+ }, [
246
+ compose,
247
+ isSendMessageAllowed,
248
+ sendDeniedReason,
249
+ mentionComposer.mentions,
250
+ mentionComposer.clear,
251
+ inputRef,
252
+ ]);
164
253
 
165
254
  const handleInputFocus = useCallback(() => {
166
255
  setIsInputFocused(true);
@@ -181,6 +270,7 @@ export const ChatComposer = memo(
181
270
  ]);
182
271
 
183
272
  const handleToggleAttachment = useCallback(() => {
273
+ mentionComposer.closeMentionPanel();
184
274
  if (activePanelType === 'sticker') {
185
275
  // switch from sticker to attachment
186
276
  setActivePanelType('attachment');
@@ -189,6 +279,16 @@ export const ChatComposer = memo(
189
279
  }
190
280
  setActivePanelType(isAttachmentOpen ? null : 'attachment');
191
281
  toggleAttachmentVisibility();
282
+ // Deliberately depend on `mentionComposer.closeMentionPanel` (stable —
283
+ // [] deps in useMentionComposer), NOT the whole `mentionComposer`
284
+ // object, which is a fresh literal every render (useMentionComposer
285
+ // doesn't memoize its return value). Depending on the whole object
286
+ // recreated this callback — and therefore every prop built from it,
287
+ // e.g. ChatMessageInput's onEmojiPress — on every single render,
288
+ // which contributed to a real "Maximum update depth exceeded" loop
289
+ // found in testing. The lint rule can't prove member-access stability
290
+ // on its own, hence the disable.
291
+ // eslint-disable-next-line react-hooks/exhaustive-deps
192
292
  }, [
193
293
  activePanelType,
194
294
  isAttachmentOpen,
@@ -196,9 +296,11 @@ export const ChatComposer = memo(
196
296
  openWithHeight,
197
297
  toggleAttachmentVisibility,
198
298
  setActivePanelType,
299
+ mentionComposer.closeMentionPanel,
199
300
  ]);
200
301
 
201
302
  const handleToggleSticker = useCallback(() => {
303
+ mentionComposer.closeMentionPanel();
202
304
  if (activePanelType === 'sticker') {
203
305
  closeAttachment();
204
306
  setActivePanelType(null);
@@ -209,9 +311,12 @@ export const ChatComposer = memo(
209
311
  }
210
312
  setActivePanelType('sticker');
211
313
  openWithHeight(STICKER_PICKER_HEIGHT);
314
+ // Same reasoning as handleToggleAttachment above — see that comment.
315
+ // eslint-disable-next-line react-hooks/exhaustive-deps
212
316
  }, [
213
317
  activePanelType,
214
318
  isKeyboardVisible,
319
+ mentionComposer.closeMentionPanel,
215
320
  closeAttachment,
216
321
  openWithHeight,
217
322
  setActivePanelType,
@@ -221,12 +326,10 @@ export const ChatComposer = memo(
221
326
  setIsInputFocused(false);
222
327
  }, [setIsInputFocused]);
223
328
 
224
- const handleChangeText = useCallback(
225
- (text?: string) => {
226
- compose.setText(text ?? '');
227
- },
228
- [compose]
229
- );
329
+ // Routes through mentionComposer instead of compose.setText directly —
330
+ // it owns the atomic-delete/reconcile pipeline and calls compose.setText
331
+ // itself once the corrected text is known (see useMentionComposer).
332
+ const handleChangeText = mentionComposer.handleChangeText;
230
333
 
231
334
  const handleInputContentSizeChange = useCallback(
232
335
  (event: { nativeEvent: { contentSize: { height: number } } }) => {
@@ -514,69 +617,88 @@ export const ChatComposer = memo(
514
617
  onRemove={onRemove}
515
618
  />
516
619
 
517
- <KContainer.View
518
- row
519
- alignItems
520
- paddingH="0.75rem"
521
- paddingT="0.75rem"
522
- paddingB="0.75rem"
523
- style={inputRowStyle}
524
- >
525
- <KContainer.Touchable
526
- onPress={handleToggleAttachment}
527
- center
528
- br="round"
620
+ <KContainer.View style={styles.inputRowWrapper}>
621
+ <KContainer.VisibleView visible={mentionComposer.isPanelOpen}>
622
+ <KContainer.View style={styles.mentionPanel}>
623
+ <MentionSuggestionList
624
+ suggestions={mentionComposer.suggestions}
625
+ isSearching={mentionComposer.isSearching}
626
+ isLoadingMore={mentionComposer.isLoadingMore}
627
+ hasMore={mentionComposer.hasMore}
628
+ onLoadMore={mentionComposer.loadMore}
629
+ onSelect={mentionComposer.selectSuggestion}
630
+ />
631
+ </KContainer.View>
632
+ </KContainer.VisibleView>
633
+
634
+ <KContainer.View
635
+ row
636
+ alignItems
637
+ paddingH="0.75rem"
638
+ paddingT="0.75rem"
639
+ paddingB="0.75rem"
640
+ style={inputRowStyle}
529
641
  >
530
- <KImage.VectorIcons
531
- name={
532
- activePanelType === 'attachment'
533
- ? 'close-circle-o'
534
- : 'plus-circle-o'
535
- }
536
- size={24}
537
- color={
538
- activePanelType === 'attachment'
539
- ? KColors.palette.primary.w400
540
- : KColors.palette.gray.w600
642
+ <KContainer.Touchable
643
+ onPress={handleToggleAttachment}
644
+ center
645
+ br="round"
646
+ >
647
+ <KImage.VectorIcons
648
+ name={
649
+ activePanelType === 'attachment'
650
+ ? 'close-circle-o'
651
+ : 'plus-circle-o'
652
+ }
653
+ size={24}
654
+ color={
655
+ activePanelType === 'attachment'
656
+ ? KColors.palette.primary.w400
657
+ : KColors.palette.gray.w600
658
+ }
659
+ />
660
+ </KContainer.Touchable>
661
+
662
+ <ChatMessageInput
663
+ value={compose.text}
664
+ placeholder="Nhập tin nhắn..."
665
+ onChangeText={handleChangeText}
666
+ onFocus={handleInputFocus}
667
+ onBlur={handleInputBlur}
668
+ onContentSizeChange={handleInputContentSizeChange}
669
+ onSelectionChange={mentionComposer.handleSelectionChange}
670
+ mentions={mentionComposer.mentions}
671
+ isInputFocused={isInputFocused}
672
+ isMultilineInput={isMultilineInput}
673
+ inputRef={inputRef}
674
+ onEmojiPress={handleToggleSticker}
675
+ onSuggestPress={
676
+ hasBotMenuOptions ? handleOpenBotMenu : undefined
541
677
  }
542
678
  />
543
- </KContainer.Touchable>
544
-
545
- <ChatMessageInput
546
- value={compose.text}
547
- placeholder="Nhập tin nhắn..."
548
- onChangeText={handleChangeText}
549
- onFocus={handleInputFocus}
550
- onBlur={handleInputBlur}
551
- onContentSizeChange={handleInputContentSizeChange}
552
- isInputFocused={isInputFocused}
553
- isMultilineInput={isMultilineInput}
554
- inputRef={inputRef}
555
- onEmojiPress={handleToggleSticker}
556
- onSuggestPress={hasBotMenuOptions ? handleOpenBotMenu : undefined}
557
- />
558
679
 
559
- <KContainer.Touchable
560
- onPress={compose.isUploading ? undefined : handleSend}
561
- pointerEvents={compose.isUploading ? 'none' : 'auto'}
562
- width={40}
563
- height={40}
564
- center
565
- br="round"
566
- background={
567
- compose.isUploading
568
- ? KColors.palette.gray.w400
569
- : KColors.palette.primary.w400
570
- }
571
- marginL="0.75rem"
572
- opacity={compose.isUploading ? 0.6 : 1}
573
- >
574
- <KImage.VectorIcons
575
- name="send-b"
576
- size={20}
577
- color={KColors.white}
578
- />
579
- </KContainer.Touchable>
680
+ <KContainer.Touchable
681
+ onPress={compose.isUploading ? undefined : handleSend}
682
+ pointerEvents={compose.isUploading ? 'none' : 'auto'}
683
+ width={40}
684
+ height={40}
685
+ center
686
+ br="round"
687
+ background={
688
+ compose.isUploading
689
+ ? KColors.palette.gray.w400
690
+ : KColors.palette.primary.w400
691
+ }
692
+ marginL="0.75rem"
693
+ opacity={compose.isUploading ? 0.6 : 1}
694
+ >
695
+ <KImage.VectorIcons
696
+ name="send-b"
697
+ size={20}
698
+ color={KColors.white}
699
+ />
700
+ </KContainer.Touchable>
701
+ </KContainer.View>
580
702
  </KContainer.View>
581
703
  </KContainer.View>
582
704
  );
@@ -618,4 +740,16 @@ const styles = StyleSheet.create({
618
740
  overflow: 'hidden',
619
741
  backgroundColor: KColors.palette.gray.w25,
620
742
  },
743
+ inputRowWrapper: {
744
+ position: 'relative',
745
+ },
746
+ // Floats above the input row without going through the accessorySlot
747
+ // (attachment/sticker) render path — that slot intentionally takes over
748
+ // the keyboard area, which the mention picker must not do (finding #5).
749
+ mentionPanel: {
750
+ position: 'absolute',
751
+ left: 0,
752
+ right: 0,
753
+ bottom: '100%',
754
+ },
621
755
  });
@@ -176,6 +176,7 @@ export const ChatListLegend = memo(
176
176
  const onResetFlow = useCallback(() => {
177
177
  resetFlow();
178
178
  }, [resetFlow]);
179
+ console.info('dwqdqwdqw');
179
180
 
180
181
  const renderItem = useCallback(
181
182
  ({ item, index }: any) => {