[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"i-ri:search-line":3,"i-ri:moon-line":8,"i-ri:sun-line":10,"i-ri:menu-3-line":12,"i-ri:close-line":14,"notes-module-items-im":16,"notes-page-im-03-message-content\u002F03-mentions":66,"i-ri:arrow-left-s-line":336,"i-ri:menu-2-line":338,"i-ri:arrow-right-s-line":340,"i-ep:copy-document":342},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":7},0,24,false,"\u003Cpath fill=\"currentColor\" d=\"m18.031 16.617l4.283 4.282l-1.415 1.415l-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9s9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617m-2.006-.742A6.98 6.98 0 0 0 18 11c0-3.867-3.133-7-7-7s-7 3.133-7 7s3.133 7 7 7a6.98 6.98 0 0 0 4.875-1.975z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":9},"\u003Cpath fill=\"currentColor\" d=\"M10 7a7 7 0 0 0 12 4.9v.1c0 5.523-4.477 10-10 10S2 17.523 2 12S6.477 2 12 2h.1A6.98 6.98 0 0 0 10 7m-6 5a8 8 0 0 0 15.062 3.762A9 9 0 0 1 8.238 4.938A8 8 0 0 0 4 12\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":11},"\u003Cpath fill=\"currentColor\" d=\"M12 18a6 6 0 1 1 0-12a6 6 0 0 1 0 12m0-2a4 4 0 1 0 0-8a4 4 0 0 0 0 8M11 1h2v3h-2zm0 19h2v3h-2zM3.515 4.929l1.414-1.414L7.05 5.636L5.636 7.05zM16.95 18.364l1.414-1.414l2.121 2.121l-1.414 1.414zm2.121-14.85l1.414 1.415l-2.121 2.121l-1.414-1.414zM5.636 16.95l1.414 1.414l-2.121 2.121l-1.414-1.414zM23 11v2h-3v-2zM4 11v2H1v-2z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":13},"\u003Cpath fill=\"currentColor\" d=\"M3 4h18v2H3zm6 7h12v2H9zm-6 7h18v2H3z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":15},"\u003Cpath fill=\"currentColor\" d=\"m12 10.587l4.95-4.95l1.414 1.414l-4.95 4.95l4.95 4.95l-1.415 1.414l-4.95-4.95l-4.949 4.95l-1.414-1.415l4.95-4.95l-4.95-4.95L7.05 5.638z\"\u002F>",[17,22,26,30,34,38,42,46,50,54,58,62],{"path":18,"title":19,"meta":20},"\u002Fnotes\u002Fim\u002F01-message-state\u002F01-offline-sync","连接恢复了，离线消息为什么还没补齐？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},"2026-11-05T09:47:15.000Z",{"path":23,"title":24,"meta":25},"\u002Fnotes\u002Fim\u002F01-message-state\u002F02-read-cursor","手机读过了，电脑为何又显示未读？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":27,"title":28,"meta":29},"\u002Fnotes\u002Fim\u002F01-message-state\u002F03-pagination-search","聊天搜索跳回旧消息后，怎样继续翻页？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":31,"title":32,"meta":33},"\u002Fnotes\u002Fim\u002F01-message-state\u002F04-unread-refresh","消息没停，未读角标就不更新？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":35,"title":36,"meta":37},"\u002Fnotes\u002Fim\u002F02-connection-protocol\u002F01-stream-parsing","一次接收回调，为什么不是一条消息？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":39,"title":40,"meta":41},"\u002Fnotes\u002Fim\u002F02-connection-protocol\u002F02-request-response","长连接上的请求，怎样等到自己的响应？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":43,"title":44,"meta":45},"\u002Fnotes\u002Fim\u002F02-connection-protocol\u002F03-connection-lifecycle","登录一直在连接中，到底在等什么？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":47,"title":48,"meta":49},"\u002Fnotes\u002Fim\u002F03-message-content\u002F01-file-download","附件下载完了，为什么还是打不开？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":51,"title":52,"meta":53},"\u002Fnotes\u002Fim\u002F03-message-content\u002F02-image-send","图片上传到 100%，为什么还没发出去？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":55,"title":56,"meta":57},"\u002Fnotes\u002Fim\u002F03-message-content\u002F03-mentions","聊天 @提及怎样保存身份和位置？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":59,"title":60,"meta":61},"\u002Fnotes\u002Fim\u002F04-client-release\u002F01-flutter-build","Release 包为什么仍连着测试环境？",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"path":63,"title":64,"meta":65},"\u002Fnotes\u002Fim","即时通信",{"date":-1,"tags":-1,"summary":-1,"lastUpdated":21},{"id":67,"type":68,"sourcePath":69,"routePath":55,"path":55,"title":56,"description":70,"updatedAt":21,"meta":71,"body":80,"toc":329},"bm90ZTpub3Rlcy9pbS8wMy1tZXNzYWdlLWNvbnRlbnQvMDMtbWVudGlvbnMubWQ","note","notes\u002Fim\u002F03-message-content\u002F03-mentions.md","群聊输入 @ 后选择成员，页面会插入名字并高亮。但如果两人都叫张三，或者成员以后改名，单独保存一段 @张三 就无法确认提到的是谁。",{"id":67,"type":68,"sourcePath":69,"slug":72,"title":56,"updatedAt":21,"description":70,"tags":73,"date":77,"comment":78,"summary":79,"lastUpdated":21},"im\u002F03-message-content\u002F03-mentions",[74,75,76],"dart","IM","unicode","2026-09-09",true,"提及需要保存身份、显示名和展示文本区间。替换标记后累计修正偏移，再统一 emoji 的索引单位与跨端文本。",{"type":81,"children":82},"root",[83,91,106,111,118,123,135,140,145,163,168,174,179,188,193,202,231,237,261,274,279,291,297,302,323],{"type":84,"tag":85,"props":86,"children":88},"element","h1",{"id":87},"聊天-提及怎样保存身份和位置",[89],{"type":90,"value":56},"text",{"type":84,"tag":92,"props":93,"children":94},"p",{},[95,97,104],{"type":90,"value":96},"群聊输入 @ 后选择成员，页面会插入名字并高亮。但如果两人都叫张三，或者成员以后改名，单独保存一段 ",{"type":84,"tag":98,"props":99,"children":101},"code",{"className":100},[],[102],{"type":90,"value":103},"@张三",{"type":90,"value":105}," 就无法确认提到的是谁。",{"type":84,"tag":92,"props":107,"children":108},{},[109],{"type":90,"value":110},"这个 Flutter App 把身份与显示文字一起保存，接收端还需要知道名字在正文中的区间。问题因此分成两部分：关联正确的人，以及在正确位置展示提及。",{"type":84,"tag":112,"props":113,"children":115},"h2",{"id":114},"只传名字不够通常还要传什么",[116],{"type":90,"value":117},"只传名字不够，通常还要传什么？",{"type":84,"tag":92,"props":119,"children":120},{},[121],{"type":90,"value":122},"可以把提及作为独立的结构化片段保存，也可以在原始文本中加入身份标记，再生成展示文本。项目采用后一种约定，示意如下：",{"type":84,"tag":124,"props":125,"children":130},"pre",{"className":126,"code":128,"language":90,"meta":129},[127],"language-text","原始：请 @[张三](user-a@example.com) 看一下\n展示：请 @张三 看一下\n","",[131],{"type":84,"tag":98,"props":132,"children":133},{"__ignoreMap":129},[134],{"type":90,"value":128},{"type":84,"tag":92,"props":136,"children":137},{},[138],{"type":90,"value":139},"保存结果包含原始文本、展示文本和提及列表。每个提及记录用户的 XMPP 地址标识 JID、这次显示的名字，以及展示文本中的起止位置。重名用户的身份仍然不同，旧消息是否跟随资料改名则需要产品另定规则。",{"type":84,"tag":92,"props":141,"children":142},{},[143],{"type":90,"value":144},"解析使用的正则是：",{"type":84,"tag":124,"props":146,"children":149},{"className":147,"code":148,"language":74,"meta":129,"style":129},"language-dart shiki shiki-themes github-light github-dark","final mentionRegex = RegExp(r'@\\[([^\\]]+)\\]\\(([^)]+)\\)');\n",[150],{"type":84,"tag":98,"props":151,"children":152},{"__ignoreMap":129},[153],{"type":84,"tag":154,"props":155,"children":158},"span",{"class":156,"line":157},"line",1,[159],{"type":84,"tag":154,"props":160,"children":161},{},[162],{"type":90,"value":148},{"type":84,"tag":92,"props":164,"children":165},{},[166],{"type":90,"value":167},"它只处理这套格式，名字中的右方括号、身份中的右括号与转义仍需约定，不能当成通用 Markdown 解析器。输入端允许的字符与序列化规则要一致。",{"type":84,"tag":112,"props":169,"children":171},{"id":170},"第一处名字缩短了第二处位置怎么计算",[172],{"type":90,"value":173},"第一处名字缩短了，第二处位置怎么计算？",{"type":84,"tag":92,"props":175,"children":176},{},[177],{"type":90,"value":178},"原始标记比展示文字长，前面替换一次，后面的位置就会前移。例如：",{"type":84,"tag":124,"props":180,"children":183},{"className":181,"code":182,"language":90,"meta":129},[127],"请 @[张三](a@example.com) 和 @[李四](b@example.com) 确认\n",[184],{"type":84,"tag":98,"props":185,"children":186},{"__ignoreMap":129},[187],{"type":90,"value":182},{"type":84,"tag":92,"props":189,"children":190},{},[191],{"type":90,"value":192},"项目记录前面累计减少的长度，用原始匹配位置减去这个偏移，再按展示名长度计算结束边界。计算关系可以写成：",{"type":84,"tag":124,"props":194,"children":197},{"className":195,"code":196,"language":90,"meta":129},[127],"开始位置 = 原始匹配位置 - 已累计减少的长度\n结束位置 = 开始位置 + 显示名长度 + 1\n本次减少 = 原始标记长度 - 显示名长度 - 1\n",[198],{"type":84,"tag":98,"props":199,"children":200},{"__ignoreMap":129},[201],{"type":90,"value":196},{"type":84,"tag":92,"props":203,"children":204},{},[205,207,213,215,221,223,229],{"type":90,"value":206},"两个 1 都对应 ",{"type":84,"tag":98,"props":208,"children":210},{"className":209},[],[211],{"type":90,"value":212},"@",{"type":90,"value":214},"。最终展示“请 @张三 和 @李四 确认”，提及区间为 ",{"type":84,"tag":98,"props":216,"children":218},{"className":217},[],[219],{"type":90,"value":220},"[2, 5)",{"type":90,"value":222}," 与 ",{"type":84,"tag":98,"props":224,"children":226},{"className":225},[],[227],{"type":90,"value":228},"[8, 11)",{"type":90,"value":230},"，按左闭右开截取。原始匹配位置与最终区间属于两份文本，后面不能混着用。",{"type":84,"tag":112,"props":232,"children":234},{"id":233},"前面加一个-emoji为什么位置又错了",[235],{"type":90,"value":236},"前面加一个 emoji，为什么位置又错了？",{"type":84,"tag":92,"props":238,"children":239},{},[240,242,251,253,259],{"type":90,"value":241},"程序索引的单位未必等于人眼看到的字符数。Dart 的 ",{"type":84,"tag":243,"props":244,"children":248},"a",{"href":245,"rel":246},"https:\u002F\u002Fapi.dart.dev\u002Fdart-core\u002FString\u002Flength.html",[247],"nofollow",[249],{"type":90,"value":250},"String.length 官方文档",{"type":90,"value":252}," 使用 UTF-16 code unit，",{"type":84,"tag":98,"props":254,"children":256},{"className":255},[],[257],{"type":90,"value":258},"😀",{"type":90,"value":260}," 占两个单位。",{"type":84,"tag":92,"props":262,"children":263},{},[264,266,272],{"type":90,"value":265},"因此 ",{"type":84,"tag":98,"props":267,"children":269},{"className":268},[],[270],{"type":90,"value":271},"😀 @张三",{"type":90,"value":273}," 中的提及从 3 开始、到 6 结束，不能按一个表情加一个空格算成 2。跨端传区间时，索引单位要统一，展示文本也必须相同。",{"type":84,"tag":92,"props":275,"children":276},{},[277],{"type":90,"value":278},"一端如果替换换行、规范化字符或更新昵称，另一端继续拿旧区间截取，单位即使一致也会错。位置是在某份具体文本上的坐标，不是脱离正文仍然有效的身份。",{"type":84,"tag":92,"props":280,"children":281},{},[282,284,289],{"type":90,"value":283},"验证可以把 emoji、连续提及和重名样例走完输入、序列化、协议转换与接收，再根据区间截出名字核对。一个普通的单次 ",{"type":84,"tag":98,"props":285,"children":287},{"className":286},[],[288],{"type":90,"value":103},{"type":90,"value":290},"，很难覆盖这些偏移和边界问题。",{"type":84,"tag":112,"props":292,"children":294},{"id":293},"接收时还需要验证什么",[295],{"type":90,"value":296},"接收时还需要验证什么？",{"type":84,"tag":92,"props":298,"children":299},{},[300],{"type":90,"value":301},"项目 XML 接收会提取身份、名字和区间，数字转换失败回退零，身份非空就收集。起止顺序、越界、重叠和截取内容与显示名是否一致，尚未完整校验。",{"type":84,"tag":92,"props":303,"children":304},{},[305,307,313,315,321],{"type":90,"value":306},"编辑辅助中的区间比较还有另一套边界，可能描述光标落点，需要按调用用途核对，不能直接与截取子串的左闭右开统一。App 输入模型又用 ",{"type":84,"tag":98,"props":308,"children":310},{"className":309},[],[311],{"type":90,"value":312},"nickname",{"type":90,"value":314}," 表示名字，协议模型使用 ",{"type":84,"tag":98,"props":316,"children":318},{"className":317},[],[319],{"type":90,"value":320},"displayName",{"type":90,"value":322},"，发送前需要经过专门转换；绕过它直接传 JSON，显示名可能丢失。",{"type":84,"tag":324,"props":325,"children":326},"style",{},[327],{"type":90,"value":328},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":129,"searchDepth":330,"depth":330,"links":331},2,[332,333,334,335],{"id":114,"depth":330,"text":117},{"id":170,"depth":330,"text":173},{"id":233,"depth":330,"text":236},{"id":293,"depth":330,"text":296},{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":337},"\u003Cpath fill=\"currentColor\" d=\"m10.828 12l4.95 4.95l-1.414 1.415L8 12l6.364-6.364l1.414 1.414z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":339},"\u003Cpath fill=\"currentColor\" d=\"M3 4h18v2H3zm0 7h12v2H3zm0 7h18v2H3z\"\u002F>",{"left":4,"top":4,"width":5,"height":5,"rotate":4,"vFlip":6,"hFlip":6,"body":341},"\u003Cpath fill=\"currentColor\" d=\"m13.172 12l-4.95-4.95l1.414-1.413L16 12l-6.364 6.364l-1.414-1.415z\"\u002F>",{"left":4,"top":4,"width":343,"height":343,"rotate":4,"vFlip":6,"hFlip":6,"body":344},1024,"\u003Cpath fill=\"currentColor\" d=\"M768 832a128 128 0 0 1-128 128H192A128 128 0 0 1 64 832V384a128 128 0 0 1 128-128v64a64 64 0 0 0-64 64v448a64 64 0 0 0 64 64h448a64 64 0 0 0 64-64z\"\u002F>\u003Cpath fill=\"currentColor\" d=\"M384 128a64 64 0 0 0-64 64v448a64 64 0 0 0 64 64h448a64 64 0 0 0 64-64V192a64 64 0 0 0-64-64zm0-64h448a128 128 0 0 1 128 128v448a128 128 0 0 1-128 128H384a128 128 0 0 1-128-128V192A128 128 0 0 1 384 64\"\u002F>"]