@droppii-org/chat-mobile 0.2.81 → 0.2.83

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 (175) 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 +49 -92
  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/quotedMessage/index.js +79 -55
  12. package/lib/module/components/messages/quotedMessage/index.js.map +1 -1
  13. package/lib/module/components/messages/stickerMessage/index.js +38 -13
  14. package/lib/module/components/messages/stickerMessage/index.js.map +1 -1
  15. package/lib/module/components/messages/textMessage/index.js +126 -13
  16. package/lib/module/components/messages/textMessage/index.js.map +1 -1
  17. package/lib/module/components/messages/types.js.map +1 -1
  18. package/lib/module/core/useChatListener.js +1 -1
  19. package/lib/module/core/useChatListener.js.map +1 -1
  20. package/lib/module/hooks/message/useSendMessage.js +64 -3
  21. package/lib/module/hooks/message/useSendMessage.js.map +1 -1
  22. package/lib/module/hooks/query-keys.js +4 -0
  23. package/lib/module/hooks/query-keys.js.map +1 -1
  24. package/lib/module/hooks/useChatCompose.js +3 -2
  25. package/lib/module/hooks/useChatCompose.js.map +1 -1
  26. package/lib/module/screens/chat-detail/ChatComposer.js +170 -48
  27. package/lib/module/screens/chat-detail/ChatComposer.js.map +1 -1
  28. package/lib/module/screens/chat-detail/ChatListLegend.js +16 -7
  29. package/lib/module/screens/chat-detail/ChatListLegend.js.map +1 -1
  30. package/lib/module/screens/chat-detail/components/ChatMessageInput.js +23 -2
  31. package/lib/module/screens/chat-detail/components/ChatMessageInput.js.map +1 -1
  32. package/lib/module/screens/chat-detail/components/MentionHighlightAdapter.js +51 -0
  33. package/lib/module/screens/chat-detail/components/MentionHighlightAdapter.js.map +1 -0
  34. package/lib/module/screens/chat-detail/components/MentionSuggestionItem.js +49 -0
  35. package/lib/module/screens/chat-detail/components/MentionSuggestionItem.js.map +1 -0
  36. package/lib/module/screens/chat-detail/components/MentionSuggestionList.js +114 -0
  37. package/lib/module/screens/chat-detail/components/MentionSuggestionList.js.map +1 -0
  38. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js +8 -0
  39. package/lib/module/screens/chat-detail/hooks/useChatComposerState.js.map +1 -1
  40. package/lib/module/screens/chat-detail/hooks/useMentionComposer.js +251 -0
  41. package/lib/module/screens/chat-detail/hooks/useMentionComposer.js.map +1 -0
  42. package/lib/module/screens/chat-detail/legend/LegendChatDay.js +4 -9
  43. package/lib/module/screens/chat-detail/legend/LegendChatDay.js.map +1 -1
  44. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js +14 -2
  45. package/lib/module/screens/chat-detail/legend/LegendChatMessage.js.map +1 -1
  46. package/lib/module/services/mentionSearch.js +49 -0
  47. package/lib/module/services/mentionSearch.js.map +1 -0
  48. package/lib/module/services/message.js +6 -1
  49. package/lib/module/services/message.js.map +1 -1
  50. package/lib/module/translation/resources/i18n.js +5 -0
  51. package/lib/module/translation/resources/i18n.js.map +1 -1
  52. package/lib/module/types/chat.js.map +1 -1
  53. package/lib/module/types/mention.js +2 -0
  54. package/lib/module/types/mention.js.map +1 -0
  55. package/lib/module/utils/legendListMessage.js +9 -1
  56. package/lib/module/utils/legendListMessage.js.map +1 -1
  57. package/lib/module/utils/mentionSerializer.js +113 -0
  58. package/lib/module/utils/mentionSerializer.js.map +1 -0
  59. package/lib/module/utils/mentions/index.js +7 -0
  60. package/lib/module/utils/mentions/index.js.map +1 -0
  61. package/lib/module/utils/mentions/mentionDetector.js +49 -0
  62. package/lib/module/utils/mentions/mentionDetector.js.map +1 -0
  63. package/lib/module/utils/mentions/mentionDiff.js +66 -0
  64. package/lib/module/utils/mentions/mentionDiff.js.map +1 -0
  65. package/lib/module/utils/mentions/mentionRange.js +188 -0
  66. package/lib/module/utils/mentions/mentionRange.js.map +1 -0
  67. package/lib/module/utils/mentions/mentionTokenize.js +50 -0
  68. package/lib/module/utils/mentions/mentionTokenize.js.map +1 -0
  69. package/lib/module/utils/messageUtils.js +6 -1
  70. package/lib/module/utils/messageUtils.js.map +1 -1
  71. package/lib/module/utils/url.js +33 -0
  72. package/lib/module/utils/url.js.map +1 -1
  73. package/lib/typescript/src/components/ThreadCard/MentionBadge.d.ts +7 -0
  74. package/lib/typescript/src/components/ThreadCard/MentionBadge.d.ts.map +1 -0
  75. package/lib/typescript/src/components/ThreadCard/ThreadCard.d.ts.map +1 -1
  76. package/lib/typescript/src/components/messages/linkMessage/LinkPreviewCard.d.ts +16 -0
  77. package/lib/typescript/src/components/messages/linkMessage/LinkPreviewCard.d.ts.map +1 -0
  78. package/lib/typescript/src/components/messages/linkMessage/index.d.ts +2 -2
  79. package/lib/typescript/src/components/messages/linkMessage/index.d.ts.map +1 -1
  80. package/lib/typescript/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.d.ts +13 -0
  81. package/lib/typescript/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.d.ts.map +1 -0
  82. package/lib/typescript/src/components/messages/quotedMessage/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/messages/stickerMessage/index.d.ts +2 -2
  84. package/lib/typescript/src/components/messages/stickerMessage/index.d.ts.map +1 -1
  85. package/lib/typescript/src/components/messages/textMessage/index.d.ts +4 -2
  86. package/lib/typescript/src/components/messages/textMessage/index.d.ts.map +1 -1
  87. package/lib/typescript/src/components/messages/types.d.ts +1 -0
  88. package/lib/typescript/src/components/messages/types.d.ts.map +1 -1
  89. package/lib/typescript/src/hooks/message/useSendMessage.d.ts +5 -2
  90. package/lib/typescript/src/hooks/message/useSendMessage.d.ts.map +1 -1
  91. package/lib/typescript/src/hooks/query-keys.d.ts +4 -0
  92. package/lib/typescript/src/hooks/query-keys.d.ts.map +1 -1
  93. package/lib/typescript/src/hooks/useChatCompose.d.ts +2 -1
  94. package/lib/typescript/src/hooks/useChatCompose.d.ts.map +1 -1
  95. package/lib/typescript/src/screens/chat-detail/ChatComposer.d.ts.map +1 -1
  96. package/lib/typescript/src/screens/chat-detail/ChatListLegend.d.ts.map +1 -1
  97. package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts +10 -2
  98. package/lib/typescript/src/screens/chat-detail/components/ChatMessageInput.d.ts.map +1 -1
  99. package/lib/typescript/src/screens/chat-detail/components/MentionHighlightAdapter.d.ts +29 -0
  100. package/lib/typescript/src/screens/chat-detail/components/MentionHighlightAdapter.d.ts.map +1 -0
  101. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionItem.d.ts +8 -0
  102. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionItem.d.ts.map +1 -0
  103. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionList.d.ts +12 -0
  104. package/lib/typescript/src/screens/chat-detail/components/MentionSuggestionList.d.ts.map +1 -0
  105. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts +1 -1
  106. package/lib/typescript/src/screens/chat-detail/hooks/useChatComposerState.d.ts.map +1 -1
  107. package/lib/typescript/src/screens/chat-detail/hooks/useMentionComposer.d.ts +48 -0
  108. package/lib/typescript/src/screens/chat-detail/hooks/useMentionComposer.d.ts.map +1 -0
  109. package/lib/typescript/src/screens/chat-detail/legend/LegendChatDay.d.ts.map +1 -1
  110. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts +2 -1
  111. package/lib/typescript/src/screens/chat-detail/legend/LegendChatMessage.d.ts.map +1 -1
  112. package/lib/typescript/src/services/mentionSearch.d.ts +15 -0
  113. package/lib/typescript/src/services/mentionSearch.d.ts.map +1 -0
  114. package/lib/typescript/src/services/message.d.ts +1 -1
  115. package/lib/typescript/src/services/message.d.ts.map +1 -1
  116. package/lib/typescript/src/translation/resources/i18n.d.ts.map +1 -1
  117. package/lib/typescript/src/types/chat.d.ts +23 -0
  118. package/lib/typescript/src/types/chat.d.ts.map +1 -1
  119. package/lib/typescript/src/types/mention.d.ts +65 -0
  120. package/lib/typescript/src/types/mention.d.ts.map +1 -0
  121. package/lib/typescript/src/utils/legendListMessage.d.ts +4 -3
  122. package/lib/typescript/src/utils/legendListMessage.d.ts.map +1 -1
  123. package/lib/typescript/src/utils/mentionSerializer.d.ts +78 -0
  124. package/lib/typescript/src/utils/mentionSerializer.d.ts.map +1 -0
  125. package/lib/typescript/src/utils/mentions/index.d.ts +5 -0
  126. package/lib/typescript/src/utils/mentions/index.d.ts.map +1 -0
  127. package/lib/typescript/src/utils/mentions/mentionDetector.d.ts +18 -0
  128. package/lib/typescript/src/utils/mentions/mentionDetector.d.ts.map +1 -0
  129. package/lib/typescript/src/utils/mentions/mentionDiff.d.ts +20 -0
  130. package/lib/typescript/src/utils/mentions/mentionDiff.d.ts.map +1 -0
  131. package/lib/typescript/src/utils/mentions/mentionRange.d.ts +86 -0
  132. package/lib/typescript/src/utils/mentions/mentionRange.d.ts.map +1 -0
  133. package/lib/typescript/src/utils/mentions/mentionTokenize.d.ts +29 -0
  134. package/lib/typescript/src/utils/mentions/mentionTokenize.d.ts.map +1 -0
  135. package/lib/typescript/src/utils/messageUtils.d.ts.map +1 -1
  136. package/lib/typescript/src/utils/url.d.ts +12 -0
  137. package/lib/typescript/src/utils/url.d.ts.map +1 -1
  138. package/package.json +1 -1
  139. package/src/components/ThreadCard/MentionBadge.tsx +28 -0
  140. package/src/components/ThreadCard/ThreadCard.tsx +3 -0
  141. package/src/components/messages/linkMessage/LinkPreviewCard.tsx +92 -0
  142. package/src/components/messages/linkMessage/index.tsx +64 -121
  143. package/src/components/messages/quotedMessage/QuotedMessagePreviewStrip.tsx +168 -0
  144. package/src/components/messages/quotedMessage/index.tsx +95 -64
  145. package/src/components/messages/stickerMessage/index.tsx +40 -11
  146. package/src/components/messages/textMessage/index.tsx +159 -17
  147. package/src/components/messages/types.ts +1 -0
  148. package/src/core/useChatListener.ts +1 -1
  149. package/src/hooks/message/useSendMessage.ts +60 -1
  150. package/src/hooks/query-keys.ts +6 -0
  151. package/src/hooks/useChatCompose.ts +115 -107
  152. package/src/screens/chat-detail/ChatComposer.tsx +201 -67
  153. package/src/screens/chat-detail/ChatListLegend.tsx +19 -4
  154. package/src/screens/chat-detail/components/ChatMessageInput.tsx +35 -2
  155. package/src/screens/chat-detail/components/MentionHighlightAdapter.tsx +57 -0
  156. package/src/screens/chat-detail/components/MentionSuggestionItem.tsx +53 -0
  157. package/src/screens/chat-detail/components/MentionSuggestionList.tsx +133 -0
  158. package/src/screens/chat-detail/hooks/useChatComposerState.ts +7 -1
  159. package/src/screens/chat-detail/hooks/useMentionComposer.ts +344 -0
  160. package/src/screens/chat-detail/legend/LegendChatDay.tsx +6 -9
  161. package/src/screens/chat-detail/legend/LegendChatMessage.tsx +25 -2
  162. package/src/services/mentionSearch.ts +57 -0
  163. package/src/services/message.ts +9 -1
  164. package/src/translation/resources/i18n.ts +6 -0
  165. package/src/types/chat.ts +22 -0
  166. package/src/types/mention.ts +70 -0
  167. package/src/utils/legendListMessage.ts +13 -4
  168. package/src/utils/mentionSerializer.ts +155 -0
  169. package/src/utils/mentions/index.ts +16 -0
  170. package/src/utils/mentions/mentionDetector.ts +58 -0
  171. package/src/utils/mentions/mentionDiff.ts +95 -0
  172. package/src/utils/mentions/mentionRange.ts +207 -0
  173. package/src/utils/mentions/mentionTokenize.ts +53 -0
  174. package/src/utils/messageUtils.ts +6 -1
  175. package/src/utils/url.ts +30 -0
