聊天输入框的 @提及实现

Sep 9·5 min
AI 生成的摘要
商业 IM 软件的 mention 处理记录:用户 ID 和显示名分开保存,多次替换时计算位置,以及 emoji 对 Dart 字符串索引的影响。

商业 IM 软件里,输入 @ 选中成员以后,会在输入框里插入名字并高亮。显示这几个字并不难,麻烦的是保存以后还得知道提及了谁。

群里可能有人重名,昵称也可能修改,所以不能只传一段 @张三。这里记一下 mention 的数据结构和文本位置计算。

mention 的数据结构

当前协议模型为每次提及保留四个主要字段:

字段用途
jid被提及的用户身份
displayName这次提及使用的显示名称
startIndex展示文本中的开始位置
endIndex展示文本中的结束边界

其中身份和名字需要分开。两位成员都叫张三,显示相同不代表指向同一个人。

至于旧消息中的名字应该保留发送时的文字,还是跟随用户资料更新,是产品选择。当前结构允许保存消息携带的显示名称,但不能据此推断所有 UI 都会采取相同更新策略。

原始文本和展示文本各有用途

项目有一种文本表示形式:

snippet
text
请 @[张三](user-a@example.com) 看一下

这是匿名化示例。解析后用于展示的内容是:

snippet
text
请 @张三 看一下

原始表示里有身份信息,展示文本则更接近日常阅读。MentionMessage 同时保存 originalTextdisplayTextmentions,把这几层关系保留下来。

当前解析使用下面的模式寻找标记:

snippet
dart
final mentionRegex = RegExp(r'@\[([^\]]+)\]\(([^)]+)\)');

它对应的是项目约定的格式,不是一个通用 Markdown 解析器。显示名中的右方括号、身份字段中的右括号以及转义规则,都不在这条模式里自动解决。

因此,输入端允许哪些字符,序列化时怎样表达特殊字符,都需要与解析规则一致。

替换以后,后面的索引会移动

假设一条消息里有两次提及:

snippet
text
请 @[张三](a@example.com) 和 @[李四](b@example.com) 确认

第一段原始标记替换成 @张三 后,文本变短了。第二次提及在展示文本中的位置,也会比它在原始文本中的位置更靠前。

当前代码维护了一个累计偏移量:

snippet
dart
final startIndex = match.start - offset;
final endIndex = startIndex + displayName.length + 1;

每次替换以后,把减少的长度累计进去:

snippet
dart
offset += match.group(0)!.length - displayName.length - 1;

末尾的 1 对应展示文本中的 @

按这套计算,上面的展示结果是“请 @张三 和 @李四 确认”,两个提及的区间分别为 [2, 5)[8, 11)。这里使用左闭右开的表示,便于直接表达子字符串范围。

关键前提是:匹配位置来自原始文本,区间却属于替换后的展示文本。两个文本版本不能混用。

一个看得见的字符,不一定占一个位置

如果在句首加一个 emoji,后面的区间会变多少?

😀 为例,它在 Dart 字符串里占两个 UTF-16 code unit。Dart 的 String.length 统计的就是这个单位,而不是用户看到的字符数量。参见 String.length 官方文档

所以解析下面这段输入时:

snippet
text
😀 @[张三](a@example.com)

展示结果中的提及从索引 3 开始,结束边界为 6。不能按“一个表情加一个空格”算成索引 2。

这也是跨端协议需要明确索引单位的原因。即使两端都用 UTF-16 索引,也仍然要保证它们针对的是同一份展示文本,没有在一端额外做换行替换、字符规范化或昵称修改。

对编辑器来说,光标位置、用户感知字符和协议区间还可能承担不同职责。不能只因为它们最终都用整数表示,就认为可以直接交换。

接收端的区间校验

接收端会从 XML 提取 mention 的身份、显示名和位置。当前解析在位置转换失败时回退为零,并在身份非空时收集记录。

这些步骤把数据读进模型,但还不能证明区间满足完整约束。

例如,进一步校验时需要考虑开始位置是否小于结束位置、边界是否超过展示文本长度、多次提及是否重叠,以及区间里的文字是否与显示名称对应。目前这层还没有把这些检查做全。

App 的编辑辅助方法还使用了 position > startIndex && position <= endIndex 判断位置。这与取子字符串时的左闭右开区间不同,可能是在表达光标落点的语义。调用方必须明确用途,不能把两种判断直接互换。

模型转换也会影响跨端结果

协议侧模型使用 displayName,App 的 AtMention.toJson() 则把显示名输出到 nickname。App 也有转换到协议模型的专门方法。

两个模型的 JSON 字段不完全一样。如果绕过转换,身份和文本可能还在,显示名字段却对不上。

我会拿带 emoji、连续提及和重名的文本走一次转换,再按区间截出名字核对。只用一条 @张三 的例子,很多位置问题看不出来。

最后修改时间: Sep 15
cd ..