package/src/types/chat.ts CHANGED
@@ -160,6 +160,12 @@ export interface DConversationQueryParams {
160
160
  pageSize: number;
161
161
  }
162
162
 
163
+ export interface ISerializedMentionEx {
164
+ userID: string;
165
+ offset: number;
166
+ length: number;
167
+ }
168
+
163
169
  export interface IMessageItemEx {
164
170
  applicationType: DChatApplicationType;
165
171
  sessionId?: string;
@@ -171,6 +177,22 @@ export interface IMessageItemEx {
171
177
  urlMetadata?: IUrlMetadata;
172
178
  flow?: IRunConversationFlowPayload;
173
179
  stickerId?: string;
180
+ /**
181
+ * @mention ranges, riding in this app-defined `ex` channel because
182
+ * OpenIM's own AtTextElem/AtMsgParams carry no per-mention offset/length
183
+ * — just a flat atUserIDList/atUsersInfo. Render logic prefers this
184
+ * (exact) and falls back to searching AtTextElem.text for
185
+ * "@{groupNickname}" per atUsersInfo entry when absent (interop safety
186
+ * net — see mentionSerializer.ts deserializeMentionRanges).
187
+ *
188
+ * Wrapped under `offsets` (not a bare array) to match the wire shape
189
+ * already sent by other Droppii clients (confirmed against a real
190
+ * message from the web/CRM client, `applicationType: "OBEFE"`:
191
+ * `ex.mentions = { offsets: [{userID, offset, length}, ...] }`). Offset
192
+ * units matched too (UTF-16 code units) — verified against a real
193
+ * Vietnamese-diacritic payload, no cross-client encoding mismatch.
194
+ */
195
+ mentions?: { offsets: ISerializedMentionEx[] };
174
196
  }
175
197
 
176
198
  export interface IGetMessagesParams {
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @mention composer types.
3
+ *
4
+ * All offsets throughout this module are raw JS string indices (UTF-16 code
5
+ * units) — the same unit RN's TextInput selection/cursor API uses on both
6
+ * platforms. This is a deliberate choice, not a default: it means offsets
7
+ * never need conversion at the RN boundary, at the cost of requiring
8
+ * surrogate-pair-aware handling wherever text is diffed (see mentionDiff.ts).
9
+ * AT Protocol-style byte offsets are unnecessary here because this data
10
+ * never crosses a non-JS boundary — see mentionSerializer.ts.
11
+ */
12
+
13
+ export interface Selection {
14
+ start: number;
15
+ end: number;
16
+ }
17
+
18
+ /**
19
+ * A committed mention: created only by explicit picker selection
20
+ * (insertMention), never inferred from text content. [start, end) excludes
21
+ * the trailing separator space inserted after the display name — see
22
+ * insertMention's doc comment for why.
23
+ */
24
+ export interface Mention {
25
+ id: string;
26
+ userId: string;
27
+ displayName: string;
28
+ start: number;
29
+ end: number;
30
+ }
31
+
32
+ /**
33
+ * The in-progress, uncommitted "@query" at the cursor. Freely editable
34
+ * character-by-character — never atomic, never promoted to a Mention except
35
+ * through explicit selection from the suggestion list.
36
+ */
37
+ export interface ActiveMention {
38
+ trigger: '@';
39
+ start: number;
40
+ end: number;
41
+ keyword: string;
42
+ }
43
+
44
+ export interface MentionSuggestion {
45
+ userId: string;
46
+ displayName: string;
47
+ avatar?: string;
48
+ }
49
+
50
+ /**
51
+ * The boundary between Part A (composer, no concrete OpenIM dependency) and
52
+ * Part B (OpenIM SDK integration). Part A depends only on this type,
53
+ * injected; Part B's `openIMMentionSearchSource` (src/services/mentionSearch.ts)
54
+ * implements it against the real SDK. Defined here, in Part A's own types
55
+ * file, rather than in the Part B service file, so the dependency direction
56
+ * stays correct: Part A defines the contract, Part B implements it.
57
+ */
58
+ export interface MentionSearchResult {
59
+ items: MentionSuggestion[];
60
+ /** True when `items.length === limit` — a same-size next page may exist. */
61
+ hasMore: boolean;
62
+ }
63
+
64
+ export interface MentionSearchSource {
65
+ search(params: {
66
+ keyword: string;
67
+ limit: number;
68
+ offset: number;
69
+ }): Promise<MentionSearchResult>;
70
+ }
@@ -48,9 +48,10 @@ export interface PrecomputedMessageData {
48
48
  messageId: string;
49
49
  dayStart: boolean;
50
50
  /**
51
- * More than GROUP_TIME_GAP has passed since the previous message. The list
52
- * marks these with a time label: the cluster break is real but, unlike a day
53
- * change, nothing else on screen would show why the run stopped.
51
+ * More than DIVIDER_TIME_GAP has passed since the previous message on the
52
+ * same day. The list marks these with a time-only divider so a long pause
53
+ * is visible even when nothing else on screen (day change, sender change)
54
+ * would show it.
54
55
  */
55
56
  timeBreak: boolean;
56
57
  createdAt: number;
@@ -60,6 +61,14 @@ export interface PrecomputedMessageData {
60
61
  /** Two messages further apart than this never share a cluster. */
61
62
  const GROUP_TIME_GAP = 300_000; // 5 minutes
62
63
 
64
+ /**
65
+ * Same-day gap that earns a standalone time divider (BR: 30 minutes) —
66
+ * intentionally separate from GROUP_TIME_GAP: a cluster can visually break on
67
+ * the shorter gap without a divider explaining it, since the spec's divider
68
+ * threshold and the grouping threshold are independent rules.
69
+ */
70
+ const DIVIDER_TIME_GAP = 1_800_000; // 30 minutes
71
+
63
72
  /** Virtual sender for bot conversations — see getGroupKey. */
64
73
  const BOT_GROUP_KEY = '__bot__';
65
74
 
@@ -145,7 +154,7 @@ export const precomputeLegendListData = (
145
154
  dayStart: dayStarts[index]!,
146
155
  timeBreak:
147
156
  prevTimestamp !== undefined &&
148
- createdAt - prevTimestamp > GROUP_TIME_GAP,
157
+ createdAt - prevTimestamp > DIVIDER_TIME_GAP,
149
158
  createdAt,
150
159
  groupPosition: 'solo',
151
160
  });
@@ -0,0 +1,155 @@
1
+ import type { AtUsersInfoItem } from '@droppii/openim-rn-client-sdk';
2
+ import type { ISerializedMentionEx } from '../types/chat';
3
+ import type { Mention } from '../types/mention';
4
+
5
+ /** Composer-internal marker for the "Tất cả" suggestion row / a committed
6
+ * "tag everyone" Mention — never sent on the wire, see AT_ALL_TAG below. */
7
+ export const ALL_MEMBERS_USER_ID = 'ALL';
8
+
9
+ /**
10
+ * OpenIM's own protocol sentinel for "everyone in the group", used inside
11
+ * atUserIDList in place of enumerating every member. Confirmed against
12
+ * openimsdk-core (pkg/constant/constant.go:242 — `AtAllString = "AtAllTag"`,
13
+ * exposed client-side as `GetAtAllTag()`) and openim-server's group-message
14
+ * send path (internal/rpc/msg/send.go's `setConversationAtInfo`), which
15
+ * special-cases exactly this string and does the "who is everyone right
16
+ * now" resolution itself, server-side.
17
+ *
18
+ * This is why "Tất cả" must serialize to this one token instead of
19
+ * expanding to every resolved member's userID client-side (the previous
20
+ * approach): the Go core's CreateTextAtMessage hard-rejects any
21
+ * atUserIDList longer than 10 entries regardless of group size
22
+ * (openimsdk-core/internal/conversation_msg/create_message.go:80-82) — a
23
+ * >10-member group's "Tất cả" silently failed to send at all. Sending just
24
+ * `[AT_ALL_TAG]` keeps atUserIDList small no matter the group size, and is
25
+ * also the behavior the protocol actually expects.
26
+ */
27
+ export const AT_ALL_TAG = 'AtAllTag';
28
+
29
+ /**
30
+ * OpenIM's Go core hard-rejects any AtMsgParams.atUserIDList longer than
31
+ * this (openimsdk-core/internal/conversation_msg/create_message.go:80-82,
32
+ * `if len(userIDList) > 10 { return nil, sdkerrs.ErrArgs }`) — not
33
+ * adjustable client-side. "Tất cả" no longer risks this (see AT_ALL_TAG),
34
+ * but tagging more than 10 *individual* members in one message still
35
+ * would, so serializeMentionsForSend caps atUserIDList/atUsersInfo at this
36
+ * count (keeping the first MAX_AT_USER_COUNT, in mention order) rather
37
+ * than letting the send fail outright the way "Tất cả" used to.
38
+ */
39
+ export const MAX_AT_USER_COUNT = 10;
40
+
41
+ export interface SerializedMentions {
42
+ atUserIDList: string[];
43
+ atUsersInfo: AtUsersInfoItem[];
44
+ exMentions: ISerializedMentionEx[];
45
+ /**
46
+ * True when more than MAX_AT_USER_COUNT distinct users were mentioned —
47
+ * atUserIDList/atUsersInfo were capped to the first MAX_AT_USER_COUNT (in
48
+ * mention order) so the send doesn't hit the Go core's hard rejection.
49
+ * `exMentions` is deliberately NOT capped: every mention still renders
50
+ * highlighted in the sent text (the sender sees what they typed), only
51
+ * native tag/notify is limited to the first MAX_AT_USER_COUNT. Callers
52
+ * use this flag to warn the user, not to block the send.
53
+ */
54
+ truncated: boolean;
55
+ }
56
+
57
+ /**
58
+ * Translates the composer's Mention[] (client-side ranges) into OpenIM's
59
+ * wire shapes for createTextAtMessage, plus our own ex.mentions channel. No
60
+ * group-member resolution needed here (or by the caller) any more — see
61
+ * AT_ALL_TAG's doc comment for why a "Tất cả" mention maps to that single
62
+ * sentinel rather than a per-member list.
63
+ */
64
+ export function serializeMentionsForSend(
65
+ mentions: Mention[]
66
+ ): SerializedMentions {
67
+ const wireUserId = (m: Mention) =>
68
+ m.userId === ALL_MEMBERS_USER_ID ? AT_ALL_TAG : m.userId;
69
+
70
+ const exMentions: ISerializedMentionEx[] = mentions.map((m) => ({
71
+ userID: wireUserId(m),
72
+ offset: m.start,
73
+ length: m.end - m.start,
74
+ }));
75
+
76
+ // Map dedupes by userID — e.g. "Tất cả" and an individually-tagged member
77
+ // both present in the same message only produce one atUserIDList entry
78
+ // each, not duplicates. Insertion order == mention order, which is what
79
+ // makes "keep the first MAX_AT_USER_COUNT" below well-defined.
80
+ const nicknameByUserId = new Map<string, string>();
81
+ for (const m of mentions) {
82
+ nicknameByUserId.set(wireUserId(m), m.displayName);
83
+ }
84
+
85
+ const allEntries = [...nicknameByUserId.entries()];
86
+ const truncated = allEntries.length > MAX_AT_USER_COUNT;
87
+ const cappedEntries = truncated
88
+ ? allEntries.slice(0, MAX_AT_USER_COUNT)
89
+ : allEntries;
90
+
91
+ return {
92
+ atUserIDList: cappedEntries.map(([atUserID]) => atUserID),
93
+ atUsersInfo: cappedEntries.map(([atUserID, groupNickname]) => ({
94
+ atUserID,
95
+ groupNickname,
96
+ })),
97
+ exMentions,
98
+ truncated,
99
+ };
100
+ }
101
+
102
+ interface ReceivedAtTextElem {
103
+ text: string;
104
+ atUserList: string[];
105
+ atUsersInfo?: AtUsersInfoItem[];
106
+ }
107
+
108
+ /**
109
+ * Reverse of serializeMentionsForSend, for rendering a received/sent
110
+ * AtTextMessage (Phase B3). Prefers `exMentions` (exact, our own channel);
111
+ * falls back to searching `atTextElem.text` for "@{groupNickname}" per
112
+ * atUsersInfo entry when ex is absent — an interop safety net, not the
113
+ * primary path. The fallback is inherently ambiguous if a member's
114
+ * nickname coincidentally appears elsewhere in the message as plain text;
115
+ * accepted as a known, documented limitation (see the mention plan's
116
+ * finding #3) rather than something client code can fully resolve without
117
+ * position data from the protocol.
118
+ */
119
+ export function deserializeMentionRanges(
120
+ atTextElem: ReceivedAtTextElem,
121
+ exMentions?: ISerializedMentionEx[]
122
+ ): Mention[] {
123
+ if (exMentions && exMentions.length > 0) {
124
+ return exMentions.map((e, index) => ({
125
+ id: `${e.userID}-${e.offset}-${index}`,
126
+ userId: e.userID,
127
+ displayName: atTextElem.text
128
+ .slice(e.offset, e.offset + e.length)
129
+ .replace(/^@/, ''),
130
+ start: e.offset,
131
+ end: e.offset + e.length,
132
+ }));
133
+ }
134
+
135
+ const nicknameByUserId = new Map(
136
+ (atTextElem.atUsersInfo ?? []).map((i) => [i.atUserID, i.groupNickname])
137
+ );
138
+
139
+ const mentions: Mention[] = [];
140
+ for (const userId of atTextElem.atUserList) {
141
+ const nickname = nicknameByUserId.get(userId);
142
+ if (!nickname) continue;
143
+ const needle = `@${nickname}`;
144
+ const start = atTextElem.text.indexOf(needle);
145
+ if (start === -1) continue;
146
+ mentions.push({
147
+ id: `${userId}-${start}`,
148
+ userId,
149
+ displayName: nickname,
150
+ start,
151
+ end: start + needle.length,
152
+ });
153
+ }
154
+ return mentions;
155
+ }
@@ -0,0 +1,16 @@
1
+ export { findActiveMention } from './mentionDetector';
2
+ export {
3
+ diffTextChange,
4
+ snapBoundary,
5
+ type TextChangeDiff,
6
+ } from './mentionDiff';
7
+ export {
8
+ insertMention,
9
+ reconcileMentions,
10
+ normalizeDeleteRange,
11
+ applyTextChange,
12
+ type InsertMentionResult,
13
+ type ReconcileMentionsResult,
14
+ type ApplyTextChangeResult,
15
+ } from './mentionRange';
16
+ export { tokenize, type Segment } from './mentionTokenize';
@@ -0,0 +1,58 @@
1
+ import type { ActiveMention } from '../../types/mention';
2
+
3
+ // No realistic @query is longer than this — caps the backward scan so the
4
+ // common case (cursor nowhere near an '@') bails in O(word length), not
5
+ // O(text length).
6
+ const MAX_ACTIVE_MENTION_SCAN = 64;
7
+
8
+ const isBoundaryChar = (ch: string) => /\s/.test(ch);
9
+
10
+ /**
11
+ * Scans backward from `cursor` for an unclosed "@query" — the in-progress
12
+ * trigger that drives the suggestion list, not a committed mention.
13
+ *
14
+ * Only called for a collapsed cursor (selection.start === selection.end);
15
+ * the caller must resolve a range selection to `null` before calling this —
16
+ * a live "@query" only makes sense at a single insertion point.
17
+ *
18
+ * "@" itself counts as a boundary: the scan stops there whether or not it's
19
+ * a valid trigger, which is what prevents re-scanning back through an
20
+ * already-committed mention's "@Name " when the user starts typing a second
21
+ * "@" right after it (insertMention always appends a trailing space, a
22
+ * boundary char, so the previous mention's "@" is never reachable from the
23
+ * new query).
24
+ */
25
+ export function findActiveMention(
26
+ text: string,
27
+ cursor: number
28
+ ): ActiveMention | null {
29
+ if (cursor <= 0 || cursor > text.length) return null;
30
+
31
+ let i = cursor;
32
+ let scanned = 0;
33
+
34
+ while (i > 0 && scanned < MAX_ACTIVE_MENTION_SCAN) {
35
+ const ch = text[i - 1] as string;
36
+
37
+ if (ch === '@') {
38
+ const before = i - 2 >= 0 ? text[i - 2] : undefined;
39
+ if (before === undefined || isBoundaryChar(before)) {
40
+ return {
41
+ trigger: '@',
42
+ start: i - 1,
43
+ end: cursor,
44
+ keyword: text.slice(i, cursor),
45
+ };
46
+ }
47
+ // '@' present but not at a valid boundary (e.g. "user@domain") — not a trigger.
48
+ return null;
49
+ }
50
+
51
+ if (isBoundaryChar(ch)) return null;
52
+
53
+ i--;
54
+ scanned++;
55
+ }
56
+
57
+ return null;
58
+ }
@@ -0,0 +1,95 @@
1
+ // RN's onChangeText hands over only the new full string — no delta, no
2
+ // info about what was inserted/deleted/replaced. A common-prefix +
3
+ // common-suffix scan is the only viable way to localize the changed region,
4
+ // and it's IME-safe for free: a Vietnamese diacritic recomposition (e.g.
5
+ // "tie" + accent key -> "tiế") is just "two different strings" to this
6
+ // algorithm, not a keystroke sequence it has to track.
7
+ //
8
+ // Assumes exactly one contiguous edit happened between the two strings —
9
+ // true for every realistic single-field mutation (typing, backspace,
10
+ // paste, autocorrect, IME composition). If that assumption is ever wrong
11
+ // (some exotic keyboard rewriting two disjoint spans in one event), the
12
+ // failure mode is safe-by-default: the computed region is a superset of
13
+ // the true edits, so callers may over-invalidate mention ranges but never
14
+ // corrupt them.
15
+
16
+ const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff;
17
+ const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
18
+
19
+ /**
20
+ * Snaps `index` so it never sits between a surrogate pair in `str` —
21
+ * without this, diffing e.g. "😀" vs "😁" (which differ only in their low
22
+ * surrogate) would compute a boundary that bisects the emoji. Snapping
23
+ * always shrinks the "common" region (treats one more code point as
24
+ * "changed"), which is always safe: being more conservative about what
25
+ * counts as touched only ever produces more mention-invalidation upstream,
26
+ * never corruption.
27
+ */
28
+ export function snapBoundary(
29
+ str: string,
30
+ index: number,
31
+ direction: 1 | -1
32
+ ): number {
33
+ if (
34
+ index > 0 &&
35
+ index < str.length &&
36
+ isHighSurrogate(str.charCodeAt(index - 1)) &&
37
+ isLowSurrogate(str.charCodeAt(index))
38
+ ) {
39
+ return index + direction;
40
+ }
41
+ return index;
42
+ }
43
+
44
+ export interface TextChangeDiff {
45
+ /** [removedStart, removedEnd) in previousText coords — what was deleted. */
46
+ removedStart: number;
47
+ removedEnd: number;
48
+ /** [insertedStart, insertedEnd) in nextText coords — what was inserted. insertedStart === removedStart. */
49
+ insertedStart: number;
50
+ insertedEnd: number;
51
+ }
52
+
53
+ export function diffTextChange(
54
+ previousText: string,
55
+ nextText: string
56
+ ): TextChangeDiff {
57
+ const prevLen = previousText.length;
58
+ const nextLen = nextText.length;
59
+ const maxCommon = Math.min(prevLen, nextLen);
60
+
61
+ let prefixLen = 0;
62
+ while (
63
+ prefixLen < maxCommon &&
64
+ previousText.charCodeAt(prefixLen) === nextText.charCodeAt(prefixLen)
65
+ ) {
66
+ prefixLen++;
67
+ }
68
+ prefixLen = snapBoundary(previousText, prefixLen, -1);
69
+
70
+ // Clamp the suffix scan budget against what's left after the prefix —
71
+ // without this, a naive independent prefix+suffix scan can overlap on
72
+ // repeated-character edits (e.g. "aaa" -> "aaaa") and produce a
73
+ // negative-length "changed" region.
74
+ const maxSuffix = maxCommon - prefixLen;
75
+ let suffixLen = 0;
76
+ while (
77
+ suffixLen < maxSuffix &&
78
+ previousText.charCodeAt(prevLen - 1 - suffixLen) ===
79
+ nextText.charCodeAt(nextLen - 1 - suffixLen)
80
+ ) {
81
+ suffixLen++;
82
+ }
83
+
84
+ let removedEnd = prevLen - suffixLen;
85
+ let insertedEnd = nextLen - suffixLen;
86
+ removedEnd = snapBoundary(previousText, removedEnd, 1);
87
+ insertedEnd = snapBoundary(nextText, insertedEnd, 1);
88
+
89
+ return {
90
+ removedStart: prefixLen,
91
+ removedEnd,
92
+ insertedStart: prefixLen,
93
+ insertedEnd,
94
+ };
95
+ }
@@ -0,0 +1,207 @@
1
+ import type { ActiveMention, Mention } from '../../types/mention';
2
+ import { diffTextChange } from './mentionDiff';
3
+
4
+ export interface InsertMentionResult {
5
+ text: string;
6
+ mentions: Mention[];
7
+ cursor: number;
8
+ }
9
+
10
+ let mentionIdCounter = 0;
11
+ const nextMentionId = () => `mention-${Date.now()}-${mentionIdCounter++}`;
12
+
13
+ /**
14
+ * Replaces the active "@query" range with "@DisplayName " and commits a new
15
+ * Mention. The trailing space is deliberately excluded from the mention's
16
+ * [start,end) range — it's a plain separator, not part of the token. This
17
+ * keeps the data model simple (no "our space" vs "user-typed space"
18
+ * special-casing) at the cost of removing a mention immediately after
19
+ * insertion taking two backspaces (first eats the space, second — now
20
+ * touching the mention's actual range — triggers atomic removal). Matches
21
+ * the spec's own fixture: "Hello @Thinh" with NO trailing space, cursor at
22
+ * the end, backspace removes the whole mention in one step, since there's
23
+ * no space to consume first.
24
+ */
25
+ export function insertMention(
26
+ text: string,
27
+ mentions: Mention[],
28
+ activeMention: ActiveMention,
29
+ selected: { userId: string; displayName: string }
30
+ ): InsertMentionResult {
31
+ const { start, end } = activeMention;
32
+ const inserted = `@${selected.displayName} `;
33
+ const newText = text.slice(0, start) + inserted + text.slice(end);
34
+ const delta = inserted.length - (end - start);
35
+
36
+ const newMention: Mention = {
37
+ id: nextMentionId(),
38
+ userId: selected.userId,
39
+ displayName: selected.displayName,
40
+ start,
41
+ end: start + 1 + selected.displayName.length,
42
+ };
43
+
44
+ // m.start >= end is safe: an existing committed mention can never overlap
45
+ // an activeMention's range — findActiveMention's '@'/boundary bail-out
46
+ // guarantees the active query never reaches back into a prior mention.
47
+ const shifted = mentions.map((m) =>
48
+ m.start >= end ? { ...m, start: m.start + delta, end: m.end + delta } : m
49
+ );
50
+
51
+ const nextMentions = [...shifted, newMention].sort(
52
+ (a, b) => a.start - b.start
53
+ );
54
+ const cursor = newMention.end + 1; // after the trailing space
55
+
56
+ return { text: newText, mentions: nextMentions, cursor };
57
+ }
58
+
59
+ export interface ReconcileMentionsResult {
60
+ mentions: Mention[];
61
+ invalidated: Mention[];
62
+ }
63
+
64
+ /**
65
+ * Re-derives mention ranges after an edit, given only the before/after full
66
+ * strings (RN's onChangeText carries no delta). Diffs the two strings to
67
+ * localize the changed region, then per mention: fully before the change ->
68
+ * kept as-is; fully after -> shifted by the length delta; any overlap at
69
+ * all -> invalidated (dropped, never left partially stale).
70
+ *
71
+ * Boundary semantics matter here: `m.end <= removedStart` (touching-but-
72
+ * before) is "kept", and `m.start >= removedEnd` (touching-but-after) is
73
+ * "shift" — so typing immediately after a mention, or deleting text
74
+ * strictly before one, never invalidates it. Anything else, even a
75
+ * 1-character overlap, does.
76
+ */
77
+ export function reconcileMentions({
78
+ previousText,
79
+ nextText,
80
+ mentions,
81
+ }: {
82
+ previousText: string;
83
+ nextText: string;
84
+ mentions: Mention[];
85
+ }): ReconcileMentionsResult {
86
+ if (nextText === previousText || mentions.length === 0) {
87
+ return { mentions, invalidated: [] };
88
+ }
89
+
90
+ const { removedStart, removedEnd } = diffTextChange(previousText, nextText);
91
+ const delta = nextText.length - previousText.length;
92
+
93
+ const kept: Mention[] = [];
94
+ const invalidated: Mention[] = [];
95
+
96
+ for (const m of mentions) {
97
+ if (m.end <= removedStart) {
98
+ kept.push(m);
99
+ } else if (m.start >= removedEnd) {
100
+ kept.push({ ...m, start: m.start + delta, end: m.end + delta });
101
+ } else {
102
+ invalidated.push(m);
103
+ }
104
+ }
105
+
106
+ return { mentions: kept, invalidated };
107
+ }
108
+
109
+ /**
110
+ * Expands a delete/replace range so it fully swallows any mention it
111
+ * merely intersects — this is what makes "atomic delete" actually happen.
112
+ * RN's Old-Architecture TextInput can't intercept a keypress before the
113
+ * native buffer mutates, so this doesn't run as a true pre-mutation guard;
114
+ * it runs on the range diffTextChange computed from an already-applied
115
+ * edit, and the caller (useMentionComposer) uses the expanded range to
116
+ * rebuild the text from previousText before React re-renders — see that
117
+ * hook's doc comment for the full repair sequence.
118
+ *
119
+ * The fixed-point loop is needed for the adjacent-mentions case: expanding
120
+ * to cover one mention can newly overlap a neighbor with zero gap between
121
+ * them (e.g. loaded from server data with no separator).
122
+ */
123
+ export function normalizeDeleteRange(
124
+ range: { start: number; end: number },
125
+ mentions: Mention[]
126
+ ): { start: number; end: number } {
127
+ let start = Math.min(range.start, range.end);
128
+ let end = Math.max(range.start, range.end);
129
+
130
+ let changed = true;
131
+ while (changed) {
132
+ changed = false;
133
+ for (const m of mentions) {
134
+ const overlaps = m.start < end && m.end > start;
135
+ if (overlaps && (m.start < start || m.end > end)) {
136
+ start = Math.min(start, m.start);
137
+ end = Math.max(end, m.end);
138
+ changed = true;
139
+ }
140
+ }
141
+ }
142
+
143
+ return { start, end };
144
+ }
145
+
146
+ export interface ApplyTextChangeResult {
147
+ text: string;
148
+ mentions: Mention[];
149
+ cursor: number;
150
+ /** True when this change touched a mention and it was atomically removed. */
151
+ mentionRemoved: boolean;
152
+ }
153
+
154
+ /**
155
+ * The full "atomic delete" pipeline, extracted as a pure function so it's
156
+ * unit-testable without a live TextInput: diff previousText -> nextTextRaw,
157
+ * expand the removed range to cover any touched mention, and if it
158
+ * expanded, discard nextTextRaw for that span and rebuild atomically from
159
+ * previousText — otherwise just diff-and-shift normally.
160
+ *
161
+ * This is what `useMentionComposer` (Phase A2) calls from its
162
+ * ref-buffered onChangeText reconciliation step; it also imperatively
163
+ * calls the native `setSelection(cursor, cursor)` after, which is RN-only
164
+ * and stays out of this pure function.
165
+ */
166
+ export function applyTextChange(
167
+ previousText: string,
168
+ mentions: Mention[],
169
+ nextTextRaw: string
170
+ ): ApplyTextChangeResult {
171
+ const { removedStart, removedEnd, insertedEnd } = diffTextChange(
172
+ previousText,
173
+ nextTextRaw
174
+ );
175
+ const expanded = normalizeDeleteRange(
176
+ { start: removedStart, end: removedEnd },
177
+ mentions
178
+ );
179
+
180
+ if (expanded.start !== removedStart || expanded.end !== removedEnd) {
181
+ const finalText =
182
+ previousText.slice(0, expanded.start) + previousText.slice(expanded.end);
183
+ const { mentions: keptMentions } = reconcileMentions({
184
+ previousText,
185
+ nextText: finalText,
186
+ mentions,
187
+ });
188
+ return {
189
+ text: finalText,
190
+ mentions: keptMentions,
191
+ cursor: expanded.start,
192
+ mentionRemoved: true,
193
+ };
194
+ }
195
+
196
+ const { mentions: nextMentions } = reconcileMentions({
197
+ previousText,
198
+ nextText: nextTextRaw,
199
+ mentions,
200
+ });
201
+ return {
202
+ text: nextTextRaw,
203
+ mentions: nextMentions,
204
+ cursor: insertedEnd,
205
+ mentionRemoved: false,
206
+ };
207
+ }