Compare commits

...
45 Commits
Author SHA1 Message Date
Your NameandClaude Sonnet 5 975dc77111 Revert "网页版支持粘贴/选择多张图片暂存,可配文字一起发送"
用户反馈这个交互做得不够好,要求退回上一版(保留粘贴发图/保存图片,去掉多图暂存+图文一起发)。

This reverts commit 6bcfc08943.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:12:29 +08:00
Your NameandClaude Sonnet 5 6bcfc08943 网页版支持粘贴/选择多张图片暂存,可配文字一起发送
老板反馈:粘贴图片、选择文件都是立即发送,体验不好;想要能粘贴多张图片,并且可以带着文字一起发,
不带文字也要能发。

- 粘贴图片、点击图片图标选图(新增 multiple 多选)现在都不再立即发送,改成暂存进输入框上方的
  缩略图预览条,可以继续粘贴/选择叠加更多图片,每张都能单独点掉重新移除
- 发送(Enter / 点击发送按钮)时,把暂存的图片按添加顺序依次发出去,再发文字(如果有输入的话);
  没有文字、只有图片也能发;没有图片、只有文字保持原有行为不变
- 引用回复只附着在这一批里实际发出的第一条消息上,跟"引用只作用于下一条消息"的既有规则保持一致
- 单张图片发送失败不影响这一批里的其他图片和后面的文字,各自独立报错提示
- 参考设计图(语音图片发送状态.png)确认了这个后端每种附件类型都是独立气泡,没有"图文合并成一条
  消息"的协议形态,所以是按多条消息依次发送实现的,不是改协议

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:06:24 +08:00
Your NameandClaude Sonnet 5 3d48abdf73 网页版支持粘贴图片发送、聊天图片保存到本地
老板反馈:"没有复制粘贴图片功能,只能保存在电脑里面发,图片也没有保存功能"

- 聊天输入框新增粘贴图片检测:剪贴板里有图片(截图工具/复制图片)时直接走发图逻辑,
  不再要求先存到电脑里再用文件选择器上传;普通文本粘贴不受影响
- "查看大图"预览里新增"保存图片"下载按钮,取消息自带的原始文件名,
  取不到时按mime类型生成默认文件名

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 02:42:21 +08:00
Your NameandClaude Sonnet 5 9abae5030b 修复codereview复核确认的多选/转发/收藏页/资料页等前端bug
- 多选模式下Drafty链接、名片消息不再意外触发跳转/开新标签页
- 语音/视频消息在多选模式下点击可正常冒泡选中该行
- 转发排除未确认/发送失败消息,识别uncertain转发结果并单独提示
- 群文件"更多操作"菜单竞态、群成员移出状态竞态
- 收藏页链接卡片new URL()崩溃点加防御,并给登录后应用区域加错误边界
- 个人资料页乐观锁版本冲突时清空草稿避免静默覆盖并发修改

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 16:47:32 +08:00
Your NameandClaude Sonnet 5 e3eed8bdd4 Add click-to-view lightbox with zoom for chat images
Tapping a chat image opens a full-screen viewer (same resolved
image source, no extra fetch) with zoom in/out buttons, mouse-wheel
zoom, drag-to-pan once zoomed, and double-click to toggle zoom.
Wheel zoom uses a native non-passive listener rather than JSX onWheel,
since a passive listener's preventDefault() is silently ignored and
would leave the page scrolling under the lightbox.

Also closes a gap the row-click-to-select change (1cc6f506) left open:
image/video/audio/file attachments inside a bubble had no selectMode
awareness, so tapping one during bulk-select would also open the
lightbox / play media / start a download alongside toggling the row.
Added a `locked` prop to all four attachment components (disabling
their own click behavior without changing how they look) and wired
`locked={selectMode}` from both MessageBubble.tsx and drafty.tsx's
IM/EX/AU/VD cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:32:07 +08:00
Your NameandClaude Sonnet 5 aca5f8609b Fix misleading "clear chat history" confirmation copy
The dialog said clearing was "local record only" -- reading the real
backend handler (im_be's Topic.replyDelMsg) shows a soft delete
persists a per-account delete watermark server-side, visible across
every device/session on that account and not reversible through any
normal client action. Only the "doesn't affect the other party" half
of the old copy was accurate. Updated both the P2P and group versions
to say what actually happens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:21:56 +08:00
Your NameandClaude Sonnet 5 1cc6f5068a Make whole message row tappable to toggle selection
The selection checkbox was the only clickable target in multi-select
mode; tapping the rest of the row (bubble, avatar, timestamp) did
nothing. Moves the toggle handler to the row container so tapping
anywhere selects/deselects, matching the multi-select UX in every
other list this app has (favorites, blocklist, etc).

Nested controls that keep their own click behavior during selectMode
(retry-failed-send, @mention profile card, quote-bar jump-to-message)
would otherwise double-fire alongside the new row click -- retry stops
propagation, mentions and the quote bar go inert instead (same
treatment this file already gives the avatar/sender-name buttons
during selectMode), so a tap only ever does the one thing: toggle the
row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 13:42:27 +08:00
Your NameandClaude Sonnet 5 95c0677b55 Fix forward comment landing before forwarded content
The comment send was fired in parallel with the forward REST call,
not chained after it -- confirmed live with a 2-source bulk forward,
where the comment (plain WS publish, fast) landed before the forwarded
messages (async endpoint, responds with status:"pending" before the
messages are actually persisted server-side). Chains the comment send
off the forward promise instead of starting both at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:37:01 +08:00
Your NameandClaude Sonnet 5 4cd2b33c53 Migrate message forwarding to backend REST endpoint
Both real mobile clients (iOS, Android) forward messages via POST
/v1/im/messages/forward, never a client-side {pub} packet -- confirmed
by reading their source directly. Switches web's single-message and
bulk forward paths to the same endpoint, which copies message content
verbatim regardless of shape, eliminating the client-side flat-content
detection this used to require. Comment-sending stays on the existing
sendText() path, unchanged, to preserve current ordering.

Removes send.ts's now-unused forwardMessage/flatForwardPayload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:27:37 +08:00
Your NameandClaude Sonnet 5 e7cb4edf3a 读了iOS/Android真实源码后修正:busy确实是真实的通话状态,之前判断错了
早前(Part I/K)判断"busy从未在持久化消息里真实出现过,只是本应用自己在
live信令上用的值",所以没把它并入 CallWebrtcState,只在本地 UI 状态单独
加了个 union。这次拿到手机端真实源码核对,iOS(ServerMessages.swift的7态
枚举)和Android(MsgServerData.java的BUSY)都把busy当成服务端下发的正式
head.webrtc取值之一,和accepted/finished等其余几个是同一级别的东西,
不是本应用凭空发明的——之前的判断依据不足。

补上 case 'busy' 的通话结果文案("对方忙线中",和已有的实时来电忙线提示
用词一致),去掉 stores/calls.ts 里两处不再需要的 `| 'busy'` 单独union。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 11:51:28 +08:00
Your NameandClaude Sonnet 5 1d383b4e93 转发消息的来源标记改用手机端同款字段,修复网页转发在手机端认不出"转发"的问题
网页版转发功能此前一直用自造的 head.forwarded: "topic:seq" 单字符串字段记录
来源,且这个字段在整个网页项目里从未被任何地方读取过(纯写入,无消费者)。

数据库排查(排查转发内容丢失bug时顺带确认)发现手机端自己的转发用的是完全
不同的字段:head['x-im-forwarded']:true + head['x-im-forward-source']:
{seq,from,topic},是真实双端数据。网页版这个自造格式手机端根本不认识,
意味着从网页转发出去的消息,即使内容显示正常,手机端也大概率认不出"这是
一条转发消息",不会显示对应的转发来源提示(如果手机端UI有这个功能的话)。

改成手机端同款的两字段格式。确认这不影响网页自己的任何逻辑(旧字段确认无
读取方)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 11:34:44 +08:00
Your NameandClaude Sonnet 5 ed2920275d 修复资料弹窗点击自己头像必定加载失败
GET /users/{id}/profile 对自己的id恒定400(和更早修过的群内自己资料
400是同一类self-lookup限制),点击群成员格子/列表里自己的头像会一直
显示"加载失败,请重试"。改成自己走专门的 GET /me/profile(没有这个
限制),归一化成和他人资料相同的字段结构供同一套渲染逻辑使用。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 00:49:06 +08:00
Your NameandClaude Sonnet 5 0ba5c97d78 codereview修复:资料弹窗好友关系判断、转发内容克隆、账号切换缓存残留等
三路独立codereview(群成员UI+资料弹窗 / 未读红点+转发修复 / 搜索+复制修复)
发现并修复:

- UserCardRoot 资料弹窗此前完全没读 profile.relationship,对陌生人也直接
  显示"发消息"/通话按钮——现在按好友/待验证/对方已申请/不可添加/添加好友
  五态分支,和 FriendProfileView 保持一致,接入 AddFriendModal。
- useEscapeKey 注册位置错误:挂在常驻的 UserCardRoot 而不是随卡片开关的
  UserCardPanel,导致从其他弹层里打开资料卡片时,按 Escape 关掉的是底下
  那层弹层而不是卡片本身。
- "移出"格子的可见性此前只判断 isAdmin,在"群主+多个管理员、没有普通成员"
  的群里会显示格子但点进去一个能移出的都没有——改成同时判断成员列表里
  是否真的存在能移出的对象。
- flatForwardPayload 对图片/文件/语音/视频/名片/通话结果类消息,转发时直接
  返回了原消息的 content 对象引用而不是拷贝——SDK 出站前会就地删除假值
  字段,等于转发的同时悄悄改写了原消息在本地缓存里的内容。改成每次转发都
  返回浅拷贝;顺手让检查顺序和其余识别这几种格式的地方保持一致(先判类型
  明确的附件/名片/通话结果,再判无类型标记的纯文本),并补上和
  mobileAttachmentContentOf/contactCardContentOf 同样的字段合法性校验。
- confirmedReadSeq 等聊天列表相关的模块级缓存此前从未在登出/切换企业时清
  空——同一个标签页不刷新页面直接换账号登录,会把上一个账号的"已读到哪"
  当成新账号的下限,把新账号真实未读的自聊消息误判为已读。补了统一的
  reset监听机制(connection.ts 的 resetTinode 现在会广播),topics.ts 据此
  清空所有相关缓存。
- 复制功能补final `?? ''`兜底,防止附件数据本身不完整时仍复制出"undefined"。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 00:45:34 +08:00
Your NameandClaude Sonnet 5 105ff76959 排查转发bug同类问题:修复查找聊天记录崩溃 + 复制功能复制出"undefined"
审查了所有"读取消息content做数据处理"的地方,找到两处和转发bug同一根因
(手机端扁平非Drafty内容格式)的真实问题:

1. 查找聊天记录(HistorySearchModal)的搜索结果渲染,对手机端发送的图片/文件/
   语音/视频/名片消息命中关键词时会让整个应用崩溃——Drafty.toPlainText在这
   类扁平内容上返回undefined,highlightMatches对undefined调用.toLowerCase()
   直接抛异常,而这个应用完全没有错误边界(ErrorBoundary)兜底,等于白屏。
   已用真实搜索接口返回确认snapshot里其实带着head字段(此前SearchHit类型
   没建模),据此在渲染前识别这几种扁平内容并显示[图片]/[文件]/[语音]/[名片]
   这样的占位文案,不再无脑喂给renderDrafty;顺手给highlightMatches补了防御
   性判空,避免类似疏漏再次崩溃整个应用。

2. 消息右键菜单"复制"功能,对手机端发送的扁平文本消息(如群公告自动发布的
   那条)和图片/文件/语音/视频消息,复制到剪贴板的是字面字符串"undefined"。
   文本类修成正确提取真实文本;附件类直接从"复制"这个操作里去掉(参考名片/
   通话记录消息已有的排除逻辑),复制一段JSON式的附件数据本来就没有意义。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 00:06:10 +08:00
Your NameandClaude Sonnet 5 eb0a6eba7e 修复转发含flat非Drafty内容的消息(手机端文本/图片/语音/视频/名片/通话结果)导致content丢失显示"已删除"
真实数据库排查确认根因:forwardMessage()原来无条件把 msg.content 喂给
Drafty.forwardedContent(toDraftyContent(...)),假设内容永远是纯字符串或真
Drafty文档({txt,fmt,ent})。但手机端把文本/图片/语音/视频/名片都发成扁平非
Drafty JSON(如 {"text":"11"}),只靠 head['x-im-type']/head.webrtc 标记类型
——对这类对象跑 Drafty 处理会退化成空内容,SDK 出站的 simplify() 又会把空
content 整个从包里剥掉,服务端收到的 {pub} 根本没有 content 字段,落库后
content/content_nonce 都是真正的 NULL,客户端据此正确地(从自己的角度)把
它当成"已撤回"渲染成"⚠ 消息已删除"。

用 SSH+kubectl exec 直接查了k3s上的真实MySQL(meeting_im库, im_003_group_
messages_202609/im_003_messages两张表),确认了具体是哪几条消息、它们的
head/content 真实长什么样,而不是猜测。

修复:flatForwardPayload() 识别这几种扁平内容形状,文本类重建成真正的
Drafty文本,附件/名片/通话结果类原样保留内容和 x-im-type/webrtc 标记(接收
端已有的对应dispatch逻辑本来就认识这些形状),只有真正的纯字符串/Drafty
内容才继续走原来的 Drafty.forwardedContent() 路径。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 20:59:37 +08:00
Your NameandClaude Sonnet 5 0ace395cfb 修复群聊自己发消息后聊天列表出现虚假未读红点
reconcileMeSubs() 的周期性 me.getMeta(withLaterSub()) 拉取,对已知会话原有的
回归保护(seq<=prev.seq && unread>prev.unread) 只防得住"seq没变但unread变大"
这一种情形。自己在已打开的会话里发消息时:本地 confirmedReadSeq 会同步立即
更新(早于服务端),但发给服务端的 note read 是异步的;如果这次拉取的请求
恰好在发送前就已经在途,返回时 seq 已经真实前进(不满足 seq<=prev.seq)、
read 却还没追上,旧保护不生效,虚假的 unread 就被原样套用——哪怕这条消息
就是自己刚发的。

补一个独立的、无条件的兜底:用已有的 confirmedReadSeq 记录做下限,read 永远
不会被这次拉取拉回到低于"本客户端已确认看过的位置",与原有保护并存、不冲突。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 18:02:25 +08:00
Your NameandClaude Sonnet 5 ef31eb2e88 新增点击头像/昵称查看用户资料弹窗,接入消息气泡/@提及/群成员列表
新增全局 UserCardRoot(stores/userCard.ts 驱动,挂载在 AppShell 里,模式同
ConfirmDialogRoot),复用 getUserProfile 数据源,展示头像/昵称/性别地区/签名,
非本人时带发消息/语音/视频快捷操作,底部"查看完整资料"跳转到完整的
/contacts/{id} 页面。

接入点:
- MessageBubble.tsx:群聊和单聊的对方头像/昵称(原来只有群聊头像/昵称是
  Link 到完整资料页,单聊完全没有交互)
- drafty.tsx:@提及实体(Drafty.mention 的 uid 存在 ent[].data.val 里,
  之前渲染完全没读这个字段),"所有人"哨兵值除外
- GroupMembersModal.tsx / GroupDetailColumn.tsx:群成员列表的行内和紧凑
  头像格子

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 17:45:23 +08:00
Your NameandClaude Sonnet 5 2427a3782c 群成员格子改回独立"移出"格子,点击打开成员列表,不再是逐头像角标+直接移出
用户再次纠正:不要在每个可移出成员的头像上单独加角标、点击直接弹确认移出;而是
像"+邀请"一样,做一个独立的"-移出"格子(同款虚线方块,仅 isAdmin 可见),点击后
打开"查看全部"成员列表,真正的移出操作留在列表里已有的那一行按钮上。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 17:28:17 +08:00
Your NameandClaude Sonnet 5 05ee23aefa 群成员格子改用头像外角标 ⊖ 移出,替换上一版的行内文字按钮
上一版把 ➖ 加在了"查看全部"弹层的成员行文字按钮里,用户纠正:应该是紧凑头像格子
(群聊设置面板里"查看全部"之前那一屏)里,像"+邀请"那样在头像外侧显示一个独立的
角标,而不是列表行内的文字。canRemoveMember 挪到 lib/api/groups.ts 共享,避免
两处判断逻辑分叉。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 17:19:45 +08:00
Your NameandClaude Sonnet 5 2d589cbe6b 群成员移出按钮补充 remove_circle 图标
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:58:34 +08:00
Your NameandClaude Sonnet 5 304283c8d8 fix(messages): @mentions leaked the sender's private remark
Reported + reproduced live: a group owner's own private friend remark
for a member ("小号1"/"私密备注勿外传" in testing) ended up baked into
that member's @mention text, visible to every other group member with
no idea what it referred to.

Root cause confirmed by reading server/im_http.go's userSummaryPayload:
GET .../members?mentionable=true's `display_name` unconditionally
prefers the CALLING viewer's own remark for that member when one is
set. That's correct for a list only the viewer sees, but MentionPicker
was using it to build the mention's label, which gets baked into the
message's shared Drafty content once (mentions.ts's buildMentionedContent)
and read identically by everyone from then on -- unlike a sender-name
label, which every viewer's own client re-renders fresh per message and
can legitimately show their own remark for.

Switches MentionPicker's label (both the picker's own visible list and
the mention baked into the sent message) to the same response's
`nickname` field (copyUserNickname in the same backend function),
which reads straight off the member's public profile and is never
remark-shadowed. Verified live: set a real friend remark, confirmed
the picker still showed the real nickname (not the remark), picked it,
and confirmed the actually-sent message's @mention text was the real
nickname too -- while the unrelated sender-name label elsewhere in the
same conversation correctly kept showing the remark, confirming the
fix didn't touch that intentionally-different behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:10:03 +08:00
Austin cbb01085c4 fix(reply): 修复回复消息中媒体引用的兼容性问题
适配了 Drafty 媒体的 `refurl` 字段,同时兼容旧的 `ref` 和 `val` 字段,确保不同来源的媒体链接都能正确解析用于渲染。
2026-09-23 21:37:07 +08:00
Austin a8d3504424 feat: 实现跨端引用回复协议,新增 QuoteBar 组件支持消息引用
本次提交实现了与 iOS 对齐的跨端消息引用协议,新增 QuoteBar 组件用于渲染引用消息栏,重构发送逻辑支持文本和媒体消息的引用功能。主要改动包括:
- 新增 reply.ts 处理引用数据的构建和解析
- 新增 QuoteBar 组件渲染引用消息预览
- 重构发送逻辑,将引用快照放入消息头部而非内容
- 调整代理配置指向本地开发环境
- 优化消息操作权限,支持名片和媒体消息引用
2026-09-23 20:40:47 +08:00
Your NameandClaude Sonnet 5 fb92f4f703 fix(groups): hover affordance on the remove-member button
Reported: the new "移出" (remove member) button gave no visual
feedback on hover, so it didn't read as clickable. Tailwind's own
preflight reset sets buttons to cursor:default (not the browser's
usual pointer) unless a cursor utility says otherwise -- adds
cursor-pointer + hover:underline, verified live via
getComputedStyle/:hover that both apply while hovering and correctly
reset on the disabled (in-flight) state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:27:17 +08:00
Your NameandClaude Sonnet 5 4961c0ae6e fix(groups,system): remove-member action + stale invite button
Adds removeGroupMember (DELETE /groups/{topic}/members/{user_id}) --
confirmed live the backend now has this route (server/im_http.go's
serveIMGroupMember + canRemoveIMGroupMember), which an earlier pass
recorded as a genuine protocol gap; it isn't anymore. Wired into
GroupMembersModal with a per-row "移出" button gated by the same rule
the backend enforces (owner or admin removes a plain member, only the
owner removes an admin, nobody removes the owner or themselves), with
a confirm dialog before the real DELETE call. Verified live end-to-end
against a real owned group: member count 4 -> 3, list updates
correctly (the side panel's own separate "群成员 N" summary count
still trailed behind after this -- confirmed via a direct GET that the
server's own group payload hadn't recalculated member_count yet, a
backend read-consistency lag unrelated to this fix, not chased
further).

Also fixes the reported "已处理的群邀请消息,即使群解散了,接受邀请
按钮还是一直可以点" bug: the button's visibility used to be derived
purely from the notification's static action payload every render, so
neither a successful accept nor a definitively-failed one (expired,
already handled, group gone) ever stopped it from looking freshly
clickable again. Tracks per-invitation resolved state (accepted/
failed) once either outcome happens and swaps the button for a static
resolved label. Doesn't survive a page reload for invitations resolved
in an earlier visit (the notification record itself carries no
resolved flag to read back) -- a real, acknowledged limit, not
silently hidden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:08:07 +08:00
Your NameandClaude Sonnet 5 6a65998e42 fix(messages): render mobile-sent image/file/voice/video attachments
Live-confirmed in a real group ("seq测试", tenant 10001) that image
messages sent from Android and iOS render as completely blank bubbles
on web, and the chat list's own preview for one showed the literal
string "undefined". Root cause confirmed by querying the actual
im_messages/im_group_messages rows: mobile apps send image/file/voice/
video as a flat, non-Drafty content object (`{file_id,
thumbnail_file_id, mime, width, height, name, size, ref?, preref?}`)
tagged only by `head['x-im-type']`, not as a real Drafty `{txt,fmt,ent}`
document the way this web client's own sender (attachments.ts) always
builds. renderDrafty/formatMessagePreview had no case for this shape
and silently produced nothing / "undefined" -- the exact same failure
pattern already fixed once for `contact_card` (see dispatch.ts's
existing contactCardContentOf), just not yet extended to attachments.

Adds mobileAttachmentContentOf (dispatch.ts), normalizing `ref`/
`preref` from `file_id`/`thumbnail_file_id` when the wire message
omits them (confirmed both cases occur on real messages), and wires it
into MessageBubble.tsx (reusing the existing Image/File/Audio/Video
Attachment components) and preview.ts (matching bracket text). Extends
VideoAttachment with a remote `previewRef` fallback alongside its
existing inline-base64 `preview`, since mobile videos' poster is a
separately-hosted file, not inline data.

Verified live against the real message rows: detection and URL
construction are confirmed correct (network panel shows the real OSS-
backed URL, exactly matching the file_id from the DB row); the actual
pixels don't load in this local dev environment because OSS's CORS
whitelist doesn't include localhost, a known, already-documented
limitation of testing via localhost instead of a real *.118im.com
domain, not a defect in this fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:48:43 +08:00
Austin 0c38adba78 fix: 修复登录时因租户不一致导致的422策略违规错误
在连接建立时记录服务器实际使用的租户代码,而非直接从存储中读取,避免登录时的租户类型判断与会话租户不一致导致的TENANT_CODE_FORBIDDEN错误。新增登录失败后的自修复重试逻辑,当收到422错误且原因是租户代码被禁止时,临时覆盖登录类型并重试一次,同时重置相关状态避免影响后续连接。
2026-09-22 20:17:39 +08:00
Your NameandClaude Sonnet 5 370bf9d5b0 fix(tinode): trim tenant code before login_type comparison
Code review of the login_type fix (11276804) found tenantCode.toLowerCase()
=== 'default' doesn't trim, so a tenant code with stray leading/trailing
whitespace would be misclassified as enterprise (login_type:0) instead of
personal (login_type:1). tenant.code always comes from the resolve
response, not raw user input, so this was a latent edge case rather than
a reproduced bug -- hardened anyway since the fix is free, matching the
.trim() already used on tenant code elsewhere in this codebase.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:42:39 +08:00
Your NameandClaude Sonnet 5 1127680425 fix(tinode): send login_type on basic-scheme login (Zentao bug #33)
server/user.go's validateLoginTenant now hard-requires a login_type
field (0 enterprise / 1 personal) on every {login scheme:"basic"}
message, rejecting the rest with a 422 LOGIN_TYPE_REQUIRED policy
violation -- confirmed live by reading the currently-deployed
server/session.go call site and server/user.go's validation logic.
The vendored tinode-sdk has no API surface for this custom field (its
login()/loginBasic() packet builders are private class fields that
only ever write scheme/secret/cred), so every basic-scheme web login
was failing.

Extends the existing installTenantHiPatch wire-level interception
(same technique already used for {hi}.tenant) to also inject
login_type into outgoing {login} packets, derived from the resolved
tenant's code. tenant_code is deliberately left unset: the backend
falls back to the session's own tenant (already established via the
{hi} patch) when the {login} message omits it, for both the personal
and enterprise branches. token-scheme logins (session restore/
reconnect re-auth) are untouched.

Verified live: captured the actual outgoing WS frame now carrying
login_type, and confirmed the resulting error changed from what would
be a 422 policy violation to a normal credential-mismatch error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 14:20:08 +08:00
Your NameandClaude Sonnet 5 ed029c59ef fix(tinode): auto-recover from cluster-failover topic proxy teardown
Native Cluster failover tears down a topic's server-side proxy session
on the surviving nodes without notifying the client -- the SDK's local
_attached flag is never reset on this path, so isSubscribed()/
ensureTopicAttached() keep reading a stale true and can't catch it in
advance. The server replies 409 "must attach first" to the {pub}
itself; publishWithReattach() catches exactly that, forces a fresh
subscribe(), and retries the same msg object once (safe: publishDraft's
own _noForwarding guard prevents a duplicate local echo, and the
unchanged x-im-client-mid still dedupes server-side). Wired into all 9
publishDraft call sites (send.ts, attachments.ts, calls.ts).

Confirmed live on an independent native-Cluster verification
deployment: killing the leader pod mid-conversation previously caused
every send to fail until a manual resubscribe; with this fix, 36/36
probes across the same failover succeeded with no manual intervention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 16:44:39 +08:00
Your NameandClaude Sonnet 5 74351af905 fix(messages): merge stale-sender-profile fallback into #32's uid-shape guard
Two independent fixes for the same raw-uid-leak bug landed in parallel:
upstream added groupSenderLabel() to catch uid-shaped names by regex
(covers a still-present member whose display_name IS the uid); the local
change instead resolved a left-group sender's real name via a standalone
getUserProfile() lookup (covers a member no longer in memberByUserId at
all). Neither alone covers both paths. groupSenderLabel now takes multiple
candidates in priority order -- member-list name first, then the fetched
stale-sender name, falling through to the placeholder only when neither
resolves to something that isn't uid-shaped -- so a left member with a
resolvable profile still shows their real name instead of the placeholder,
while the still-present-but-nameless case is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 13:50:41 +08:00
Austin ca7af8baf9 feat(acc-support): 实现动态获取并渲染联系我们条目
新增从后台获取管理员配置的联系我们数据的功能,支持邮箱和电话的跳转链接, fallback 到旧的固定链接配置
2026-09-21 13:12:56 +08:00
Austin 4dadb1bd5a fix(tinode/errors): 处理429账号锁定错误的提示信息
清理429错误消息末尾的状态码后缀,直接使用服务器返回的中文提示,无匹配时使用备用文案
2026-09-20 17:08:59 +08:00
Austin 1952f0ebcd fix(messages): never render raw uid as group sender name (#32)
Senders missing from the member list (left/removed) fell back to msg.from verbatim, and nameless members arrive from the REST API with the uid itself as display_name. Resolve both through a shared groupSenderLabel helper that substitutes a fixed placeholder for uid-shaped names (message bubbles, quoted replies, history search).
2026-09-20 16:42:35 +08:00
Austin 41728017cb Merge branch 'dev' of https://git.118im.com/im_team/im_webapp into dev 2026-09-18 18:34:21 +08:00
Your NameandClaude Sonnet 5 7cf8e4f4e0 fix(groups): correct rename-sync event name and unreliable getMeta path
The previous fix listened for a documented-but-wrong event name
(group.profile.updated); live instrumentation of a real rename showed
the actual wire event is group.settings.updated. Fixing only the name
still didn't work: the refresh mechanism it triggered
(getMeta(startMetaQuery().withDesc().build())) was itself unreliable
against this backend -- live-confirmed to either time out (504) on a
brand-new topic or return stale pre-rename data on an established one,
which also explains the "second rename flips back to the original
name" symptom (a late, stale response from an earlier call clobbering
the correct value).

Replace it with the already-reliable REST getGroup() response applied
locally via the SDK's own _processMetaDesc (same internal method the
SDK calls when a real meta response arrives; confirmed to reach
me.contacts(), which the sidebar/header read).

Also fixes a second, independently-discovered bug found while
verifying the above: notifyTopic's requestAnimationFrame-based
notification batching could get permanently stuck if the callback
was scheduled while the page wasn't compositing frames, silently
freezing all future topic-list updates (not just renames) until a
full reload. Falls back to setTimeout when the document is hidden,
since that is never dropped the way the rAF was.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 18:07:41 +08:00
Austin 0331cb099c Merge branch 'dev' of https://git.118im.com/im_team/im_webapp into dev 2026-09-18 17:58:05 +08:00
Austin f4a6ff36b4 fix: realtime friend-request badge via connection-level info fallback
Server friend.request.* signals ride {info} frames addressed to the
recipient's own user id, but the SDK dispatcher resolves that topic via
its topic cache, where the me topic lives under the literal me -- the
lookup missed and the frame was dropped before any listener could see
it, so the 新朋友 badge and NewFriendsView list only updated after a
manual refresh or relogin. topics.ts now forwards frames whose topic is
not in the SDK cache (isTopicCached) through the connection-level
onInfoMessage hook to the same infoListeners used by topic-level
routing (no duplicate delivery), and AppShell invalidates the shared
['friend-requests'] query namespace on friend.request.* events, keeping
the pending badge live on any page. Zentao #3.
2026-09-18 17:42:48 +08:00
Your NameandClaude Sonnet 5 5748f1290d fix(groups): sync group rename in real time for owner and members
PATCH .../groups/{topic} pushes a custom {info what:"group",
event:"group.profile.updated"} that nothing recognized, so the
sidebar/header name (native SDK contact.public.fn) never refreshed
until an unrelated focus-triggered resync happened to touch it.
Add the missing branch to onInfo (mirrors the existing
group.dissolved/message.recalled handling) to re-pull public/desc
via topic.getMeta(...withDesc()), and trigger the same refresh from
the renamer's own save success since they won't receive their own
push.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 17:36:11 +08:00
Your Name 0de8c0b5ac fix: message list shaking/flickering when scrolling to top or bottom
topic.onData = notify (wireTopicCallbacks) fires once per individual
message, not once per batch. Live-instrumented a real loadOlder() page
fetch (24 older messages): scrollHeight/scrollTop mutated in ~14
separate small steps over ~400ms, each one racing loadOlder()'s own
scroll-compensation and ConversationView's separate autoscroll-to-
bottom effect before the page had actually finished arriving -- that
rapid multi-step crawl is what reads as "shaking" rather than one
clean jump.

Coalesce notifications landing within the same animation frame into a
single flush, so a burst of onData calls (initial load, loadOlder(), a
fast run of incoming messages) triggers at most one re-render per
frame instead of N. messageSnapshots invalidation stays synchronous
(pure cache bookkeeping); only the listener callbacks that schedule
React's re-render are batched.

Live-verified with the same instrumentation: the same 24-message
loadOlder() fetch now produces 2 dom-mutation events instead of 14,
with scrollTop only moving once the batch has actually settled instead
of crawling in lockstep with a growing scrollHeight.
2026-09-18 17:11:40 +08:00
Your Name f5a512b8c9 fix: recall/edit not syncing to the other party in real time
docs/IM-前后端对接文档.md:416,1256 documents a custom {info what:"im",
event:"message.recalled"|"message.edited"} push to every other online
topic member, with an explicit instruction to refresh that message by
topic+seq+version rather than waiting for the user to re-enter the
conversation. Nothing recognized "im" as a valid `what` value (the
native SDK's own _routeInfo has no such case either, same gap as the
existing group.dissolved handling right above this fix), so the push
arrived over the wire but never triggered anything -- the peer's local
message cache kept the stale pre-recall/pre-edit content until some
unrelated full resync happened to refetch it.

Widen the `what` union (both the app-level RawTopicInfo and the SDK's
own onInfo callback type) to include 'im', and on that event issue the
same targeted single-message getMeta(...).withData(seq,seq+1,1) refetch
ConversationView's jump-to-message feature already established.

Live-verified: recalling a message now shows "⚠ 消息已删除" on the
other party's screen immediately, with no manual refresh.
2026-09-18 16:09:52 +08:00
Your Name 1a4a96461a feat: show group announcement read count and send read receipts
Backend added read_count/member_count/read_summary to the announcement
payload in today's deploy (previously confirmed absent). Wire it up:
- groups.ts: add the three fields to the GroupAnnouncement type.
- GroupAnnouncementModal.tsx: render "已读N/M" per README §4.8, and
  call readGroupAnnouncement() on open -- that endpoint existed in
  groups.ts already but was never called from anywhere, so opening an
  announcement never told the server this member had read it.
2026-09-18 16:05:04 +08:00
Your Name b4675b70e1 docs: correct root-cause comment on avatar CORS investigation
The OSS bucket's CORS rule does allow the app's auth headers -- just
scoped to the real web/web1-5.118im.com origins. The earlier failure
was from testing against a local dev origin, never in any CORS
whitelist, not from OSS lacking CORS entirely.
2026-09-18 15:18:32 +08:00
Your Name 74f4e753ef fix: don't route already-absolute avatar URLs through authenticated fetch
Default-avatar placeholders return avatar_file_id as a complete OSS URL
rather than a short file id. The previous fix stopped re-wrapping that
URL into a malformed /v0/file/s/https://... request, but still routed
it through useAuthedBlobUrl's authenticated fetch -- live-confirmed
that OSS bucket serves default avatars with no CORS headers at all, so
the cross-origin fetch carrying auth headers gets blocked at preflight
and the tile silently falls back to the plain letter. An absolute URL
needs no credentials; render it directly as a plain <img src> instead.
2026-09-18 15:13:27 +08:00
Your Name 7fff1a7b3a fix: call media-connect state, blocklist banner staleness, group rename, avatar URL
- stores/calls.ts, CallRoot.tsx: distinguish signaling-connected from
  actual media-joined (mediaJoined flag), so a call whose mic never
  attaches no longer shows a live timer/"网络良好"; propagate a
  disconnected reason on post-accept media failure so both sides agree
  on the outcome instead of one side seeing a normal "已结束".
- DetailColumn.tsx: invalidate conversation-context alongside blocks on
  block/unblock, so the composer's blocked-notice banner updates
  immediately instead of only after a window-focus refetch.
- GroupDetailColumn.tsx: add an owner-only group rename control
  (updateGroup was already implemented but never wired to any UI).
- Avatar.tsx: stop re-wrapping an already-absolute avatar_file_id (seen
  on default-avatar placeholders) into a malformed
  /v0/file/s/https://... request.
2026-09-18 15:01:08 +08:00
41 changed files with 4269 additions and 279 deletions
+3
View File
@@ -834,3 +834,6 @@ export const getAbuseReasons = (targetType = 'user') => imGet('/reports/abuse/re
export const submitAbuseReport = body => imPost('/reports/abuse', body);
export const getFeedbackConfig = () => imGet('/feedback/config');
export const submitFeedback = body => imPost('/feedback', {client_context: clientContext(), ...body});
// -- Contact us (admin 字典 im_contact_us 透传,含邮箱/电话条目;后台修改后经缓存失效近实时生效). --
export const getContactUs = () => imGet('/contact-us');
+38 -3
View File
@@ -3,6 +3,12 @@ import React from 'react';
import { FormattedMessage } from 'react-intl';
import { APP_NAME, LINK_CONTACT_US, LINK_PRIVACY_POLICY, LINK_TERMS_OF_SERVICE } from '../config.js';
import { getContactUs } from '../lib/im-api.js';
// Admin-managed "contact us" entries (dictionary type im_contact_us): emails get mailto:, phone
// numbers get tel:, anything else renders as plain (non-navigating) text.
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const PHONE_PATTERN = /^\+?[0-9][0-9\- ]{4,}$/;
// Renders as a real link when a destination is configured, or as inert (non-navigating) text
// otherwise -- same treatment auth-onboarding-view.jsx already gives its own placeholder
@@ -14,13 +20,42 @@ function FlatLink({href, icon, children}) {
}
export default class AccSupportView extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
contactItems: []
};
}
componentDidMount() {
// Entries come from the im-server /contact-us endpoint (admin dictionary im_contact_us).
// On failure the static config fallback below stays in place; no toast for a support page.
getContactUs().then(data => {
const items = (data && Array.isArray(data.items)) ?
data.items.filter(it => it && it.label && it.value) : [];
this.setState({contactItems: items});
}).catch(() => {});
}
render() {
const {contactItems} = this.state;
return (
<div className="scrollable-panel">
<div className="panel-form-column">
<FlatLink href={LINK_CONTACT_US} icon="email">
<FormattedMessage id="link_contact_us" defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
</FlatLink>
{contactItems.length > 0 ?
contactItems.map((item, idx) => {
const value = item.value;
const href = EMAIL_PATTERN.test(value) ? 'mailto:' + value :
(PHONE_PATTERN.test(value) ? 'tel:' + value.replace(/[^\d+]/g, '') : null);
return <FlatLink key={idx} href={href} icon={href && href.startsWith('tel:') ? 'call' : 'email'}>
{item.label}
</FlatLink>;
})
:
<FlatLink href={LINK_CONTACT_US} icon="email">
<FormattedMessage id="link_contact_us" defaultMessage="Contact Us" description="Ancor text for contacting us by email" />
</FlatLink>
}
<FlatLink href={LINK_TERMS_OF_SERVICE} icon="description">
<FormattedMessage id="link_terms_of_service" defaultMessage="Terms of Service" description="Ancor text for terms of service link" />
</FlatLink>
+1965
View File
File diff suppressed because it is too large Load Diff
+27 -1
View File
@@ -1,9 +1,10 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom';
import { Navigate, createBrowserRouter, RouterProvider, useRouteError } from 'react-router-dom';
import { AppShell } from '@/app/AppShell';
import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
import { GlobalToastRoot } from '@/app/GlobalToastRoot';
import { Icon } from '@/app/Icon';
import { KickedNoticeRoot } from '@/app/KickedNoticeRoot';
import { RequireAuth } from '@/app/RequireAuth';
import { SecurityChallengeModal } from '@/app/SecurityChallengeModal';
@@ -34,6 +35,30 @@ import { SystemMessagesView } from '@/features/system/SystemMessagesView';
const queryClient = new QueryClient();
/**
* Without this, an uncaught render-time exception ANYWHERE under `<AppShell>` (any page, any one
* bad field in server data) falls through to react-router's own built-in default error boundary --
* real, not hypothetical (confirmed live: `node_modules/react-router` DOES implicitly wrap the
* outermost matched route the way this codebase never configured one to). That default shows
* "Unexpected Application Error!" plus the raw error message/stack to the end user and replaces the
* WHOLE logged-in shell, sidebar included, with no way back except a full page reload. Attached to
* AppShell (not each leaf route) so it covers every authenticated page with one component, and low
* enough that `RequireAuth`/login/tenant-entry are never affected by a bug in, say, 收藏.
*/
function RouteErrorBoundary() {
const error = useRouteError();
if (import.meta.env.DEV) console.error(error);
return (
<div className="flex h-dvh w-full flex-col items-center justify-center gap-3 bg-surface-page px-6 text-center">
<Icon name="error_outline" className="text-[40px] text-text-tertiary-3" />
<p className="text-sm text-text-secondary">页面出现异常,请刷新重试</p>
<button type="button" onClick={() => window.location.reload()} className="rounded-lg bg-accent px-4 py-2 text-sm text-white">
刷新页面
</button>
</div>
);
}
const router = createBrowserRouter([
{ path: 'tenant', element: <TenantEntryPage /> },
{ path: 'login', element: <LoginPage /> },
@@ -45,6 +70,7 @@ const router = createBrowserRouter([
children: [
{
element: <AppShell />,
errorElement: <RouteErrorBoundary />,
children: [
{ index: true, element: <Navigate to="/messages" replace /> },
{
+21 -1
View File
@@ -1,13 +1,15 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Outlet, useNavigate } from 'react-router-dom';
import { UserCardRoot } from '@/app/UserCardRoot';
import { CallRoot } from '@/features/calls/CallRoot';
import { useBootstrap } from '@/lib/api/hooks';
import { getMe } from '@/lib/api/profile';
import { updateTabIndicator } from '@/lib/notifications/tabIndicator';
import { useChatList } from '@/lib/tinode/hooks';
import { subscribeTopicInfo } from '@/lib/tinode/topics';
import { initCallSignaling } from '@/stores/calls';
import { useNotificationNavStore } from '@/stores/notificationNav';
@@ -35,6 +37,23 @@ export function AppShell() {
useEffect(() => {
initCallSignaling();
}, []);
const queryClient = useQueryClient();
// Friend-request realtime signals (Zentao #3): after a request is created/accepted/rejected the
// server pushes `{info what:"im" event:"friend.request.*"}` to the recipient's `me` topic
// (publishIMRelationEvent -> sessionStore.DeliverToUser). Nothing here used to listen for it --
// the SDK's _routeInfo hits its default case for what:"im" and only fires the onInfo chain -- so
// the 新朋友 badge/list stayed stale until a manual refresh or relogin. Subscribe once per app
// session (same shape as initCallSignaling above) and invalidate the shared ['friend-requests']
// query namespace: ContactsListColumn's pending badge (['friend-requests','inbound','pending'])
// and NewFriendsView's list (['friend-requests','all']) both refetch on invalidation. Listening
// here rather than in those views keeps the badge live no matter which page the user is on.
useEffect(() => {
return subscribeTopicInfo((_topicName, info) => {
if (info?.event?.startsWith('friend.request.')) {
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
}
});
}, [queryClient]);
// "sys" has its own authoritative unread source in entry_badges (bootstrap.ts) -- excluded here
// in case it ever also shows up as a regular Tinode subscription in `contacts`, which would
// otherwise double-count the same unread messages into this badge. "slf" gets no such exclusion:
@@ -80,6 +99,7 @@ export function AppShell() {
</div>
</div>
<CallRoot />
<UserCardRoot />
</div>
);
}
+20 -6
View File
@@ -20,14 +20,28 @@ interface AvatarProps {
* through every profile-update API in this app but never actually RENDERED anywhere -- every avatar
* call site across the whole codebase drew only the letter-tile fallback, so uploading a custom
* avatar produced zero visible change anywhere, including on the very page the upload happened.
* `avatar_file_id` is a bare file id (e.g. "abc123"), not a ref path -- reconstruct the
* `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already
* used for chat attachments) expects.
* `avatar_file_id` is USUALLY a bare file id (e.g. "abc123"), needing the `/v0/file/s/{id}` shape
* `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already used for chat
* attachments) reconstructed -- but live-confirmed (2026-09-18): default-avatar placeholders come
* back as an ALREADY-COMPLETE OSS URL in this same field (e.g.
* "https://im-oss-t1.oss-cn-shenzhen.aliyuncs.com/im-assets/default-avatars/male/4.png"). A first
* fix attempt stopped re-wrapping that URL into the malformed "/v0/file/s/https://..." shape but
* still routed it through `useAuthedBlobUrl`'s authenticated fetch, which then failed when tested
* from a local dev origin -- turned out to be a false lead: the OSS bucket's CORS rule (checked
* directly in the Aliyun console) DOES allow this app's auth headers, just scoped to the real
* `web/web1-5.118im.com` origins, which a local dev server was never going to match regardless of
* bucket config (same class of mistake this codebase has hit before testing OSS URLs from
* localhost). The actual reason to skip `useAuthedBlobUrl` for an absolute URL isn't "CORS is
* broken" -- it's that the auth headers it attaches are meaningless to OSS for a public asset in the
* first place, so the fetch-to-blob round trip (plus its preflight) is pure overhead; render the URL
* directly as a plain `<img src>` instead, same as any other publicly-loadable image.
*/
export function Avatar({ id, name, avatarFileId, className = '', fallbackBgClass }: AvatarProps) {
const remote = useAuthedBlobUrl(avatarFileId ? `/v0/file/s/${avatarFileId}` : undefined);
if (remote.url) {
return <img src={remote.url} alt="" className={`object-cover ${className}`} />;
const isAbsoluteUrl = !!avatarFileId && /^https?:\/\//.test(avatarFileId);
const remote = useAuthedBlobUrl(avatarFileId && !isAbsoluteUrl ? `/v0/file/s/${avatarFileId}` : undefined);
const resolvedUrl = isAbsoluteUrl ? avatarFileId : remote.url;
if (resolvedUrl) {
return <img src={resolvedUrl} alt="" className={`object-cover ${className}`} />;
}
return (
<span className={`grid place-items-center font-medium text-white ${fallbackBgClass || avatarBgClass(id)} ${className}`}>
+229
View File
@@ -0,0 +1,229 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { ImApiError } from '@/lib/api/client';
import { getUserProfile } from '@/lib/api/friends';
import { getMyProfile } from '@/lib/api/profile';
import { useEscapeKey } from '@/lib/hooks';
import { useCallStore } from '@/stores/calls';
import { useSessionStore } from '@/stores/session';
import { closeUserCard, useUserCardStore } from '@/stores/userCard';
const GENDER_LABEL: Record<string, string> = { male: '男', female: '女' };
/** Mounted once at the app root (App.tsx) -- renders whatever `openUserCard()` (stores/userCard.ts)
* most recently asked for. A lighter, faster-to-reach sibling of FriendProfileView's full `/contacts/
* :userId` page (same `getUserProfile` data source, same relationship-aware action gating), not a
* replacement for it -- "查看完整资料" below still routes there for remark-editing/QR code/etc. */
export function UserCardRoot() {
const userId = useUserCardStore((s) => s.userId);
if (!userId) return null;
// key={userId} so switching straight from one card to another (tapping a different @mention
// before closing the first) resets the query/local state instead of showing stale data mid-fetch.
return <UserCardPanel key={userId} userId={userId} />;
}
function UserCardPanel({ userId }: { userId: string }) {
const navigate = useNavigate();
// Registered here, not in the always-mounted UserCardRoot above: useEscapeKey's stack (lib/hooks.ts)
// orders entries by MOUNT time, so an entry registered once at app boot would sit permanently at
// the bottom, below every modal that mounts later -- a real, reproduced bug (open GroupMembersModal
// -> tap a member row to open this card on top of it -> Escape closed the member-list modal
// underneath instead of this card). UserCardPanel only mounts while a card is genuinely open, same
// as every other overlay's own useEscapeKey call, so this now stacks correctly.
useEscapeKey(closeUserCard);
const myUserId = useSessionStore((s) => s.myUserId);
const isSelf = !!myUserId && myUserId === userId;
const [addingFriend, setAddingFriend] = useState(false);
// Real, live-confirmed bug (2026-09-25 review): `GET /users/{id}/profile` 400s on a SELF lookup,
// same self-lookup gap already documented and fixed elsewhere for the group-scoped equivalent
// (getMyGroupProfile's own comment, `lib/api/groups.ts`) -- tapping your OWN avatar (reachable from
// the member grid/list like anyone else's) always landed on the "加载失败,请重试" branch, retrying
// into the same 400 forever. Routes self through the dedicated `GET /me/profile` instead, which has
// no such restriction; normalized into the same shape the render below already expects so the two
// branches don't need two separate JSX trees.
const otherQuery = useQuery({
queryKey: ['user-profile', userId],
queryFn: () => getUserProfile(userId),
enabled: !isSelf,
});
const selfQuery = useQuery({
queryKey: ['my-profile-card', userId],
queryFn: () => getMyProfile(),
enabled: isSelf,
});
const query = isSelf ? selfQuery : otherQuery;
const profile = isSelf
? selfQuery.data
? {
user_id: userId,
display_name: selfQuery.data.nickname,
avatar_file_id: selfQuery.data.avatar_file_id,
gender: selfQuery.data.gender,
region: selfQuery.data.region,
signature: selfQuery.data.signature,
public_account: selfQuery.data.public_account,
relationship: 'self',
remark: undefined as string | undefined,
capabilities: undefined as { can_send?: boolean; can_call?: boolean; can_add_friend?: boolean } | undefined,
}
: undefined
: otherQuery.data;
const name = profile ? profile.remark || profile.display_name : '';
// Real bug this fixes: this card's own doc comment always claimed "same relationship-aware action
// gating" as FriendProfileView.tsx, but until now it never read `profile.relationship` at all --
// tapping a non-friend's avatar (this card is reachable from many more places than the friends-only
// surfaces FriendProfileView is, e.g. any group member's avatar or an @mention) showed an enabled
// "发消息"/call row with no add-friend affordance anywhere, gated only by `capabilities.can_send`/
// `can_call` -- a signal ConversationView.tsx's own comment already documents as unreliable for
// exactly this purpose (it 403s from a blocked party's own side instead of reporting false). Mirrors
// FriendProfileView's own isFriend/isPendingOutbound/isPendingInbound branching exactly, just at
// this card's single (not two-size-variant) button size.
const isFriend = profile?.relationship === 'friend';
const isPendingOutbound = profile?.relationship === 'pending_outbound';
const isPendingInbound = profile?.relationship === 'pending_inbound';
const goToFullProfile = () => {
closeUserCard();
navigate(isSelf ? '/settings/profile' : `/contacts/${userId}`);
};
return (
<>
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={closeUserCard}>
<div className="w-[300px] overflow-hidden rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end px-3 pt-3">
<button
type="button"
onClick={closeUserCard}
className="grid h-7 w-7 place-items-center rounded-full text-text-tertiary-2 hover:bg-surface-input"
>
<Icon name="close" className="text-[18px]" />
</button>
</div>
{query.isLoading ? (
<div className="flex h-44 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
) : !profile ? (
<div className="flex h-44 flex-col items-center justify-center gap-2 px-6 text-center text-sm text-text-tertiary-4">
{query.error instanceof ImApiError && query.error.status === 403 ? (
'对方设置了资料保护,暂时无法查看'
) : query.error instanceof ImApiError && query.error.status === 404 ? (
'未找到该用户'
) : (
<>
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</>
)}
</div>
) : (
<div className="flex flex-col items-center px-6 pb-6">
<Avatar id={profile.user_id} name={name} avatarFileId={profile.avatar_file_id} className="h-18 w-18 flex-none rounded-2xl text-xl" />
<strong className="mt-3 max-w-full truncate text-base font-semibold text-text-heading">{name}</strong>
{profile.remark ? <span className="mt-0.5 max-w-full truncate text-[13px] text-text-tertiary-2">昵称:{profile.display_name}</span> : null}
{profile.gender || profile.region ? (
<span className="mt-0.5 text-[13px] text-text-tertiary-2">
{[profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region].filter(Boolean).join(' · ')}
</span>
) : null}
{profile.signature ? <p className="m-0 mt-2 line-clamp-3 text-center text-sm text-text-secondary">{profile.signature}</p> : null}
{!isSelf ? (
<div className="mt-5 flex w-full items-center gap-2">
{isFriend ? (
<>
<button
type="button"
disabled={profile.capabilities?.can_send === false}
onClick={() => {
closeUserCard();
navigate(`/messages/${profile.user_id}`);
}}
className="flex h-9 flex-1 items-center justify-center gap-1.5 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm disabled:opacity-50"
>
<Icon name="chat_bubble" className="text-base" />
发消息
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase !== 'idle') return;
closeUserCard();
useCallStore.getState().startCall(profile.user_id, name, 'audio');
}}
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
>
<Icon name="call" className="text-base" />
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase !== 'idle') return;
closeUserCard();
useCallStore.getState().startCall(profile.user_id, name, 'video');
}}
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50"
>
<Icon name="videocam" className="text-base" />
</button>
</>
) : isPendingOutbound ? (
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
等待验证
</button>
) : isPendingInbound ? (
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
对方已申请加你
</button>
) : profile.capabilities?.can_add_friend === false ? (
<button type="button" disabled className="h-9 flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white">
对方暂不可添加
</button>
) : (
<button
type="button"
onClick={() => setAddingFriend(true)}
className="h-9 flex-1 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm"
>
添加好友
</button>
)}
</div>
) : null}
<button type="button" onClick={goToFullProfile} className="mt-3 text-[13px] text-accent">
查看完整资料
</button>
</div>
)}
</div>
</div>
{/* A true sibling of the card's own backdrop above, not nested inside it: AddFriendModal is
itself a dismissible `fixed inset-0` overlay with its own backdrop-click-to-close handler,
and React events bubble -- nesting it inside the card's backdrop (which ALSO closes on any
click) would make clicking outside AddFriendModal close both overlays at once instead of
just the top one. */}
{addingFriend ? (
<AddFriendModal
onClose={() => setAddingFriend(false)}
initialTarget={
profile
? { user_id: profile.user_id, display_name: profile.display_name, public_account: profile.public_account, avatar_file_id: profile.avatar_file_id }
: undefined
}
/>
) : null}
</>
);
}
+21 -9
View File
@@ -127,7 +127,7 @@ function MobileIncomingCall() {
* minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never
* unmounted between full-screen and minimized). */
function MinimizedCallBar() {
const { topic, peerName, connectedAt, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
const { topic, peerName, connectedAt, mediaJoined, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
const elapsed = useElapsedSeconds(connectedAt);
return (
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
@@ -141,7 +141,10 @@ function MinimizedCallBar() {
</span>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm font-medium">{peerName}</strong>
<span className="text-xs text-success">{connectedAt ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
{/* `mediaJoined`, not bare `connectedAt` -- signaling accept flips `connectedAt` before
media has actually joined (see stores/calls.ts's own comment on this field), so this
used to claim "通话中" for a call whose mic never actually attached. */}
<span className="text-xs text-success">{connectedAt && mediaJoined ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
</span>
</button>
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'} className="grid h-8 w-8 flex-none place-items-center rounded-full bg-white/10">
@@ -165,6 +168,7 @@ function CallWindow() {
phase,
peerName,
connectedAt,
mediaJoined,
muted,
cameraOff,
hasRemoteVideo,
@@ -189,7 +193,11 @@ function CallWindow() {
if (minimized) return <MinimizedCallBar />;
const statusLabel = phase === 'connected' ? `${formatElapsed(elapsed)} · 网络良好` : '正在等待对方接听…';
// Signaling `connected` and actual media both being up are two different things (see stores/
// calls.ts's `mediaJoined` comment) -- a real "网络良好" claim needs the second one too, and the
// brief signaling-only window in between gets its own label rather than either overstating the
// connection or reusing the pre-answer ringing text.
const statusLabel = phase === 'connected' ? (mediaJoined ? `${formatElapsed(elapsed)} · 网络良好` : '正在连接中…') : '正在等待对方接听…';
// 06-calls.dc.html #1b (呼出等待中) labels this same button "取消", matching the protocol doc's own
// "发起方取消发送 hang-up:cancelled" wording for the pre-connect case -- it was hardcoded to "挂断"
// at all 3 call sites below regardless of phase, which is only actually correct once connected.
@@ -241,9 +249,13 @@ function CallWindow() {
<strong className="text-base font-semibold">{peerName}</strong>
<span className="flex items-center gap-1 text-xs text-white/70">
{phase === 'connected' ? (
<>
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
</>
mediaJoined ? (
<>
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
</>
) : (
'正在连接中…'
)
) : (
'正在等待对方接听…'
)}
@@ -294,7 +306,7 @@ function CallWindow() {
>
{avatarLetter(peerName)}
</span>
<span className="text-sm text-white/70">{phase === 'connected' ? '对方已关闭摄像头' : ''}</span>
<span className="text-sm text-white/70">{phase === 'connected' && mediaJoined ? '对方已关闭摄像头' : ''}</span>
</div>
</div>
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
@@ -307,12 +319,12 @@ function CallWindow() {
</span>
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
<span className="mt-1 text-sm text-white/70">
{phase === 'connected' ? formatElapsed(elapsed) : '正在等待对方接听…'}
{phase === 'connected' ? (mediaJoined ? formatElapsed(elapsed) : '正在连接中…') : '正在等待对方接听…'}
</span>
{/* Desktop's version of this same fallback state shows this explanation (line 285
above); mobile silently dropped it, showing only the elapsed-time line with no
indication of WHY there's no video. */}
{phase === 'connected' ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
{phase === 'connected' && mediaJoined ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
</div>
</>
)}
+13 -1
View File
@@ -144,7 +144,19 @@ function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite
// Only show a second line when it says something the title line doesn't already -- a
// hostname alongside a real title/url is useful, but repeating `text` under itself when
// that's ALSO what the title line fell back to would just be the same line twice.
const subtitle = item.snapshot.url ? new URL(item.snapshot.url).hostname : title === text ? '' : text;
// `new URL()` throws on anything that isn't a well-formed absolute URL -- `url` can come from
// another client's own Drafty implementation or the backend's own extraction logic (docs/
// IM-前后端对接文档.md §7.6), neither of which this codebase controls the shape of. A single
// malformed value here must not crash the whole render (there's no error boundary around this
// list short of react-router's own default one, which would replace the entire logged-in UI).
let subtitle = title === text ? '' : text;
if (item.snapshot.url) {
try {
subtitle = new URL(item.snapshot.url).hostname;
} catch {
// fall through to the plain-text fallback computed above
}
}
return (
<div className="flex items-start gap-3">
{item.snapshot.thumbnail_file_id ? (
@@ -1,10 +1,10 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { getAllGroupMembers, getLatestGroupAnnouncement, updateCurrentGroupAnnouncement, type GroupRole } from '@/lib/api/groups';
import { getAllGroupMembers, getLatestGroupAnnouncement, readGroupAnnouncement, updateCurrentGroupAnnouncement, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useEscapeKey } from '@/lib/hooks';
@@ -24,12 +24,15 @@ interface GroupAnnouncementModalProps {
* corrections from real responses (2026-09-08, real group `grpvrdHqKqkCq0`): the publisher comes
* back as a flat `author: "usr..."` id, not the `published_by` object this file originally guessed
* -- resolved here against the group's member list, same as any other bare user id in this API.
* And `read_count`/`member_count` are confirmed ABSENT (not just unconfirmed), so dropped rather
* than kept as permanently-dead optional-chained code. When nothing has been published yet, the
* endpoint still returns `200` with every field zero-valued, never `404`/`null` -- checked below
* via `content` emptiness, not object existence (a real bug this file had before this fix: `!query.
* data` was never true, so it rendered a blank "announcement" -- empty name, empty date -- instead
* of the empty state).
* When nothing has been published yet, the endpoint still returns `200` with every field
* zero-valued, never `404`/`null` -- checked below via `content` emptiness, not object existence (a
* real bug this file had before this fix: `!query.data` was never true, so it rendered a blank
* "announcement" -- empty name, empty date -- instead of the empty state).
* `read_count`/`member_count` were confirmed ABSENT on 2026-09-08; the 2026-09-18 backend deploy
* added them (see groups.ts's own comment on `GroupAnnouncement`), so this now renders "已读N/M" as
* README §4.8 originally called for, and calls `readGroupAnnouncement` on open -- that REST endpoint
* existed in groups.ts since before this fix but was never actually called from anywhere, so opening
* an announcement never told the server "this member has read it" at all.
*/
export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnouncementModalProps) {
const queryClient = useQueryClient();
@@ -42,6 +45,22 @@ export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnou
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const author = membersQuery.data?.find((m) => m.user_id === query.data?.author);
// Fire-and-forget: viewing the announcement IS "reading" it, matching how the P2P/group message
// read receipt (native `{note what:"read"}`) already works elsewhere in this app -- no explicit
// "mark as read" button anywhere in the mock. Guarded on a real, non-empty `announcement_id` so
// this doesn't fire for the zero-valued "nothing published yet" response, and keyed on that id (not
// just "modal opened") so re-opening the SAME announcement doesn't re-send needlessly, though the
// endpoint's own idempotency key means it would be harmless either way.
const announcementId = query.data?.announcement_id;
useEffect(() => {
if (!announcementId) return;
// Invalidate (not just fire-and-forget) so THIS viewer's own read immediately bumps the
// displayed count instead of showing a stale pre-read number until some unrelated refetch.
readGroupAnnouncement(topicId, announcementId)
.then(() => queryClient.invalidateQueries({ queryKey: ['group-announcement', topicId] }))
.catch(() => {});
}, [topicId, announcementId, queryClient]);
const saveMutation = useMutation({
mutationFn: (content: string) => updateCurrentGroupAnnouncement(topicId, content, query.data?.version ?? 0),
onSuccess: (data) => {
@@ -120,6 +139,19 @@ export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnou
<span className="text-xs text-text-tertiary-3">发布于 {formatEventTimestamp(new Date(query.data.published_at))}</span>
</div>
<p className="m-0 mt-3 whitespace-pre-wrap text-sm leading-[1.7] text-text-body">{query.data.content}</p>
{/* README §4.8. Both the flat fields and `read_summary` carry the same two numbers
(confirmed reading the backend's own payload builder) -- prefer the flat ones,
fall back to the nested object in case a future response only sends one shape. */}
{(() => {
const readCount = query.data.read_count ?? query.data.read_summary?.read_count;
const memberCount = query.data.member_count ?? query.data.read_summary?.member_count;
if (readCount == null || memberCount == null) return null;
return (
<p className="m-0 mt-2 text-xs text-text-tertiary-3">
已读 {readCount}/{memberCount}
</p>
);
})()}
</>
)}
</div>
+120 -5
View File
@@ -12,6 +12,7 @@ import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
import {
canRemoveMember,
disbandGroup,
getGroup,
getMyGroupProfile,
@@ -19,18 +20,22 @@ import {
getGroupSettings,
getGroupSummary,
leaveGroup,
updateGroup,
updateGroupMemberSettings,
updateMyGroupNickname,
type GroupMember,
type GroupProfile,
type GroupSettings,
} from '@/lib/api/groups';
import type { MediaType } from '@/lib/api/messages';
import { MAX_TITLE_LENGTH } from '@/lib/config';
import { useTinode } from '@/lib/tinode/hooks';
import { forgetTopic } from '@/lib/tinode/topics';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session';
import { openUserCard } from '@/stores/userCard';
interface GroupDetailColumnProps {
topicId: string;
@@ -68,6 +73,7 @@ function RoleCorner({ role }: { role: GroupMember['role'] }) {
return null;
}
/**
* README §4.4's 群聊设置 detail rail (group-owner/admin/member view). Sibling of DetailColumn.tsx
* (the P2P equivalent) -- same layout conventions, but group-specific data source (lib/api/groups.ts,
@@ -81,6 +87,13 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
const myUserId = useSessionStore((s) => s.myUserId);
const [nicknameEditing, setNicknameEditing] = useState(false);
const [nicknameDraft, setNicknameDraft] = useState('');
// `updateGroup` (lib/api/groups.ts) has always supported renaming a group -- confirmed live no UI
// anywhere ever called it (grep across the whole repo: zero call sites), so a group's name was
// fixed forever at whatever was typed into CreateGroupModal's input at creation time, with no way
// to fix a typo or rename it later. Owner-only, matching this file's other high-consequence group-
// level (as opposed to per-member) actions.
const [nameEditing, setNameEditing] = useState(false);
const [nameDraft, setNameDraft] = useState('');
const [errorToast, setErrorToast] = useState('');
const [openModal, setOpenModal] = useState<'members' | 'invite' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
@@ -90,6 +103,8 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
useLayoutEffect(() => {
setNicknameEditing(false);
setNicknameDraft('');
setNameEditing(false);
setNameDraft('');
setErrorToast('');
setOpenModal(null);
}, [topicId]);
@@ -152,6 +167,25 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
onError: (err) => showError(err, '昵称保存失败'),
});
const nameMutation = useMutation({
mutationFn: (name: string) => updateGroup(topicId, { name, version: groupQuery.data?.version ?? 1 }),
// Merge, not replace -- same defensive reasoning as updateSettings above, in case this endpoint
// ever turns out to return a partial object like member-settings did.
onSuccess: (data) => {
queryClient.setQueryData(['group', topicId], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data));
setNameEditing(false);
// The renamer's OWN sidebar/header (`contact.public?.fn`, native SDK state) doesn't update from
// this REST call either -- only from the `group.settings.updated` push topics.ts listens for,
// which this account won't receive for its own action. `data` here is already the fresh REST
// response, so apply it locally via `_processMetaDesc` (live-confirmed to reach `me.contacts()`,
// see topics.ts's comment on the same call) instead of an extra `getMeta(...withDesc())` round
// trip -- that SDK-sanctioned path was live-instrumented and found to reliably time out (504) or
// return stale data against this backend, so it's not used anywhere in this fix anymore.
tinode.getTopic(topicId)._processMetaDesc({ public: { fn: data.name, name: data.name } });
},
onError: (err) => showError(err, '群名保存失败'),
});
// Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting
// on the async native/`group.dissolved` gone-signal that normally follows a group actually going
// away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale
@@ -180,7 +214,16 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
});
const clearHistory = async () => {
if (!(await requestConfirm({ message: '确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。', danger: true }))) return;
// See DetailColumn.tsx's identical clearHistory for why this copy changed: a soft delete
// (`delMessagesAll(false)`) is a real, per-account server watermark (im_be's Topic.replyDelMsg),
// not a local-only cache wipe a refresh or another device would undo.
if (
!(await requestConfirm({
message: '确定清空本群的聊天记录吗?清空后,你的账号(含其他设备)将无法再看到这些消息,且无法恢复;不影响其他成员。',
danger: true,
}))
)
return;
try {
await tinode.getTopic(topicId).delMessagesAll(false);
} catch (err) {
@@ -209,6 +252,14 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
const members = membersQuery.data?.items || [];
const memberCount = groupQuery.data?.member_count ?? settingsQuery.data?.member_count ?? 0;
// Real gap this fixes: `isAdmin` alone gates the "-移出" tile below, but canRemoveMember (the
// per-row check the tile's own destination list uses) is strictly finer -- an admin can't remove
// the owner or another admin, only the owner can remove an admin. A group with no plain members
// (e.g. [owner, admin(me), admin(other)]) would show the tile yet land on a list with zero "移出"
// buttons anywhere, a dead end. Checked against the same preview page the compact grid above
// already renders from (not exhaustive for a group with more members than that page holds, but
// that's the same tradeoff this component's own member grid already makes, not a new one).
const canRemoveAnyone = isAdmin && members.some((m) => canRemoveMember(myUserId, myRole || 'member', m));
return (
<aside className="flex h-full w-full flex-none flex-col overflow-hidden border-l border-border-panel bg-white md:w-[320px] md:overflow-y-auto lg:w-[300px]">
@@ -259,8 +310,49 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
avatarFileId={groupQuery.data?.avatar_file_id}
className="h-13 w-13 flex-none rounded-xl text-xl"
/>
<div className="flex min-w-0 flex-col">
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
<div className="flex min-w-0 flex-1 flex-col">
{nameEditing ? (
<div className="flex items-center gap-2">
<input
autoFocus
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value.slice(0, MAX_TITLE_LENGTH))}
onKeyDown={(e) => e.key === 'Enter' && nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
/>
<button
type="button"
onClick={() => nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
disabled={!nameDraft.trim() || nameMutation.isPending}
className="flex-none text-sm text-accent disabled:opacity-40"
>
保存
</button>
<button type="button" onClick={() => setNameEditing(false)} className="flex-none text-sm text-text-tertiary-2">
取消
</button>
</div>
) : (
<span className="flex min-w-0 items-center gap-1">
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
{/* Owner-only -- renaming a group is a group-level action, not a per-member one
like the 我在本群的昵称 row further down, so this follows this file's other
owner-gated actions (转让群主/解散群聊) rather than opening it to admins too. */}
{isOwner ? (
<button
type="button"
aria-label="修改群名"
onClick={() => {
setNameDraft(groupQuery.data?.name || '');
setNameEditing(true);
}}
className="flex-none text-text-tertiary-3"
>
<Icon name="edit" className="text-[16px]" />
</button>
) : null}
</span>
)}
<span className="text-xs text-text-tertiary-2">
群号 {topicId} · {memberCount} 人
</span>
@@ -290,7 +382,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
</button>
<div className="grid grid-cols-5 gap-2 md:mt-2">
{members.slice(0, 9).map((m) => (
<div key={m.user_id} className="relative flex flex-col items-center gap-1">
<button
key={m.user_id}
type="button"
onClick={() => openUserCard(m.user_id)}
className="relative flex flex-col items-center gap-1"
>
{/* README §4.10/#1e: mobile member-grid avatars are 44px, one size up from
desktop's 40px (unconditional here before this fix). */}
<Avatar
@@ -301,7 +398,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
/>
<RoleCorner role={m.role} />
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
</div>
</button>
))}
{canInvite ? (
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
@@ -317,6 +414,22 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
<span className="text-[11px] text-text-secondary">邀请</span>
</button>
) : null}
{canRemoveAnyone ? (
// Sibling tile to "+邀请" right above -- same dashed-square treatment, mirrored icon.
// A single standalone entry point into the plain member list (not a per-avatar corner
// badge, and not a direct remove action) -- the actual "移出" control already lives on
// each removable row inside that list. Explicit user correction after a first attempt
// put a "-" badge on every removable avatar's own corner: that read as "many separate
// remove buttons" when the intent was one "+"-shaped entry point, mirroring "+邀请".
// Gated on canRemoveAnyone (not bare isAdmin) so the tile doesn't show in a group made
// entirely of owner+admins with nobody actually removable -- a dead end otherwise.
<button type="button" onClick={() => setOpenModal('members')} className="flex flex-col items-center gap-1">
<span className="grid h-11 w-11 place-items-center rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3 md:h-10 md:w-10">
<Icon name="remove" className="text-[18px]" />
</span>
<span className="text-[11px] text-text-secondary">移出</span>
</button>
) : null}
</div>
<button type="button" onClick={() => setOpenModal('members')} className="mt-2 flex justify-center py-1 text-[13px] text-accent md:hidden">
查看全部成员 ({memberCount})
@@ -450,6 +563,8 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
<GroupMembersModal
topicId={topicId}
canInvite={canInvite}
myUserId={myUserId}
myRole={myRole || 'member'}
initialView={openModal === 'invite' ? 'invite' : 'members'}
onClose={() => setOpenModal(null)}
/>
+7 -1
View File
@@ -219,7 +219,13 @@ export function GroupFilesModal({ topicId, isAdmin, canUpload, onClose }: GroupF
isPdf={pdf}
open={openMenuId === f.group_file_id}
onToggle={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : f.group_file_id))}
onClose={() => setOpenMenuId(null)}
// Functional update, not a plain `setOpenMenuId(null)` -- this fires from
// useClickOutside's window-level listener, which runs AFTER another row's own
// onToggle already ran in the same click (React's synthetic onToggle always
// precedes a window listener in the bubble order). An unconditional null would
// clobber that other row's freshly-opened state; only clear if this row is
// still the one actually open.
onClose={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : cur))}
onDownload={() => downloadFile(f)}
onOpen={() => openFileInNewTab(f)}
onDelete={async () => {
+64 -9
View File
@@ -6,9 +6,11 @@ import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends';
import { getAllGroupMembers, inviteGroupMembers, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
import { canRemoveMember, getAllGroupMembers, inviteGroupMembers, removeGroupMember, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { requestConfirm } from '@/stores/confirmDialog';
import { openUserCard } from '@/stores/userCard';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
// README §4.3: role tags are a BORDER style, no fill -- matches MessageBubble.tsx's already-correct
@@ -28,6 +30,10 @@ interface GroupMembersModalProps {
* from the same settings query) rather than re-deriving from a bare role here, which is exactly
* what silently made that governance toggle a no-op before this was wired through. */
canInvite: boolean;
/** Own user id + role -- drives per-row remove-member eligibility (canRemoveMember, lib/api/groups.ts),
* mirroring the backend's own canRemoveIMGroupMember rule exactly rather than re-guessing it client-side. */
myUserId: string | null;
myRole: GroupRole;
/** "invite" opens straight into the invite-candidates view, skipping the member list entirely --
* the dedicated "邀请" entry point (GroupDetailColumn's dashed-border tile) used to route through
* here too but land on the member list regardless, requiring a SECOND click on an in-modal "邀请"
@@ -40,9 +46,10 @@ interface GroupMembersModalProps {
/** README §4.4's "查看全部成员" -- full member list, plus an invite flow backed by
* `inviteGroupMembers` (README §4.7's create/invite modal covers the same endpoint for adding to
* an existing group, not just at creation time). No "移出成员" affordance here: this protocol has
* no documented REST or WS operation for removing a group member at all (grepped the whole
* protocol doc) -- a real backend gap, not a client oversight, so this doesn't pretend to offer it. */
* an existing group, not just at creation time). "移出成员" (removeGroupMember) used to have no
* documented REST or WS operation at all -- confirmed live 2026-09-23 the backend has since added
* `DELETE /groups/{topic}/members/{user_id}` (see removeGroupMember's own comment), so this is no
* longer a backend gap. */
/** Per-candidate outcome once invited THIS session -- keyed by user_id, separate from `alreadyMember`
* (a person can be successfully invited without becoming a member yet -- see the "pending"/
* "pending_approval" case below). No REST endpoint exists to list a group's outstanding invitations
@@ -58,12 +65,18 @@ const OUTCOME_LABEL: Record<InviteOutcome, string> = {
failed: '邀请失败',
};
export function GroupMembersModal({ topicId, canInvite, initialView = 'members', onClose }: GroupMembersModalProps) {
export function GroupMembersModal({ topicId, canInvite, myUserId, myRole, initialView = 'members', onClose }: GroupMembersModalProps) {
const queryClient = useQueryClient();
const [query, setQuery] = useState('');
const [inviting, setInviting] = useState(initialView === 'invite');
const [error, setError] = useState('');
const [outcomeByUser, setOutcomeByUser] = useState<Record<string, InviteOutcome>>({});
// Per-row "移出中" tracking -- `removeMutation` is one shared mutation instance, so its own
// `.variables`/`.isPending` only ever reflects the MOST RECENT call. Removing member A then quickly
// also removing member B would make A's button-disabled/"移出中…" state resolve back to normal the
// moment B's click fires, even though A's own request may still be in flight.
const [removingIds, setRemovingIds] = useState<Set<string>>(new Set());
useEscapeKey(onClose);
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
@@ -100,6 +113,16 @@ export function GroupMembersModal({ topicId, canInvite, initialView = 'members',
onError: (err) => setError(err instanceof Error ? err.message : '邀请失败'),
});
const removeMutation = useMutation({
mutationFn: (userId: string) => removeGroupMember(topicId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
},
onError: (err) => setError(err instanceof Error ? err.message : '移出失败'),
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
@@ -173,11 +196,43 @@ export function GroupMembersModal({ topicId, canInvite, initialView = 'members',
) : (
filtered.map((m) => (
<div key={m.user_id} className="flex w-full items-center gap-3 rounded-lg px-3 py-2">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
</span>
<span className="flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
<button type="button" onClick={() => openUserCard(m.user_id)} className="flex min-w-0 flex-1 items-center gap-3 text-left">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
</button>
<RoleTag role={m.role} />
{canRemoveMember(myUserId, myRole, m) ? (
<button
type="button"
disabled={removingIds.has(m.user_id)}
onClick={async () => {
const label = m.display_name || m.user_id;
if (await requestConfirm({ message: `确定将"${label}"移出群聊吗?`, danger: true })) {
setRemovingIds((prev) => new Set(prev).add(m.user_id));
removeMutation.mutate(m.user_id, {
onSettled: () =>
setRemovingIds((prev) => {
const next = new Set(prev);
next.delete(m.user_id);
return next;
}),
});
}
}}
className="flex flex-none cursor-pointer items-center gap-0.5 text-xs text-danger-text hover:underline disabled:pointer-events-none disabled:cursor-default disabled:opacity-60 disabled:hover:no-underline"
>
{removingIds.has(m.user_id) ? (
'移出中…'
) : (
<>
<Icon name="remove_circle" className="text-sm" />
移出
</>
)}
</button>
) : null}
</div>
))
)}
+192 -13
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import { useAuthedBlobUrl } from '@/lib/hooks';
import { useAuthedBlobUrl, useEscapeKey } from '@/lib/hooks';
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
interface ImageData {
@@ -10,12 +10,156 @@ interface ImageData {
mime?: string;
width?: number;
height?: number;
/** Drafty.insertImage's own `filename` field -- present on every image this WEB client sent
* (see sendImage in lib/tinode/attachments.ts). */
filename?: string;
/** mobileAttachmentContentOf's `name` -- the equivalent field on an image sent from the iOS/Android
* app, which flows through a completely different (non-Drafty) flat shape than the web client's
* own `IM` entity above; MessageBubble passes that content straight through as this same ImageData
* shape, so both need a name field here even though the two clients don't call it the same thing. */
name?: string;
}
export function ImageAttachment({ data }: { data: ImageData }) {
/** Best-effort filename for the "保存图片" download below -- prefers whatever name the sender's own
* client attached (see ImageData's own field comments), falling back to a generic name derived from
* the mime type for the inline-base64/mobile-relay cases where neither field is set. */
function imageDownloadName(data: ImageData): string {
if (data.filename) return data.filename;
if (data.name) return data.name;
const ext = data.mime?.split('/')[1]?.replace('jpeg', 'jpg') || 'jpg';
return `image.${ext}`;
}
const LIGHTBOX_ZOOM_MIN = 1;
const LIGHTBOX_ZOOM_MAX = 4;
const LIGHTBOX_ZOOM_STEP = 0.5;
/** Full-screen "查看大图" overlay opened by tapping a chat image -- `src` is the SAME blob/data URI
* ImageAttachment already resolved (real full-resolution image; the 260x170 thumbnail is only a CSS
* crop of it, not a separately-fetched smaller asset), so opening this needs no extra network work.
* Zoom is CSS transform scale/translate on the `<img>` itself, not a native browser zoom -- wheel
* and the +/- buttons zoom around the center; dragging only does anything once zoomed in, otherwise
* the image has nowhere to pan to and a drag gesture would just fight the backdrop's click-to-close. */
function ImageLightbox({ src, filename, onClose }: { src: string; filename: string; onClose: () => void }) {
const [zoom, setZoom] = useState(LIGHTBOX_ZOOM_MIN);
const [pan, setPan] = useState({ x: 0, y: 0 });
const imgRef = useRef<HTMLImageElement>(null);
const dragRef = useRef<{ startX: number; startY: number; panX: number; panY: number } | null>(null);
useEscapeKey(onClose);
const clampZoom = (z: number) => Math.min(LIGHTBOX_ZOOM_MAX, Math.max(LIGHTBOX_ZOOM_MIN, z));
const applyZoom = (next: number) => {
const clamped = clampZoom(next);
setZoom(clamped);
if (clamped === LIGHTBOX_ZOOM_MIN) setPan({ x: 0, y: 0 });
};
const zoomIn = () => applyZoom(zoom + LIGHTBOX_ZOOM_STEP);
const zoomOut = () => applyZoom(zoom - LIGHTBOX_ZOOM_STEP);
const toggleZoom = () => applyZoom(zoom > LIGHTBOX_ZOOM_MIN ? LIGHTBOX_ZOOM_MIN : LIGHTBOX_ZOOM_MAX);
// Native listener with `passive:false`, not JSX `onWheel` -- React's synthetic wheel handler isn't
// guaranteed non-passive across versions/browsers, and a passive listener's preventDefault() is
// silently ignored, which would leave the page scrolling underneath the lightbox while "zooming".
useEffect(() => {
const el = imgRef.current;
if (!el) return undefined;
const onWheel = (e: WheelEvent) => {
e.preventDefault();
applyZoom(zoom + (e.deltaY < 0 ? LIGHTBOX_ZOOM_STEP : -LIGHTBOX_ZOOM_STEP));
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-binds each render so onWheel always
// closes over the current `zoom`; the listener itself is cheap to re-add and this avoids a stale
// closure that would zoom from an outdated level.
}, [zoom]);
const onMouseDown = (e: React.MouseEvent) => {
if (zoom <= LIGHTBOX_ZOOM_MIN) return;
e.stopPropagation();
dragRef.current = { startX: e.clientX, startY: e.clientY, panX: pan.x, panY: pan.y };
};
const onMouseMove = (e: React.MouseEvent) => {
if (!dragRef.current) return;
setPan({ x: dragRef.current.panX + (e.clientX - dragRef.current.startX), y: dragRef.current.panY + (e.clientY - dragRef.current.startY) });
};
const endDrag = () => {
dragRef.current = null;
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/80" onClick={onClose}>
<div className="absolute right-6 top-6 flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={zoomOut}
disabled={zoom <= LIGHTBOX_ZOOM_MIN}
aria-label="缩小"
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white disabled:opacity-30"
>
<Icon name="zoom_out" className="text-[20px]" />
</button>
<span className="min-w-[3.5em] text-center text-sm text-white">{Math.round(zoom * 100)}%</span>
<button
type="button"
onClick={zoomIn}
disabled={zoom >= LIGHTBOX_ZOOM_MAX}
aria-label="放大"
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white disabled:opacity-30"
>
<Icon name="zoom_in" className="text-[20px]" />
</button>
{/* Real anchor + `download`, same technique FileAttachment already relies on below -- works
for both inline `data:` URIs (small images) and the `blob:` URL useAuthedBlobUrl fetches
for larger ones, triggering a normal save-to-disk with no extra round trip. User report
(老板, 2026-09-29): "图片也没有保存功能" -- right-click "另存为" technically already works on
an `<img>`, but wasn't discoverable enough; this puts an explicit, visible affordance here,
same place every mainstream IM's own "查看大图" view puts it. */}
<a
href={src}
download={filename}
aria-label="保存图片"
className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white"
>
<Icon name="download" className="text-[20px]" />
</a>
<button type="button" onClick={onClose} aria-label="关闭" className="grid h-10 w-10 place-items-center rounded-full bg-white/10 text-white">
<Icon name="close" className="text-[20px]" />
</button>
</div>
{/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions -- pan/zoom drag
target inside an already-dismissible dialog; alt="" is correct here, same reasoning as
ImageAttachment's own thumbnail (chat image content has no meaningful alt text to give it). */}
<img
ref={imgRef}
src={src}
alt=""
onClick={(e) => e.stopPropagation()}
onDoubleClick={(e) => {
e.stopPropagation();
toggleZoom();
}}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={endDrag}
onMouseLeave={endDrag}
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
className={`max-h-[85vh] max-w-[85vw] select-none rounded object-contain ${zoom > LIGHTBOX_ZOOM_MIN ? 'cursor-grab active:cursor-grabbing' : ''}`}
/>
</div>
);
}
/** `locked` (MessageBubble's selectMode): a tap must only toggle the row's own selection, same
* reasoning as QuoteBar/the @mention buttons going inert during bulk-select. Renders the same
* thumbnail either way -- only the click behavior (and the lightbox it would open) is disabled, so
* entering select mode doesn't visually change every image bubble in the list. */
export function ImageAttachment({ data, locked }: { data: ImageData; locked?: boolean }) {
const inline = typeof data.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
const src = inline || remote.url;
const [viewing, setViewing] = useState(false);
if (!src) {
return (
@@ -25,7 +169,16 @@ export function ImageAttachment({ data }: { data: ImageData }) {
);
}
// README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box.
return <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
const thumb = <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
if (locked) return thumb;
return (
<>
<button type="button" onClick={() => setViewing(true)} className="block">
{thumb}
</button>
{viewing ? <ImageLightbox src={src} filename={imageDownloadName(data)} onClose={() => setViewing(false)} /> : null}
</>
);
}
interface FileData {
@@ -36,7 +189,11 @@ interface FileData {
size?: number;
}
export function FileAttachment({ data }: { data: FileData }) {
/** `locked`: see ImageAttachment's own comment -- a tap during selectMode must only toggle the row,
* not start a download (which `preventDefault` blocks for an ordinary click; middle-click/"open in
* new tab" aren't specially guarded against, matching this codebase's existing level of rigor for
* other locked-during-selectMode controls). */
export function FileAttachment({ data, locked }: { data: FileData; locked?: boolean }) {
const style = fileTypeStyle(data.name, data.mime);
const remote = useAuthedBlobUrl(typeof data.val === 'string' ? undefined : data.ref);
const inline = typeof data.val === 'string' ? `data:${data.mime || 'application/octet-stream'};base64,${data.val}` : null;
@@ -47,7 +204,9 @@ export function FileAttachment({ data }: { data: FileData }) {
href={href}
download={data.name}
onClick={(e) => {
if (remote.error) {
if (locked) {
e.preventDefault();
} else if (remote.error) {
e.preventDefault();
remote.retry();
} else if (!href) {
@@ -79,6 +238,7 @@ interface VideoData {
ref?: string;
mime?: string;
preview?: string;
previewRef?: string;
duration?: number;
}
@@ -92,12 +252,23 @@ function formatDuration(ms: number | undefined): string {
* favorites' identical `VD` entity shape, docs/design_handoff plan file Part D) -- not paintable as
* an `<img>`, so the static poster comes from the separate inline `preview` field (a real JPEG,
* confirmed by its base64 signature) instead. Tapping swaps the poster for a real `<video>` playing
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality. */
export function VideoAttachment({ data }: { data: VideoData }) {
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality.
*
* `locked`: see ImageAttachment's own comment -- gated in `onClick`, not folded into `disabled`,
* since a disabled <button> never dispatches `click` at all and would swallow the row-toggle this
* click is supposed to bubble up to (unlike FileAttachment's plain anchor, this is a real button). */
export function VideoAttachment({ data, locked }: { data: VideoData; locked?: boolean }) {
const [playing, setPlaying] = useState(false);
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
const videoInline = typeof data.val === 'string' ? `data:${data.mime || 'video/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(videoInline ? undefined : data.ref);
// Mobile-sent videos (see mobileAttachmentContentOf) have no inline base64 poster at all -- their
// thumbnail is a separately-hosted file (`previewRef`, the same `/v0/file/s/{id}` shape as the
// video body itself), not the base64 `preview` field this component was built around for the web
// client's own Drafty `VD` entities (confirmed live, favorites' identical shape). Only fetched when
// there's no inline poster, same short-circuit `remote` above already uses for the video body.
const posterRemote = useAuthedBlobUrl(posterInline ? undefined : data.previewRef);
const posterUrl = posterInline || posterRemote.url;
const videoSrc = videoInline || remote.url;
if (playing && videoSrc) {
@@ -107,15 +278,18 @@ export function VideoAttachment({ data }: { data: VideoData }) {
return (
<button
type="button"
onClick={() => setPlaying(true)}
onClick={() => {
if (locked) return;
setPlaying(true);
}}
disabled={!videoSrc}
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
>
{posterInline ? (
<img src={posterInline} alt="" className="h-full w-full object-cover" />
{posterUrl ? (
<img src={posterUrl} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center">
<Icon name={remote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
<Icon name={posterRemote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
</div>
)}
{videoSrc ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
@@ -133,7 +307,11 @@ interface AudioData {
duration?: number;
}
export function AudioAttachment({ data }: { data: AudioData }) {
/** `locked`: see VideoAttachment's own comment -- gated in `toggle()`, not folded into `disabled`,
* so the click still bubbles to the row toggle instead of being swallowed by the native button.
* Ongoing playback started BEFORE selectMode was entered is left alone -- this only blocks a NEW
* toggle action. */
export function AudioAttachment({ data, locked }: { data: AudioData; locked?: boolean }) {
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
const src = inline || remote.url;
@@ -154,6 +332,7 @@ export function AudioAttachment({ data }: { data: AudioData }) {
}, [audioEl]);
const toggle = () => {
if (locked) return;
if (!audioEl || !src) return;
if (playing) {
audioEl.pause();
+182 -41
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQueries, useQuery } from '@tanstack/react-query';
import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom';
import type { StoredMessage } from 'tinode-sdk';
import { Drafty } from 'tinode-sdk';
@@ -17,20 +17,21 @@ import { VoiceRecorder } from '@/features/messages/VoiceRecorder';
import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
import { useBootstrap } from '@/lib/api/hooks';
import { favoriteMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages';
import { favoriteMessages, forwardMessages, getTopicDraft, type MediaType, putTopicDraft, recallMessage } from '@/lib/api/messages';
import { getAllGroupMembers, getAllGroupMutes, getGroup, getGroupGovernance, getGroupSummary } from '@/lib/api/groups';
import { getConversationContext, getUserProfile } from '@/lib/api/friends';
import { getMe } from '@/lib/api/profile';
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
import { useClickOutside } from '@/lib/hooks';
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
import { isRecalled } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { availableActions, isConfirmedSeq, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
import { flatTextContentOf, isRecalled } from '@/lib/messages/dispatch';
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
import { buildReplyInfo, replySnapshotText } from '@/lib/messages/reply';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { isTopicGone } from '@/lib/tinode/errors';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
import { sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
import {
closeConversation,
ensureTopicAttached,
@@ -541,6 +542,38 @@ export function ConversationView() {
enabled: isGroup,
});
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
// `memberByUserId` only ever holds CURRENT members (`getAllGroupMembers`) -- a historical message
// from someone who has since left/been removed has no entry there, and used to fall all the way
// back to the raw `msg.from` uid string in the bubble (confirmed live, 2026-09-20: a real message
// from a member who then left the group rendered as "usrp77kUAkgqpU" instead of "systest02").
// Fetching each such uid's standalone profile (same `getUserProfile`/`['user-profile', id]` P2P
// already uses above) recovers a real display name/avatar in the common case; `retry:false` and a
// swallowed error mean a stranger this account has no visibility into (403, same as the already-
// documented `usrW9ijv236hHA` case) just falls back to the uid exactly as before -- never worse.
const staleSenderIds = useMemo(() => {
if (!isGroup) return [] as string[];
const ids = new Set<string>();
for (const msg of messages) {
if (msg.from && !memberByUserId.has(msg.from)) ids.add(msg.from);
}
return Array.from(ids);
}, [isGroup, messages, memberByUserId]);
const staleSenderQueries = useQueries({
queries: staleSenderIds.map((uid) => ({
queryKey: ['user-profile', uid],
queryFn: () => getUserProfile(uid),
staleTime: Infinity,
retry: false,
})),
});
const staleSenderById = useMemo(() => {
const map = new Map<string, { display_name: string; avatar_file_id?: string }>();
staleSenderIds.forEach((uid, i) => {
const data = staleSenderQueries[i]?.data;
if (data) map.set(uid, data);
});
return map;
}, [staleSenderIds, staleSenderQueries]);
const myMember = myUserId ? memberByUserId.get(myUserId) : undefined;
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
const groupSublineParts = [
@@ -568,10 +601,22 @@ export function ConversationView() {
// second, divergent resolution path.
const quotedSenderLabel = (fromUserId: string | undefined) => {
if (myUserId && fromUserId === myUserId) return '我';
if (isGroup && fromUserId) return memberByUserId.get(fromUserId)?.display_name || fromUserId;
if (isGroup && fromUserId) {
return groupSenderLabel(memberByUserId.get(fromUserId)?.display_name, staleSenderById.get(fromUserId)?.display_name);
}
return peerName;
};
/** Arms a media send with the pending quote and clears the composer's reply state in one step:
* the quote applies to the NEXT outgoing message only (cross-client rule), and a picked image
* or recorded voice is that message. Returns null when nothing is armed or the quoted message
* has become unquotable in the meantime (send proceeds as a plain media message). */
const consumePendingReply = () => {
const pending = replyTo ? buildReplyInfo(replyTo) : null;
setReplyTo(null);
return pending;
};
/**
* "加载更多" (README §3.2/old app's messages-view.jsx): not simple since/before paging -- computes
* which seq ranges are actually missing from the local cache within the currently-visible window
@@ -635,7 +680,7 @@ export function ConversationView() {
// Mentions inside a reply don't get spliced into real MN entities -- a quoted reply that
// also pings someone is a rare enough compound case that it isn't worth a second
// stitching path; the "@name" text still sends fine, just without the entity/highlight.
return sendReply(target, text, pendingReplyTo, quotedSenderLabel(pendingReplyTo.from));
return sendReply(target, text, pendingReplyTo);
}
if (pendingMentions.length > 0) {
return sendPrebuiltContent(target, buildMentionedContent(text, pendingMentions));
@@ -773,24 +818,54 @@ export function ConversationView() {
// order, landing #10 ahead of #3 in the target conversation and misrepresenting which one was
// actually said first.
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
for (const targetTopic of targetTopics) {
// A target that isn't the conversation currently open (i.e. almost any real forward target)
// is not actually subscribed, and publishing on an unsubscribed topic is rejected outright by
// the SDK -- see ensureTopicAttached's own comment for why this is the common case, not an
// edge case.
ensureTopicAttached(targetTopic)
.then((target) => {
for (const seq of orderedSeqs) {
const msg = topic.findMessage(seq);
// A message can be recalled by its sender AFTER it was already selected (the checkbox
// itself refuses a NEW selection of a recalled message, but can't retroactively un-select
// one recalled mid-session) -- forwarding null content would silently send an empty bubble
// with a misleading "forwarded from" tag rather than the message the user actually picked.
if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
}
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
})
.catch((err) => showError(err, '转发失败'));
// A message can be recalled by its sender AFTER it was already selected (the checkbox itself
// refuses a NEW selection of a recalled message, but can't retroactively un-select one recalled
// mid-session) -- forwarding null content would silently send an empty bubble with a misleading
// "forwarded from" tag rather than the message the user actually picked. Checkbox selection also
// doesn't refuse a still-sending/failed message (its placeholder seq is a plain `number`, same as
// a real one) -- that message has no server-side (topic, seq) yet for forwardMessages() to find.
const sources = orderedSeqs
.map((seq) => topic.findMessage(seq))
.filter((msg): msg is StoredMessage => !!msg && !isRecalled(msg) && isConfirmedSeq(msg))
.map((msg) => ({ topic: topicId, seq: msg.seq as number }));
// One REST call now covers every (source, target) pair -- forwardMessages()'s own comment
// explains why this replaced per-target `ensureTopicAttached()` + per-message client-side
// `{pub}` publishes (was N sources x M targets separate WS sends, each independently racing
// subscription/attachment; the server handles every pair in one request with real per-pair
// success/failure reporting instead of each WS publish failing silently on its own).
//
// The comment send is chained AFTER this promise settles, not fired in parallel -- confirmed
// live (2026-09-25) that firing both at once is a real, reproducible bug, not a theoretical one:
// the endpoint's response comes back with per-item `status:"pending"`/`reason:
// "MESSAGE_PERSIST_PENDING"` (it acks the request before the forwarded messages are actually
// written server-side), while the comment's own sendText() goes over the plain WS publish path,
// which is fast enough to land FIRST. A 2-source bulk forward reordered exactly this way in a
// live test before this fix (comment appeared above both forwarded messages). Chaining at least
// waits for the request round-trip before starting the comment send, which resolved the
// reordering in the same live scenario after this fix -- see this function's own follow-up
// verification, not a guarantee for every possible load/latency condition given the endpoint's
// own async "pending" semantics.
const forwardDone =
sources.length > 0
? forwardMessages(sources, targetTopics)
.then((res) => {
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 条消息转发失败`);
// 'sent'/'pending' both mean the server has it; 'uncertain' (e.g. a timeout mid-write)
// does not -- silently lumping it in with success would leave the user with no signal
// that this particular copy may never actually land in the target conversation.
const uncertain = res.items.filter((item) => item.status === 'uncertain').length;
if (uncertain > 0) notify(`${uncertain} 条转发结果暂不确定,请稍后在目标会话中确认`);
})
.catch((err) => showError(err, '转发失败'))
: Promise.resolve();
if (comment) {
forwardDone.then(() => {
for (const targetTopic of targetTopics) {
ensureTopicAttached(targetTopic)
.then((target) => sendText(target, comment))
.catch((err) => showError(err, '附言发送失败'));
}
});
}
setBulkForwardOpen(false);
exitSelectMode();
@@ -801,9 +876,22 @@ export function ConversationView() {
setMenu(null);
if (!msg || typeof msg.seq !== 'number') return;
switch (action) {
case 'copy':
navigator.clipboard.writeText(Drafty.toPlainText(msg.content ?? '')).catch(() => {});
case 'copy': {
// Real, live-confirmed bug: a flat non-Drafty text message (e.g. the group-announcement
// auto-post, `{text:"..."}` not `{txt:"..."}`) isn't excluded from 'copy' by availableActions
// (it reads fine in the bubble via renderDrafty's own flatTextContentOf fallback -- only this
// handler's `Drafty.toPlainText` didn't know about it), so "复制" silently put the literal
// string "undefined" on the clipboard instead of the text visibly shown on screen.
// Final `?? ''` is real, not paranoia: `availableActions` only excludes 'copy' for a mobile
// attachment whose payload passes `mobileAttachmentContentOf`'s own validation (needs a real
// `file_id`/`ref`) -- a malformed/partial attachment payload that fails that check falls
// through to here with neither a `.text` nor a `.txt` field, and `Drafty.toPlainText` would
// return `undefined` again, which `writeText` coerces to the literal string "undefined" same
// as the original bug -- this stops that regardless of exactly which shape slips through.
const flatText = flatTextContentOf(msg.content);
navigator.clipboard.writeText(flatText ?? Drafty.toPlainText(msg.content ?? '') ?? '').catch(() => {});
break;
}
case 'recall':
recallMessage(topicId, msg.seq)
.then(() => topic.delMessagesList([msg.seq], true))
@@ -833,13 +921,42 @@ export function ConversationView() {
const handlePickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) sendImage(topic, file).catch((err) => showError(err, '图片发送失败'));
if (file) {
const reply = consumePendingReply();
sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败'));
}
};
const handlePickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) sendFile(topic, file).catch((err) => showError(err, '文件发送失败'));
if (file) {
const reply = consumePendingReply();
sendFile(topic, file, reply).catch((err) => showError(err, '文件发送失败'));
}
};
/** User report (老板, 2026-09-29): "没有复制粘贴图片功能,只能保存在电脑里面发" -- e.g. a screenshot
* tool, or "复制图片" from a browser/chat app, only ever puts raw pixel data on the clipboard, never
* a File the hidden `<input type="file">` above can see; without this, getting that image into a
* message required saving it to disk first and picking it via handlePickImage. `item.getAsFile()`
* returns a real File, indistinguishable from one chosen through the file picker, so this reuses
* the exact same send path. Only preventDefault once an actual image item is found -- an ordinary
* text paste (no image on the clipboard at all) is left completely untouched. */
const handleComposerPaste = (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items;
if (!items) return;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item.kind === 'file' && item.type.startsWith('image/')) {
const file = item.getAsFile();
if (!file) continue;
e.preventDefault();
const reply = consumePendingReply();
sendImage(topic, file, reply).catch((err) => showError(err, '图片发送失败'));
return;
}
}
};
return (
@@ -943,8 +1060,13 @@ export function ConversationView() {
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
{messages.map((msg) => {
const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined;
const staleSender = isGroup && msg.from && !sender ? staleSenderById.get(msg.from) : undefined;
const mine = !!myUserId && msg.from === myUserId;
const rowAvatarFileId = mine ? myAvatarFileId : isGroup ? sender?.avatar_file_id : peerAvatarFileId;
const rowAvatarFileId = mine
? myAvatarFileId
: isGroup
? sender?.avatar_file_id || staleSender?.avatar_file_id
: peerAvatarFileId;
return (
<MessageRow
key={msg.seq}
@@ -953,7 +1075,7 @@ export function ConversationView() {
mine={mine}
isGroup={isGroup}
peerName={peerName}
senderName={sender?.display_name}
senderName={sender?.display_name || staleSender?.display_name}
senderRole={sender?.role}
avatarFileId={rowAvatarFileId}
selectMode={selectMode}
@@ -962,6 +1084,7 @@ export function ConversationView() {
highlighted={typeof msg.seq === 'number' && highlightedSeq === msg.seq}
onToggleSelect={toggleSelect}
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
resolveQuotedName={quotedSenderLabel}
onError={setErrorToast}
/>
);
@@ -1002,7 +1125,7 @@ export function ConversationView() {
{replyTo ? (
<div className="mb-2 flex items-center justify-between rounded-lg bg-surface-input px-3 py-1.5 text-xs text-text-tertiary-1">
<span className="truncate">
回复 {quotedSenderLabel(replyTo.from)}:{renderDrafty(replyTo.content)}
回复 {quotedSenderLabel(replyTo.from)}:{replySnapshotText(replyTo)}
</span>
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
<Icon name="close" className="text-[16px]" />
@@ -1097,7 +1220,8 @@ export function ConversationView() {
onCancel={() => setRecording(false)}
onSend={(blob, durationMs) => {
setRecording(false);
sendVoice(topic, blob, durationMs).catch((err) => showError(err, '语音发送失败'));
const reply = consumePendingReply();
sendVoice(topic, blob, durationMs, reply).catch((err) => showError(err, '语音发送失败'));
}}
/>
) : (
@@ -1113,6 +1237,7 @@ export function ConversationView() {
value={draft}
onChange={handleDraftChange}
onKeyDown={handleKeyDown}
onPaste={handleComposerPaste}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
className="min-h-[72px] flex-1 resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
/>
@@ -1147,13 +1272,29 @@ export function ConversationView() {
<ForwardPickerModal
onClose={() => setForwardMsg(null)}
onPick={(targetTopics, comment) => {
for (const targetTopic of targetTopics) {
ensureTopicAttached(targetTopic)
.then((target) => {
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
})
.catch((err) => showError(err, '转发失败'));
// Comment is chained AFTER the forward call settles, not fired in parallel -- see
// `bulkForward`'s own comment above for why this matters (confirmed live: firing both
// at once lets the comment's plain WS send outrace the forward endpoint's own async
// "pending" persistence, landing the comment BEFORE the forwarded content it's meant to
// follow).
const forwardDone =
isConfirmedSeq(forwardMsg)
? forwardMessages([{ topic: topicId, seq: forwardMsg.seq as number }], targetTopics)
.then((res) => {
if (res.failed.length > 0) showError(undefined, `${res.failed.length} 个目标转发失败`);
const uncertain = res.items.filter((item) => item.status === 'uncertain').length;
if (uncertain > 0) notify(`${uncertain} 个目标转发结果暂不确定,请稍后在目标会话中确认`);
})
.catch((err) => showError(err, '转发失败'))
: Promise.resolve();
if (comment) {
forwardDone.then(() => {
for (const targetTopic of targetTopics) {
ensureTopicAttached(targetTopic)
.then((target) => sendText(target, comment))
.catch((err) => showError(err, '附言发送失败'));
}
});
}
setForwardMsg(null);
}}
+22 -2
View File
@@ -135,7 +135,15 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
const blockMutation = useMutation({
mutationFn: (blocked: boolean) => (blocked ? blockUser(peerUserId) : unblockUser(peerUserId)),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
// `['blocks']` alone left the composer's "你已将对方加入黑名单/对方已将你加入黑名单" banner (driven
// by a SEPARATE query, `['conversation-context', topicId]` in ConversationView.tsx) showing the
// pre-mutation state until something ELSE happened to refetch it -- live-confirmed: toggling block
// then re-checking the same conversation immediately still showed the old banner/composer state,
// and it only ever caught up after an incidental `refetchOnWindowFocus` (switching tabs and back).
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
queryClient.invalidateQueries({ queryKey: ['conversation-context', topicId] });
},
onError: (err) => showError(err, '设置失败,请重试'),
});
@@ -152,7 +160,19 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
});
const clearHistory = async () => {
if (!(await requestConfirm({ message: '确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。', danger: true }))) return;
// The old copy here ("此操作仅清除本地记录") was actively misleading -- confirmed by reading
// the real backend handler (im_be's Topic.replyDelMsg): a soft delete (`hardDel=false`, what
// `delMessagesAll(false)` sends) is NOT a browser-local cache wipe a refresh would undo. The
// server records a per-account delete watermark (`t.perUser[asUid].delID`) that persists across
// every device/session on this account and isn't reversible through any normal client action --
// only "does it affect the other party" (no) was actually true about the old wording.
if (
!(await requestConfirm({
message: '确定清空与该联系人的聊天记录吗?清空后,你的账号(含其他设备)将无法再看到这些消息,且无法恢复;不影响对方。',
danger: true,
}))
)
return;
tinode.getTopic(topicId).delMessagesAll(false);
};
@@ -20,10 +20,9 @@ interface ForwardPickerModalProps {
* context menu's single-message "转发", the multi-select bulk bar's "转发", and favorites' bulk
* forward -- all three previously shared this component when it only supported picking ONE target
* and sending immediately with no comment, meaning forwarding to several people required repeating
* the whole picker flow once per recipient. `forwardFavorites` (favorites.ts) already accepts
* `target_topics: string[]`/`comment` server-side; chat-message forwarding has no such batch REST
* endpoint (`forwardMessage` in lib/tinode/send.ts publishes one native {pub} per target instead),
* so callers loop `targetTopics` themselves -- this component only collects the selection.
* the whole picker flow once per recipient. Both `forwardFavorites` (favorites.ts) and chat-message
* forwarding (`forwardMessages`, lib/api/messages.ts) accept `target_topics: string[]` server-side
* in one batched call -- this component only collects the selection and comment; callers send.
*/
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
const contacts = useChatList();
@@ -8,7 +8,9 @@ import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem
import { checkUrlSafety } from '@/lib/api/security';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { contactCardContentOf, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { formatEventTimestamp } from '@/lib/messages/time';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
@@ -67,10 +69,44 @@ function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
* through rather than picking fields back out of it by hand. The previous version's manual
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
* search against a real plain-text message showed before this fix. */
* search against a real plain-text message showed before this fix.
*
* Real, DB/API-confirmed bug fixed here (2026-09-24): a mobile-sent message's `snapshot.content` is
* a FLAT, non-Drafty object (`{text:...}` for text, `{file_id,...}` for image/file/voice/video,
* `{user_id,...}` for a contact card), tagged only by `snapshot.head['x-im-type']` -- confirmed live
* via a real search hit's raw network response (`snapshot.head` IS present despite not previously
* being modeled on `SearchHit`, same as favorites.ts's own snapshot). Handing that straight to
* `renderDrafty` used to crash the WHOLE APP the moment a keyword matched one (`Drafty.toPlainText`
* returns `undefined` for a shape with no `.txt`, and `highlightMatches` then called `.toLowerCase()`
* on it -- this app has no error boundary anywhere to contain that). Same flat-vs-Drafty shape
* distinction `dispatch.ts`'s own `mobileAttachmentContentOf`/`contactCardContentOf` already
* document for message bubbles. */
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
const content = item.snapshot?.content;
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
const asMessage = { content, head: item.snapshot?.head } as Parameters<typeof mobileAttachmentContentOf>[0];
const mobileAttachment = mobileAttachmentContentOf(asMessage);
if (mobileAttachment) {
const label =
mobileAttachment.kind === 'image'
? '[图片]'
: mobileAttachment.kind === 'video'
? '[视频]'
: mobileAttachment.kind === 'voice'
? '[语音]'
: `[文件] ${mobileAttachment.name || ''}`;
return <span className="text-text-body">{label}</span>;
}
if (contactCardContentOf(asMessage)) return <span className="text-text-body">[名片]</span>;
// Any OTHER flat shape this app doesn't recognize yet (a future/unknown x-im-type, or corrupted
// data) still has neither `fmt`/`ent` (so `Drafty.isPlainText` says "plain text") nor `txt` (so
// `Drafty.toPlainText` returns `undefined`) -- same failure shape as the crash this whole fix
// addresses, just degrading to a silently blank line (highlightMatches' own defensive guard turns
// it into `[]`) instead of throwing. Surface that rather than leaving zero visual signal that
// something is there but unsupported, same spirit as the `(内容不可用)` placeholder above.
if (typeof content === 'object' && !('txt' in content) && !('fmt' in content) && !('ent' in content)) {
return <span className="text-text-tertiary-4">[暂不支持的消息类型]</span>;
}
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
}
@@ -163,7 +199,7 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGr
const senderLabel = (from: string): string => {
const full = normalizeSender(from);
if (myUserId && full === myUserId) return '我';
if (isGroup) return membersQuery.data?.find((m) => m.user_id === full)?.display_name || from;
if (isGroup) return groupSenderLabel(membersQuery.data?.find((m) => m.user_id === full)?.display_name);
return peerName;
};
+31 -21
View File
@@ -50,28 +50,38 @@ export function MentionPicker({ topicId, query, onPick }: MentionPickerProps) {
<span className="text-sm text-text-body">所有人</span>
</button>
) : null}
{items.map((m) => (
<button
key={m.user_id}
type="button"
onClick={() => onPick({ userId: m.user_id, label: m.display_name || m.user_id })}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
>
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
{m.role !== 'member' ? (
<span
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
}`}
>
{ROLE_LABEL[m.role]}
{items.map((m) => {
// Real, reported bug: `display_name` here silently prefers the CURRENT viewer's own
// private remark for this member (see GroupMember.display_name's own doc comment) --
// fine for a list only the viewer sees, wrong for an @mention, whose label gets baked
// into the message's shared content once and read by every OTHER member from then on,
// who have no idea what the sender's personal remark for this person means. `nickname`
// is that member's own real public name, unaffected by anyone's remark, so it stays
// correct for whoever else ends up reading the mention.
const label = m.nickname || m.display_name || m.user_id;
return (
<button
key={m.user_id}
type="button"
onClick={() => onPick({ userId: m.user_id, label })}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
>
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(label)}
</span>
) : null}
</button>
))}
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{label}</span>
{m.role !== 'member' ? (
<span
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
}`}
>
{ROLE_LABEL[m.role]}
</span>
) : null}
</button>
);
})}
</>
)}
</div>
+84 -19
View File
@@ -5,12 +5,25 @@ import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch';
import {
callResultDisplay,
contactCardContentOf,
isRecalled,
isServerDeleted,
mobileAttachmentContentOf,
systemNoticeText,
type ContactCardContent,
} from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
import { QuoteBar } from '@/features/messages/QuoteBar';
import { parseReplyHead } from '@/lib/messages/reply';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { groupSenderLabel } from '@/lib/messages/sender-name';
import { retryMessage } from '@/lib/tinode/send';
import { ensureTopicAttached } from '@/lib/tinode/topics';
import { openUserCard } from '@/stores/userCard';
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
@@ -18,15 +31,25 @@ import { ensureTopicAttached } from '@/lib/tinode/topics';
/** Distinct from a regular Drafty-rendered bubble -- see `contactCardContentOf`'s own doc comment
* for why this is dispatched by `head['x-im-type']` rather than a Drafty style tag. Tapping opens
* the real profile page, the one thing a contact-card message is fundamentally for. */
function ContactCardBubble({ content }: { content: ContactCardContent }) {
function ContactCardBubble({ content, locked }: { content: ContactCardContent; locked?: boolean }) {
const name = content.display_name || content.public_account || content.user_id;
return (
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
const inner = (
<>
<Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{name}</span>
{content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null}
</span>
</>
);
// `locked` (selectMode): same reasoning as the avatar/@mention buttons elsewhere in this file --
// a tap must only toggle the row, not navigate away and lose whatever's already been picked.
if (locked) {
return <div className="flex w-[220px] items-center gap-3">{inner}</div>;
}
return (
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
{inner}
</Link>
);
}
@@ -64,6 +87,10 @@ interface MessageRowProps {
highlighted?: boolean;
onToggleSelect?: (seq: number) => void;
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
/** Resolves a QUOTED message's sender uid (from head["x-im-reply"].from) to a display label.
* Owned by ConversationView so group remark/nickname resolution stays in one place; QuoteBar
* must never print a raw uid. */
resolveQuotedName?: (uid: string | undefined) => string;
/** Fired when the inline "重试" retry (below) itself fails -- e.g. still offline. Without this the
* failed-send icon just silently stays put with no feedback at all that the retry attempt was
* even made. */
@@ -85,6 +112,7 @@ export function MessageRow({
highlighted,
onToggleSelect,
onContextMenu,
resolveQuotedName,
onError,
}: MessageRowProps) {
if (isServerDeleted(msg)) {
@@ -110,6 +138,10 @@ export function MessageRow({
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
const callResult = callResultDisplay(msg, mine);
const contactCard = contactCardContentOf(msg);
const mobileAttachment = mobileAttachmentContentOf(msg);
// Quoted-reply snapshot (x-im-reply). Not rendered for a recalled message -- its whole bubble
// collapses to the "消息已删除" placeholder, quote bar included.
const replyInfo = isRecalled(msg) ? null : parseReplyHead(msg.head);
const retry = () => {
if (typeof msg.seq !== 'number') return;
@@ -122,8 +154,19 @@ export function MessageRow({
.catch(() => onError?.('重试发送失败'));
};
// In selectMode, the WHOLE row toggles selection (not just the checkbox) -- confirmed by the user
// that tapping only the small checkbox target is the wrong UX for a bulk-select list. A recalled
// message renders a spacer instead of a checkbox (see below) and must stay unselectable, so this
// guards the same way.
const rowSelectable = selectMode && !isRecalled(msg) && typeof msg.seq === 'number';
const toggleThisRow = () => rowSelectable && onToggleSelect?.(msg.seq as number);
return (
<div data-seq={msg.seq} className={`flex items-center gap-2 ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}>
<div
data-seq={msg.seq}
onClick={toggleThisRow}
className={`flex items-center gap-2 ${rowSelectable ? 'cursor-pointer' : ''} ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}
>
{selectMode ? (
// A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below)
// has nothing real to forward/favorite -- an empty flex-none spacer of the same size keeps
@@ -132,9 +175,11 @@ export function MessageRow({
isRecalled(msg) ? (
<div className="h-5 w-5 flex-none" />
) : (
<button type="button" onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)} className="flex-none">
// No onClick of its own -- the row-level handler above already toggles it, and a second
// handler here would double-toggle on every click (select then immediately un-select).
<div className="flex-none">
<SelectionCheckbox selected={selected} />
</button>
</div>
)
) : null}
<div
@@ -152,14 +197,14 @@ export function MessageRow({
relationship isn't a case this app has ever needed to support. Not during `selectMode`
either -- the checkbox is the only thing a tap in this row should do while bulk-selecting,
not navigate away and lose whatever's already been picked. */}
{isGroup && !mine && !selectMode && msg.from ? (
<Link to={`/contacts/${msg.from}`} className="flex-none no-underline">
<Avatar id={msg.from} name={senderName || msg.from} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
</Link>
{!mine && !selectMode && msg.from ? (
<button type="button" onClick={() => openUserCard(msg.from!)} className="flex-none">
<Avatar id={msg.from} name={isGroup ? groupSenderLabel(senderName) : peerName} avatarFileId={avatarFileId} className="h-9 w-9 rounded-lg text-sm" />
</button>
) : (
<Avatar
id={msg.from || (isGroup ? '' : peerName)}
name={mine ? '我' : isGroup ? senderName || msg.from : peerName}
name={mine ? '我' : isGroup ? groupSenderLabel(senderName) : peerName}
avatarFileId={avatarFileId}
fallbackBgClass={mine ? 'bg-accent' : undefined}
className="h-9 w-9 flex-none rounded-lg text-sm"
@@ -169,11 +214,11 @@ export function MessageRow({
{isGroup ? (
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
{!mine && !selectMode && msg.from ? (
<Link to={`/contacts/${msg.from}`} className="text-text-tertiary-4 no-underline hover:underline">
{senderName || msg.from}
</Link>
<button type="button" onClick={() => openUserCard(msg.from!)} className="text-text-tertiary-4 hover:underline">
{groupSenderLabel(senderName)}
</button>
) : (
<span>{mine ? '我' : senderName || msg.from}</span>
<span>{mine ? '我' : groupSenderLabel(senderName)}</span>
)}
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
</div>
@@ -190,6 +235,9 @@ export function MessageRow({
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
>
{replyInfo ? (
<QuoteBar topicName={topic.name} reply={replyInfo} resolveName={resolveQuotedName ?? (() => '消息')} locked={selectMode} />
) : null}
{isRecalled(msg) ? (
'⚠ 消息已删除'
) : callResult ? (
@@ -198,14 +246,31 @@ export function MessageRow({
{callResult.text}
</span>
) : contactCard ? (
<ContactCardBubble content={contactCard} />
<ContactCardBubble content={contactCard} locked={selectMode} />
) : mobileAttachment ? (
mobileAttachment.kind === 'image' ? (
<ImageAttachment data={mobileAttachment} locked={selectMode} />
) : mobileAttachment.kind === 'video' ? (
<VideoAttachment data={{ ...mobileAttachment, previewRef: mobileAttachment.previewRef }} locked={selectMode} />
) : mobileAttachment.kind === 'voice' ? (
<AudioAttachment data={mobileAttachment} locked={selectMode} />
) : (
<FileAttachment data={mobileAttachment} locked={selectMode} />
)
) : (
renderDrafty(msg.content)
renderDrafty(msg.content, undefined, !selectMode)
)}
</div>
</div>
{failed ? (
<button type="button" onClick={retry} className="text-xs text-unread">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
retry();
}}
className="text-xs text-unread"
>
发送失败,点击重试
</button>
) : (
+143
View File
@@ -0,0 +1,143 @@
import { useMemo } from 'react';
import { Icon } from '@/app/Icon';
import { useAuthedBlobUrl } from '@/lib/hooks';
import { isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
import {
placeholderForType,
quotedMediaOf,
type ReplyInfo,
type QuoteMedia,
} from '@/lib/messages/reply';
import { useTopicMessages } from '@/lib/tinode/hooks';
import { useMessageJumpStore } from '@/stores/messageJump';
import type { StoredMessage } from 'tinode-sdk';
/**
* Quote bar rendered at the top of a bubble, driven exclusively by `head["x-im-reply"]`
* (cross-client contract aligned with iOS, 2026-09-23):
*
* - the head only carries a lightweight snapshot {seq,type,text,from}; the quoted message itself
* is re-resolved from the LOCAL message cache by seq, which unlocks real thumbnails / voice
* durations and the "该消息已删除" state that no snapshot can express;
* - when the quoted message isn't local (fresh device, cleared history, unloaded range), the
* snapshot carries rendering alone -- localized placeholder per type, never blank;
* - clicking requests a scroll-locate + highlight via the same messageJump store the history
* search uses; it transparently fetches the surrounding range when the target isn't loaded.
*
* Deliberately a plain <button>-free clickable div: it must not submit anything, but must allow
* nested long-press/selection behavior of the bubble itself.
*/
function formatVoiceDuration(ms: number | undefined): string {
if (typeof ms !== 'number' || ms <= 0) return '';
return `${Math.max(1, Math.round(ms / 1000))}″`;
}
/** Small authenticated thumbnail (28x28 box per iOS spec) for image/video quotes. */
function QuoteThumb({ media }: { media: { val?: string; ref?: string; mime?: string } }) {
const inline = typeof media.val === 'string' ? `data:${media.mime || 'image/jpeg'};base64,${media.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : media.ref);
const src = inline || remote.url;
if (!src) {
return (
<span className="grid h-7 w-7 flex-none place-items-center rounded bg-black/[0.06]">
<Icon name="image" className="text-[15px] text-text-tertiary-3" />
</span>
);
}
return <img src={src} alt="" className="h-7 w-7 flex-none rounded object-cover" />;
}
function MediaSummary({ media }: { media: QuoteMedia }) {
if ((media.kind === 'image' || media.kind === 'video') && media.image) {
return (
<span className="flex min-w-0 flex-none items-center gap-1">
{media.kind === 'video' ? <Icon name="play_circle" className="flex-none text-[14px]" /> : null}
<QuoteThumb media={media.image} />
</span>
);
}
if (media.kind === 'voice') {
const seconds = formatVoiceDuration(media.durationMs);
return (
<span className="flex flex-none items-center gap-1">
<Icon name="mic_none" className="text-[14px]" />
{seconds ? <span>{seconds}</span> : null}
</span>
);
}
return <Icon name="insert_drive_file" className="flex-none text-[14px]" />;
}
interface QuoteBarProps {
topicName: string;
reply: ReplyInfo;
/** Resolves a quoted sender uid to a display label (group remark/nick -> peer name); the parent
* already owns this lookup, and must never surface a raw uid to the user. */
resolveName: (uid: string | undefined) => string;
/** MessageBubble's selectMode: a tap here must only toggle the row's own selection (same reasoning
* as the avatar/sender-name buttons going inert during bulk-select), not also jump to the quoted
* message -- so `locate` is left unwired and this renders as a plain, non-interactive div instead
* of `role="button"`. */
locked?: boolean;
}
export function QuoteBar({ topicName, reply, resolveName, locked }: QuoteBarProps) {
const messages = useTopicMessages(topicName);
const quoted: StoredMessage | undefined = useMemo(
() => messages.find((m) => typeof m.seq === 'number' && m.seq === reply.seq),
[messages, reply.seq],
);
const deleted = !!quoted && (isRecalled(quoted) || isServerDeleted(quoted));
// Local media is only usable when the quoted message is present AND alive -- a recalled message's
// thumbnail must not leak back through the quote bar (the snapshot is the only legal residue).
const localMedia = quoted && !deleted ? quotedMediaOf(quoted) : null;
const name = resolveName(quoted?.from || reply.from);
// Per the cross-client spec, only the media/card-like types render as placeholders; any unknown
// type is rendered as text using the snapshot body.
const MEDIA_LIKE_TYPES = new Set(['image', 'video', 'voice', 'file', 'contact_card', 'forward_bundle', 'sticker']);
const isTextLike = !MEDIA_LIKE_TYPES.has(reply.type);
// Real media affordances only when the local copy actually carries what they need: a usable image
// ref for image/video, a duration for voice. Otherwise fall back to the snapshot placeholder
// (exactly the iOS "取不到缩略图 → 名字:[图片]" / "取不到 → 名字:[语音]" behavior).
const hasThumb = !!localMedia && (localMedia.kind === 'image' || localMedia.kind === 'video') && !!localMedia.image;
const hasVoiceDuration = localMedia?.kind === 'voice' && typeof localMedia.durationMs === 'number' && localMedia.durationMs > 0;
const locate = () => {
useMessageJumpStore.getState().requestJump(topicName, reply.seq);
};
if (deleted) {
return (
<div
role={locked ? undefined : 'button'}
tabIndex={locked ? undefined : 0}
onClick={locked ? undefined : locate}
className="mb-1.5 truncate rounded border-l-2 border-text-tertiary-3 bg-black/[0.04] px-2 py-1 text-xs italic text-text-tertiary-3"
>
该消息已删除
</div>
);
}
return (
<div
role={locked ? undefined : 'button'}
tabIndex={locked ? undefined : 0}
onClick={locked ? undefined : locate}
title={locked ? undefined : '查看原消息'}
className="mb-1.5 flex max-w-full items-center gap-1.5 rounded border-l-2 border-accent bg-black/[0.04] px-2 py-1 text-xs text-[#4a4f57]"
>
<span className="max-w-[40%] flex-none truncate text-accent">{name}</span>
{hasThumb || hasVoiceDuration ? <MediaSummary media={localMedia as QuoteMedia} /> : null}
{isTextLike ? (
<span className="line-clamp-2 min-w-0 break-words">{reply.text}</span>
) : (
<span className="min-w-0 truncate">{reply.text || placeholderForType(reply.type)}</span>
)}
</div>
);
}
@@ -6,6 +6,7 @@ import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
import { ImApiError } from '@/lib/api/client';
import { getMyProfile, updateMyProfile } from '@/lib/api/profile';
import { uploadFile } from '@/lib/api/upload';
import { useSessionStore } from '@/stores/session';
@@ -87,9 +88,20 @@ export function ProfileSettingsPage() {
// IM-前后端对接文档.md 10.3 (版本冲突先拉最新资料再由用户确认): a stale `version` (e.g. edited
// on another tab/device) 400s here, but the cached `query.data.version` used to just sit there
// unchanged -- every retry would resend the exact same stale version and 400 again forever, the
// toast's own "请刷新后重试" copy notwithstanding, since nothing actually refetched.
// toast's own "请刷新后重试" copy notwithstanding, since nothing actually refetched. Worse: even
// once `query.data` DOES refetch fresh, `draft` (what's actually on screen and what a follow-up
// save would resubmit) was never resynced -- a second "保存" click would then silently succeed
// and overwrite whichever OTHER fields changed elsewhere with this tab's own stale values.
onError: (err) => {
setError(err instanceof Error ? err.message : '保存失败');
if (err instanceof ImApiError && err.reason === 'VERSION_CONFLICT') {
setError('资料已在其他设备更新,你的修改未保存,请重新编辑');
// Resets to null (not the fresh data directly, which isn't available here yet) so the
// effect above repopulates `draft` once the invalidated query below refetches -- same path
// as the initial load, guaranteeing `draft` never observably lags `query.data`'s version.
setDraft(null);
} else {
setError(err instanceof Error ? err.message : '保存失败');
}
queryClient.invalidateQueries({ queryKey: ['my-profile'] });
},
});
+38 -19
View File
@@ -116,6 +116,17 @@ export function SystemMessagesView() {
// already-handled invitation, or a race where someone else's action beat you to it), not just a
// toast, so it doesn't look like the button silently did nothing.
const [invitationError, setInvitationError] = useState<Record<string, string>>({});
// Real, reported bug: this card's "接受邀请" button stayed live and clickable forever, even for an
// invitation already accepted (in an earlier visit to this same list) or whose group had since been
// dissolved -- there was no concept of "this one's done" anywhere, on success OR failure, so every
// render just re-derived "show the button" from the notification's own static `action` payload,
// which the server never updates after the fact. Once EITHER outcome happens for a given
// invitation_id, it should never look freshly-clickable again -- a genuinely transient failure
// (a network blip) is rare enough, and "already handled"/"group gone" common enough, that treating
// every failure as terminal is the safer default. Doesn't survive a page reload (the server's own
// notification record has no resolved flag to read back), only this list staying mounted -- a real,
// acknowledged gap, not silently pretended away.
const [resolvedInvitations, setResolvedInvitations] = useState<Record<string, 'accepted' | 'failed'>>({});
const acceptInvitationMutation = useMutation({
mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId),
onSuccess: (data, invitationId) => {
@@ -125,10 +136,12 @@ export function SystemMessagesView() {
delete next[invitationId];
return next;
});
setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'accepted' }));
navigate(`/messages/${data.topic}`);
},
onError: (err, invitationId) => {
setInvitationError((prev) => ({ ...prev, [invitationId]: err instanceof Error ? err.message : '邀请已失效,请联系邀请人重新邀请' }));
setResolvedInvitations((prev) => ({ ...prev, [invitationId]: 'failed' }));
},
});
@@ -266,25 +279,31 @@ export function SystemMessagesView() {
{action ? <span className="self-start text-sm text-accent">{action.label} ›</span> : null}
{item.action?.type === 'group_invitation_accept' && typeof item.action.invitation_id === 'string' ? (
<div className="mt-1 flex flex-col items-start gap-1.5">
{/* A REAL nested button, unlike the plain-text action label above -- accepting
is a distinct action from "open this card" (which only marks it read; see
actionRoute's own comment for why this event type has no plain navigation
at all), so stopPropagation is the right tool here rather than avoiding the
nesting the way the plain-text case does. */}
<button
type="button"
disabled={acceptInvitationMutation.isPending}
onClick={(e) => {
e.stopPropagation();
if (isUnread) readMutation.mutate(item.id);
acceptInvitationMutation.mutate(item.action!.invitation_id as string);
}}
className="rounded-lg bg-btn px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
>
{acceptInvitationMutation.isPending && acceptInvitationMutation.variables === item.action.invitation_id
? '处理中…'
: '接受邀请'}
</button>
{resolvedInvitations[item.action.invitation_id] === 'accepted' ? (
<span className="text-xs text-text-tertiary-4">已加入群聊</span>
) : resolvedInvitations[item.action.invitation_id] === 'failed' ? (
<span className="text-xs text-text-tertiary-4">邀请已失效</span>
) : (
// A REAL nested button, unlike the plain-text action label above -- accepting
// is a distinct action from "open this card" (which only marks it read; see
// actionRoute's own comment for why this event type has no plain navigation
// at all), so stopPropagation is the right tool here rather than avoiding the
// nesting the way the plain-text case does.
<button
type="button"
disabled={acceptInvitationMutation.isPending}
onClick={(e) => {
e.stopPropagation();
if (isUnread) readMutation.mutate(item.id);
acceptInvitationMutation.mutate(item.action!.invitation_id as string);
}}
className="rounded-lg bg-btn px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
>
{acceptInvitationMutation.isPending && acceptInvitationMutation.variables === item.action.invitation_id
? '处理中…'
: '接受邀请'}
</button>
)}
{invitationError[item.action.invitation_id] ? (
<span className="text-xs text-danger-text">{invitationError[item.action.invitation_id]}</span>
) : null}
+51 -5
View File
@@ -147,7 +147,23 @@ export type GroupRole = 'owner' | 'admin' | 'member';
export interface GroupMember {
user_id: string;
/**
* Real, live-confirmed gotcha (2026-09-24): this is NOT always the member's own real name.
* Reading server/im_http.go's userSummaryPayload confirms `display_name` here unconditionally
* prefers the CALLING viewer's own private remark for that member when one is set, falling back
* to their real public nickname only when the viewer has no remark for them -- correct and
* intended for rendering a list to yourself (README's member list/search), completely wrong for
* anything that gets baked into shared message CONTENT once and read by everyone from then on
* (an @mention's label): a real reported bug was exactly this -- a group owner's own private
* remark for a member ("小号1") ended up as that member's permanent @mention text, which every
* OTHER member then saw with no idea who it meant. Use `nickname` (below) instead for that case.
*/
display_name?: string;
/** The member's own real public profile nickname -- same backend response, but from
* copyUserNickname(), which reads straight off their public profile and is NEVER shadowed by the
* viewer's own remark the way `display_name` above is. This is what stays correct for everyone
* once baked into shared content (an @mention's label), not just for the viewer's own screen. */
nickname?: string;
avatar_file_id?: string;
role: GroupRole;
mentionable?: boolean;
@@ -186,6 +202,29 @@ export function getAllGroupMembers(
return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor }));
}
/**
* `DELETE /groups/{topic}/members/{user_id}` -- confirmed live (2026-09-23) this route now exists
* (`server/im_http.go`'s `serveIMGroupMember`, `canRemoveIMGroupMember`): owner or admin can remove a
* plain member, only the owner can remove an admin, nobody (owner included) can remove the owner or
* themselves. An earlier pass through the protocol doc found no such operation at all -- that was
* true then, the backend has since added it, this isn't a doc-vs-reality mismatch this time.
*/
export function removeGroupMember(topic: string, userId: string) {
return imDelete<{ topic: string; user_id: string; status: string }>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}`);
}
/** Mirrors server/im_http.go's canRemoveIMGroupMember exactly: can't remove yourself or the owner;
* removing a plain member needs admin-or-better, removing an admin needs the owner specifically.
* Shared by every surface that renders a per-member remove affordance (GroupMembersModal's full
* list, GroupDetailColumn's compact avatar grid) so the rule can't drift between the two. */
export function canRemoveMember(myUserId: string | null, myRole: GroupRole, member: { user_id: string; role: GroupRole }): boolean {
if (!myUserId || member.user_id === myUserId) return false;
if (myRole === 'member') return false;
if (member.role === 'owner') return false;
if (member.role === 'admin' && myRole !== 'owner') return false;
return true;
}
export interface GroupMemberProfile {
topic: string;
user_id: string;
@@ -409,11 +448,15 @@ export function getGroupSummary(topic: string) {
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not
* the `published_by` object this file originally guessed -- resolve it against the group's member
* list for a display name/role, same as any other bare user id in this API. `read_count`/
* `member_count` are confirmed ABSENT from the real response (not just unconfirmed), and when
* nothing has been published yet the endpoint still returns `200` with every field zero-valued
* (`announcement_id:"", author:"", content:"", published_at:"", version:0`), never `404`/`null` --
* callers must check `content` for emptiness, not object existence. */
* list for a display name/role, same as any other bare user id in this API. When nothing has been
* published yet the endpoint still returns `200` with every field zero-valued (`announcement_id:"",
* author:"", content:"", published_at:"", version:0`), never `404`/`null` -- callers must check
* `content` for emptiness, not object existence.
* `read_count`/`member_count`/`read_summary` were confirmed ABSENT on 2026-09-08 (dropped from this
* type entirely, not just left optional) -- backend added them in the 2026-09-18 deploy (confirmed
* by reading `groupAnnouncementPayload` in `im_be/server/im_http.go`, which now sets all three).
* Optional here because an old cached response (or a tenant not yet on the new deploy) may still
* omit them. */
export interface GroupAnnouncement {
announcement_id: string;
topic: string;
@@ -422,6 +465,9 @@ export interface GroupAnnouncement {
published_at: string;
author: string;
permissions?: { can_edit?: boolean };
read_count?: number;
member_count?: number;
read_summary?: { read_count: number; member_count: number };
}
export function getLatestGroupAnnouncement(topic: string) {
+46
View File
@@ -65,6 +65,14 @@ export interface SearchHit {
// other Drafty consumer in this app, discovered here because a real search hit for a plain-text
// message rendered as blank space until this was fixed (see HistorySearchModal's SnapshotContent).
content?: DraftyContent | string;
// Confirmed LIVE (2026-09-24, a real mobile-sent flat-text hit): the snapshot also carries the
// original message's `head`, same as favorites.ts's own snapshot already documents. Needed to
// recognize a mobile-sent flat content shape (`{text:...}`/`{file_id,...}`/etc., tagged only by
// `head['x-im-type']`) BEFORE handing `content` to renderDrafty -- that flat shape isn't a real
// Drafty document, and used to crash the whole search modal (no `.txt`/`.text` field `renderDrafty`
// recognizes -> `Drafty.toPlainText` returns `undefined` -> highlightMatches() called `.toLowerCase()`
// on it). See HistorySearchModal's SnapshotContent for the fix.
head?: Record<string, unknown>;
};
}
@@ -145,3 +153,41 @@ export function favoriteMessages(items: Array<{ topic: string; seq: number }>) {
messages: items.map((i) => ({ topic: i.topic, seq: String(i.seq) })),
});
}
export interface ForwardResultItem {
source?: { topic: string; seq: number };
target_topic: string;
client_mid: string;
seq: number;
status: 'sent' | 'pending' | 'uncertain';
reason?: string;
}
export interface ForwardFailureItem {
source?: { topic: string; seq: number };
target_topic?: string;
status: 'failed';
reason: string;
}
/**
* `POST /v1/im/messages/forward` (server/im_http.go's serveIMMessageForward, confirmed by reading
* the real Go source directly, not the protocol doc) -- the SAME backend-authoritative forward path
* both real mobile clients use (confirmed by reading their real source too: iOS's
* ChatDetailViewController+Forward.swift, Android's ImMessageActionApi.forward(), NEITHER ever builds
* a `{pub}` packet client-side). One call handles every (source, target) pair at once -- up to 100
* sources x 20 targets -- with per-pair success/failure reporting, and the server copies
* `msg.Content` verbatim regardless of shape (real Drafty or a mobile-sent flat object), so unlike
* the client-side approach this replaces, there is no content-shape detection to get wrong here.
* `comment` is deliberately NOT wired here even though the endpoint accepts one -- callers keep
* sending an optional forward comment as its own separate, ordinary text message afterward (matching
* this app's existing, unchanged UX: the comment appears AFTER the forwarded message(s), whereas the
* endpoint's own built-in `comment` would save it BEFORE -- a real behavioral difference that's the
* caller's choice to take on, not something to silently opt into here).
*/
export function forwardMessages(sources: Array<{ topic: string; seq: number }>, targetTopics: string[]) {
return imPost<{ items: ForwardResultItem[]; failed: ForwardFailureItem[] }>('/messages/forward', {
sources,
target_topics: targetTopics,
});
}
+43 -10
View File
@@ -1,7 +1,8 @@
import type { StoredMessage } from 'tinode-sdk';
import type { MessagePolicy } from '@/lib/api/bootstrap';
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, mobileAttachmentContentOf } from '@/lib/messages/dispatch';
import { canQuote } from '@/lib/messages/reply';
/** SDK-internal placeholder seq threshold for a not-yet-server-confirmed message (tinode.dev.js's
* own `LOCAL_SEQID`, not exported as a Tinode static -- verified against source, not guessed). */
@@ -17,13 +18,21 @@ export type MessageAction = 'copy' | 'quote' | 'favorite' | 'forward' | 'recall'
*/
export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): number | null {
const mine = !!myUserId && msg.from === myUserId;
const isConfirmed = typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
const recallWindow = messagePolicy?.recall_window_seconds ?? 0;
if (!mine || !isConfirmed || recallWindow <= 0 || !msg.ts) return null;
if (!mine || !isConfirmedSeq(msg) || recallWindow <= 0 || !msg.ts) return null;
const remaining = recallWindow - Math.floor((Date.now() - msg.ts.getTime()) / 1000);
return remaining > 0 ? remaining : null;
}
/** True once the server has actually confirmed this message's `seq` -- false for a still-sending or
* failed-to-send message (the SDK's local placeholder seq is a `number`, so a plain `typeof` check
* isn't enough). Shared by recall (above) and forward (below): both need the server to already know
* about the message -- there's nothing yet at (topic, seq) for a recall or a forwardMessages() call
* to locate. */
export function isConfirmedSeq(msg: StoredMessage): boolean {
return typeof msg.seq === 'number' && msg.seq < LOCAL_SEQID;
}
/**
* Which context-menu actions apply to one message. Scoped to P2P for now (no group-admin recall,
* no self-topic hard-delete-always -- those need group role/topic-type knowledge Phase 3 will add).
@@ -33,13 +42,37 @@ export function recallRemainingSeconds(msg: StoredMessage, myUserId: string | nu
*/
export function availableActions(msg: StoredMessage, myUserId: string | null, messagePolicy: MessagePolicy | undefined): MessageAction[] {
if (isServerDeleted(msg) || isRecalled(msg)) return [];
// 名片消息和通话结果消息are both flat, non-Drafty custom objects (dispatch.ts's own comments on
// contactCardContentOf/callResultDisplay), not something Drafty.toPlainText/replyContent/
// forwardedContent know how to handle -- copy/quote/forward would all feed a Drafty function an
// object it doesn't recognize, producing empty/malformed output rather than an error a user would
// notice. Favorite/delete/select don't re-parse content, so they stay safe for these two types.
const isNonDrafty = contactCardContentOf(msg) !== null || callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null;
const actions: MessageAction[] = isNonDrafty ? ['favorite'] : ['quote', 'copy', 'favorite', 'forward'];
// Contact-card and call-result messages are flat, non-Drafty custom objects dispatched by
// head['x-im-type'] (dispatch.ts), not bodies Drafty.toPlainText can handle -- "copy" stays off
// both. Quote/forward differ since the 2026-09-23 x-im-reply snapshot protocol represents the
// quoted message by {seq,type,text,from} rather than parsing its content: a contact card now
// quotes fine ([名片] snapshot). Call bubbles remain favorite-only -- a transient call result
// has no meaningful "forward/quote" semantics anywhere in the cross-client spec.
const isCard = contactCardContentOf(msg) !== null;
const isCall = callResultDisplay(msg, !!myUserId && msg.from === myUserId) !== null;
// Same flat-object reasoning as contact cards -- a mobile-sent image/file/voice/video message has
// no `.txt`/`.text` for `copy` to extract (confirmed live: `ConversationView.tsx`'s copy handler
// used to put the literal string "undefined" on the clipboard for one of these). Quote/favorite/
// forward are unaffected -- quote uses the x-im-reply snapshot same as a contact card, favorite
// uses its own snapshot (favorites.ts), and forward now goes through the backend's REST forward
// endpoint (lib/api/messages.ts's `forwardMessages`), which copies content verbatim regardless of
// shape.
const isMobileAttachment = mobileAttachmentContentOf(msg) !== null;
let actions: MessageAction[];
if (isCall) {
actions = ['favorite'];
} else if (isCard || isMobileAttachment) {
actions = ['quote', 'favorite', 'forward'];
} else {
actions = ['quote', 'copy', 'favorite', 'forward'];
}
// Quote is additionally gated on the x-im-reply contract: no failed/pending messages, no
// system notices, positive confirmed seq (buildReplyInfo returns null otherwise, and the server
// would reject a malformed x-im-reply head outright).
if (!canQuote(msg)) actions = actions.filter((action) => action !== 'quote');
// A still-sending/failed message has no server-side (topic, seq) yet for forwardMessages() to
// locate -- same reasoning as recall's isConfirmedSeq gate above.
if (!isConfirmedSeq(msg)) actions = actions.filter((action) => action !== 'forward');
// Defer to the server's own supported_actions list when it's present, so a server-side policy
// change (recall disabled tenant-wide, say) doesn't leave the client offering an action that
// will just 400 when tapped -- the time-window check below still applies on top of this.
+71 -4
View File
@@ -83,6 +83,69 @@ export function contactCardContentOf(msg: StoredMessage): ContactCardContent | n
return isContactCardContent(msg.content) ? msg.content : null;
}
export interface MobileAttachmentContent {
kind: 'image' | 'file' | 'voice' | 'video';
ref: string;
previewRef?: string;
mime?: string;
name?: string;
size?: number;
width?: number;
height?: number;
duration?: number;
}
const MOBILE_ATTACHMENT_KINDS = new Set(['image', 'file', 'voice', 'video']);
/**
* Real, live-confirmed shape (2026-09-23, a real "seq测试" group with real Android/iOS senders):
* this backend's mobile apps send image/file/voice/video messages as a FLAT, non-Drafty content
* object straight off the device upload response -- `{file_id, thumbnail_file_id, mime, width,
* height, name, size, ref?, preref?}` -- tagged only by `head['x-im-type']` (one of these 4 values),
* the exact same "flat object dispatched by head, not by Drafty entity" shape `contactCardContentOf`
* above already documents for `contact_card`. The web client's OWN sender (attachments.ts) never
* produces this shape -- it always builds a real Drafty `{txt,fmt,ent}` document (`Drafty.insertImage`
* etc.) and only uses `x-im-type` as a side-channel category tag, so `renderDrafty`/
* `formatMessagePreview` were never built to recognize this flat shape and silently show nothing /
* "undefined" for it (confirmed live: 3 real messages from 3 different mobile senders in the same
* group rendered as completely blank bubbles, and the chat list's own preview read the literal string
* "undefined" for the most recent one) -- checked BEFORE ever calling `renderDrafty`/`Drafty.preview`
* on `msg.content`, same as every other flat-content case in this file.
*
* `ref`/`preref` (a real, already-resolved `/v0/file/s/{id}` path) are present on some real messages
* but not others -- confirmed on a real file/voice/video message but absent on 2 of 3 real image
* messages in the same group (present on the one from an Android-camera-named file, absent on the
* two from UUID-named files, so this looks like a per-platform difference, not random) -- falls back
* to constructing the same `/v0/file/s/{file_id}` path this app already uses for avatar_file_id
* whenever `ref`/`preref` themselves are missing, rather than assuming they're always there.
*/
export function mobileAttachmentContentOf(msg: StoredMessage): MobileAttachmentContent | null {
const headType = msg.head?.['x-im-type'];
if (typeof headType !== 'string' || !MOBILE_ATTACHMENT_KINDS.has(headType)) return null;
const c = msg.content as unknown as Record<string, unknown> | null | undefined;
if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
// A real Drafty document (this app's own sent attachments) always has `ent`/`fmt` -- the mobile
// flat shape never does. Guards against a future web-sent message that happens to reuse the same
// `x-im-type` tag on a genuine Drafty body (exactly how the web client's own sender uses it today).
if ('ent' in c || 'fmt' in c) return null;
const fileId = typeof c.file_id === 'string' ? c.file_id : undefined;
const ref = typeof c.ref === 'string' ? c.ref : fileId ? `/v0/file/s/${fileId}` : undefined;
if (!ref) return null;
const thumbId = typeof c.thumbnail_file_id === 'string' ? c.thumbnail_file_id : undefined;
const previewRef = typeof c.preref === 'string' ? c.preref : thumbId ? `/v0/file/s/${thumbId}` : undefined;
return {
kind: headType as MobileAttachmentContent['kind'],
ref,
previewRef,
mime: typeof c.mime === 'string' ? c.mime : undefined,
name: typeof c.name === 'string' ? c.name : undefined,
size: typeof c.size === 'number' ? c.size : undefined,
width: typeof c.width === 'number' ? c.width : undefined,
height: typeof c.height === 'number' ? c.height : undefined,
duration: typeof c.duration === 'number' ? c.duration : undefined,
};
}
export function formatCallDuration(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
@@ -93,10 +156,12 @@ export function formatCallDuration(ms: number): string {
* 1953/1954): `missed` alone covers both "未接听" and "已取消" (a callee who let it ring out and a
* caller who cancelled before pickup both replace to the SAME wire value) -- direction picks the
* text, not the wire value, using `mine` (was the ORIGINAL invite sent by me, i.e. was I the
* caller) exactly the way this app already threads that prop through MessageRow. No `busy` value
* exists in the confirmed real enum (only accepted/finished/disconnected/missed/declined) despite
* the old app's own UI vocabulary having one -- not rendered here since nothing legitimate can
* produce it yet.
* caller) exactly the way this app already threads that prop through MessageRow. `busy` WAS assumed
* to never appear in a persisted message (only ever seen on this app's own live, ephemeral
* `sendCallHangup(...,'busy')` signal) -- disproven by reading the real iOS/Android source
* (2026-09-25): both platforms' own SDKs declare `busy` as a genuine `head.webrtc` enum member the
* SERVER controls, same as every other case here, so a persisted busy result is a real thing this
* app can receive, not just something it can locally signal to a live peer.
*/
export function callResultDisplay(msg: StoredMessage, mine: boolean): { icon: string; text: string; danger: boolean } | null {
const content = callContentOf(msg);
@@ -114,6 +179,8 @@ export function callResultDisplay(msg: StoredMessage, mine: boolean): { icon: st
return { icon: 'phone_missed', danger: true, text: '已拒绝' };
case 'disconnected':
return { icon: 'phone_missed', danger: true, text: '异常结束' };
case 'busy':
return { icon: 'phone_missed', danger: true, text: '对方忙线中' };
case 'accepted':
case 'started':
return { icon, danger: false, text: '通话中' };
+45 -9
View File
@@ -5,6 +5,8 @@ import { Drafty, type DraftyContent } from 'tinode-sdk';
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
import { SafeLink } from '@/features/messages/SafeLink';
import { flatTextContentOf } from '@/lib/messages/dispatch';
import { MENTION_ALL_SENTINEL } from '@/lib/messages/mentions';
import { openUserCard } from '@/stores/userCard';
/**
* Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown
@@ -23,6 +25,13 @@ import { flatTextContentOf } from '@/lib/messages/dispatch';
* feature for user-authored highlight formatting, not search-match highlighting). Global,
* case-insensitive, one `<mark>` per match. */
function highlightMatches(text: string, query: string, keyPrefix: string | number): ReactNode[] {
// Defensive, not decorative: a caller one level up (`renderDrafty`'s plain-text branch) can still
// hand this `undefined` for a flat non-Drafty shape it doesn't recognize (a mobile-sent attachment/
// contact-card snapshot with no `.text`/`.txt` field) -- live-confirmed to crash the whole app with
// no error boundary to catch it (`text.toLowerCase()` on `undefined`) the moment a search keyword
// matched one of those. `renderDrafty` itself still degrades to a placeholder-less `undefined`
// node for the no-keyword case, which this alone doesn't fix -- see SnapshotContent's own guard.
if (text == null) return [];
const needle = query.trim().toLowerCase();
if (!needle) return [text];
const haystack = text.toLowerCase();
@@ -49,7 +58,7 @@ function highlightMatches(text: string, query: string, keyPrefix: string | numbe
* own callback type has a REAL 5th parameter (`stack: unknown[]`, see treeBottomUp's actual call),
* so a `formatNode` typed to accept `highlightQuery` in that same slot would stop structurally
* matching the formatter type `Drafty.format` expects wherever it's passed directly by reference. */
function makeFormatNode(highlightQuery: string | undefined) {
function makeFormatNode(highlightQuery: string | undefined, interactive: boolean) {
return function formatNode(
style: string | undefined,
data: Record<string, unknown> | undefined,
@@ -72,12 +81,27 @@ function makeFormatNode(highlightQuery: string | undefined) {
);
case 'BR':
return <br key={key} />;
case 'MN':
case 'MN': {
// The mention's uid lives on the entity's own `data` (Drafty.mention(name, uid) -- SDK
// source: `ent:[{tp:'MN', data:{val:uid}}]`), not on `values`/`children` (that's just the
// rendered label text). MENTION_ALL_SENTINEL ("所有人") isn't a real user -- nothing to open.
// `interactive=false` (MessageBubble's selectMode) falls through to the same inert <span> as
// the sentinel case -- a tap during bulk-select must only toggle the row, same reasoning as
// the avatar/sender-name buttons right above this component's own JSX going inert there.
const uid = typeof data?.val === 'string' ? data.val : undefined;
if (interactive && uid && uid !== MENTION_ALL_SENTINEL) {
return (
<button key={key} type="button" onClick={() => openUserCard(uid)} className="inline border-0 bg-transparent p-0 text-accent hover:underline">
{children}
</button>
);
}
return (
<span key={key} className="text-accent">
{children}
</span>
);
}
case 'HL':
return (
<mark key={key} className="bg-warn-bg">
@@ -86,6 +110,16 @@ function makeFormatNode(highlightQuery: string | undefined) {
);
case 'LN': {
const url = typeof data?.url === 'string' ? data.url : undefined;
// Same interactive gating as 'MN' above -- a tap during bulk-select must only toggle the
// row, not open a link (SafeLink's <button> doesn't stopPropagation, so an active link would
// both open a tab AND toggle the row's checkbox).
if (!interactive) {
return (
<span key={key} className="text-accent underline">
{children}
</span>
);
}
return (
<SafeLink key={key} href={url}>
{children}
@@ -93,13 +127,13 @@ function makeFormatNode(highlightQuery: string | undefined) {
);
}
case 'IM':
return <ImageAttachment key={key} data={data || {}} />;
return <ImageAttachment key={key} data={data || {}} locked={!interactive} />;
case 'EX':
return <FileAttachment key={key} data={data || {}} />;
return <FileAttachment key={key} data={data || {}} locked={!interactive} />;
case 'AU':
return <AudioAttachment key={key} data={data || {}} />;
return <AudioAttachment key={key} data={data || {}} locked={!interactive} />;
case 'VD':
return <VideoAttachment key={key} data={data || {}} />;
return <VideoAttachment key={key} data={data || {}} locked={!interactive} />;
case 'QQ':
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain
// style span wrapping mention(header,uid) + linebreak + body, already in `children`.
@@ -121,8 +155,10 @@ function makeFormatNode(highlightQuery: string | undefined) {
}
/** `highlightQuery` is opt-in and additive -- every existing call site that omits it renders exactly
* as before; only HistorySearchModal's search results pass one. */
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode {
* as before; only HistorySearchModal's search results pass one. Same for `interactive` (default
* true): MessageBubble's selectMode is the only caller that passes `false`, to keep an @mention from
* opening a profile card mid-bulk-select (see makeFormatNode's own comment on the MN case). */
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string, interactive = true): ReactNode {
if (content == null) return null;
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
const flatText = flatTextContentOf(content);
@@ -131,5 +167,5 @@ export function renderDrafty(content: DraftyContent | string | null | undefined,
const text = Drafty.toPlainText(content);
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
}
return Drafty.format(content, makeFormatNode(highlightQuery));
return Drafty.format(content, makeFormatNode(highlightQuery, interactive));
}
+27 -1
View File
@@ -1,6 +1,13 @@
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
import { callResultDisplay, contactCardContentOf, flatTextContentOf, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
import {
callResultDisplay,
contactCardContentOf,
flatTextContentOf,
isServerDeleted,
mobileAttachmentContentOf,
systemNoticeText,
} from '@/lib/messages/dispatch';
/**
* One-line chat-list preview text (README §2.1): plain text renders as-is, everything else falls
@@ -26,6 +33,25 @@ export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: s
if (contactCardContentOf(msg)) return `${prefix}[名片]`;
const callResult = callResultDisplay(msg, msg.from === myUserId);
if (callResult) return `${prefix}${callResult.text}`;
// Same flat, non-Drafty shape mobileAttachmentContentOf documents (mobile-sent image/file/voice/
// video) -- without this it falls through to the Drafty-entity branches below the same way the
// flat-text case above used to, and produces the same literal "undefined" (confirmed live: the
// chat list's own preview for a real mobile-sent image read exactly that).
const mobileAttachment = mobileAttachmentContentOf(msg);
if (mobileAttachment) {
switch (mobileAttachment.kind) {
case 'image':
return `${prefix}[图片]`;
case 'video':
return `${prefix}[视频]`;
case 'voice': {
const totalSeconds = Math.round((mobileAttachment.duration || 0) / 1000);
return `${prefix}[语音] ${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
case 'file':
return `${prefix}[文件] ${mobileAttachment.name || ''}`;
}
}
// Same flat `{text}` shape `systemNoticeText` above already knows about, but NOT gated to
// `system_notice`/`system_action` -- a real, live-confirmed example is a group's own
// announcement-publish notice (`head['x-im-type']:"text"`, ordinary sender, not a centered system
+227
View File
@@ -0,0 +1,227 @@
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
import { flatTextContentOf, isRecalled, isServerDeleted, mobileAttachmentContentOf, systemNoticeText } from '@/lib/messages/dispatch';
/**
* Quoted-reply wire contract (`head["x-im-reply"]`), aligned with the iOS cross-client spec
* 《消息引用数据格式-跨端对接说明》 (2026-09-23) and the im_server contract landed in commit
* 3e92021 ("feat: support quoted message headers"):
*
* { "seq": 128, // NUMBER (positive int); a string seq makes iOS drop the quote bar
* "type": "text", // quoted message's x-im-type; unknown values render as text
* "text": "摘要快照", // full body for text (display side truncates); media = placeholder
* "from": "usr..." } // optional quoted sender uid; OMIT the key entirely when empty
*
* The snapshot lives in `head`, NEVER in `content`. The server validates the shape
* (non-empty type/text, positive integer seq, non-empty from when present) and rejects the whole
* message with ErrMalformed on violation, so builders here must never emit an empty/placeholder
* `from` or an invalid seq.
*/
export interface ReplyInfo {
seq: number;
type: string;
text: string;
from?: string;
}
/** SDK-internal placeholder seq threshold for a not-yet-server-confirmed message (mirrors
* actions.ts's own LOCAL_SEQID; tinode.dev.js LOCAL_SEQID, not exported). */
const LOCAL_SEQID = 0xfffffff;
/** Media types whose snapshot text is a localized placeholder rather than a real body. */
const MEDIA_TYPES = new Set(['image', 'video', 'voice', 'file']);
const PLACEHOLDER_BY_TYPE: Record<string, string> = {
image: '[图片]',
video: '[视频]',
voice: '[语音]',
file: '[文件]',
contact_card: '[名片]',
forward_bundle: '[聊天记录]',
link: '[链接]',
};
const PLACEHOLDER_FALLBACK = '[消息]';
/** Localized placeholder per quoted type (§7 of the cross-client spec). Unknown -> `[消息]`. */
export function placeholderForType(type: string): string {
return PLACEHOLDER_BY_TYPE[type] ?? PLACEHOLDER_FALLBACK;
}
/** Legacy head `type` values seen in stored messages, mapped onto the product x-im-type enum. */
const LEGACY_TYPE_MAP: Record<string, string> = {
audio: 'voice',
};
/** The effective product type of a message, read from head (`x-im-type` preferred over legacy
* `type`). Falls back to "text" -- a message without an explicit type tag is a plain text body. */
export function messageReplyType(msg: StoredMessage): string {
const head = msg.head;
const product = head?.['x-im-type'];
if (typeof product === 'string' && product.trim()) return product.trim();
const legacy = head?.['type'];
if (typeof legacy === 'string' && legacy.trim()) return LEGACY_TYPE_MAP[legacy.trim()] ?? legacy.trim();
return 'text';
}
/** Whether a message may be quoted at all: server-confirmed positive seq, not failed/pending,
* not deleted/recalled, and not a centered system notice or a transient call-result bubble. */
export function canQuote(msg: StoredMessage): boolean {
if (typeof msg.seq !== 'number' || !Number.isInteger(msg.seq) || msg.seq <= 0 || msg.seq >= LOCAL_SEQID) return false;
if (isServerDeleted(msg) || isRecalled(msg) || msg.content == null) return false;
const type = messageReplyType(msg);
if (type === 'system_notice' || type === 'system_action' || type === 'system_event' || type === 'call') return false;
// Belt and suspenders: systemNoticeText recognizes centered notices by head tag even if the type
// string ever drifts.
if (systemNoticeText(msg)) return false;
return true;
}
function draftyPlainText(content: StoredMessage['content']): string {
if (content == null) return '';
if (typeof content === 'string') return content;
return Drafty.toPlainText(content as DraftyContent).trim();
}
/** Snapshot text written into `x-im-reply.text` at send time: full text body for text-like
* messages (no truncation -- the display side truncates), localized placeholder for media/card. */
export function replySnapshotText(msg: StoredMessage, type: string = messageReplyType(msg)): string {
if (MEDIA_TYPES.has(type) || type === 'contact_card' || type === 'forward_bundle' || type === 'sticker') {
return placeholderForType(type);
}
const flat = flatTextContentOf(msg.content);
if (flat !== null) return flat.trim();
const plain = draftyPlainText(msg.content);
return plain || PLACEHOLDER_FALLBACK;
}
/** Builds the `x-im-reply` head value for an outgoing message, or null when the quoted message
* is not quotable (caller must then refuse to arm the reply state). */
export function buildReplyInfo(msg: StoredMessage): ReplyInfo | null {
if (!canQuote(msg)) return null;
const type = messageReplyType(msg);
const info: ReplyInfo = {
seq: msg.seq,
type,
text: replySnapshotText(msg, type),
};
// OMIT `from` when empty -- the server rejects an empty-string from as malformed.
if (typeof msg.from === 'string' && msg.from.trim()) info.from = msg.from.trim();
return info;
}
function asPositiveInt(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value) && value > 0) return value;
// Tolerant receive path: the spec suggests accepting numeric strings even though senders must
// write a real JSON number (a string seq is exactly what makes iOS fail to parse, but other
// clients' history may still carry one).
if (typeof value === 'string' && /^\d+$/.test(value.trim())) {
const n = Number(value.trim());
if (Number.isSafeInteger(n) && n > 0) return n;
}
return null;
}
function parseReplyValue(raw: unknown): ReplyInfo | null {
let value: unknown = raw;
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return null;
try {
value = JSON.parse(trimmed);
} catch {
return null;
}
}
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const obj = value as Record<string, unknown>;
const seq = asPositiveInt(obj.seq);
if (seq == null) return null;
if (typeof obj.type !== 'string' || !obj.type.trim()) return null;
if (typeof obj.text !== 'string') return null;
const reply: ReplyInfo = { seq, type: obj.type.trim(), text: obj.text };
// Unknown types are kept as-is here; the display layer maps them onto text rendering.
if (typeof obj.from === 'string' && obj.from.trim()) reply.from = obj.from.trim();
return reply;
}
/** Parses a received message's `head["x-im-reply"]`, tolerating both object and JSON-string
* carriers (iOS already accepts both) and any malformed shape -> null (no quote, never crash). */
export function parseReplyHead(head: Record<string, unknown> | undefined): ReplyInfo | null {
if (!head) return null;
return parseReplyValue(head['x-im-reply']);
}
/** Media descriptor of a LOCALLY AVAILABLE quoted message, allowing the quote bar to render a
* real thumbnail / voice duration instead of the text placeholder. Returns null when the quoted
* message isn't in the local cache; the caller then renders the snapshot placeholder. */
export interface QuoteMedia {
kind: 'image' | 'video' | 'voice' | 'file';
/** Image/video box: authenticated inline bits or a remote ref. */
image?: { val?: string; ref?: string; mime?: string };
/** Voice bubble: duration in milliseconds. */
durationMs?: number;
}
interface DraftyEntityDoc {
ent?: Array<{ tp?: string; data?: Record<string, unknown> }>;
}
function draftyEntityMedia(msg: StoredMessage): QuoteMedia | null {
const c = msg.content as DraftyContent | null;
if (!c || typeof c !== 'object') return null;
const doc = c as Partial<DraftyEntityDoc>;
if (!Array.isArray(doc.ent)) return null;
// Prefer the first matching media entity -- a quoted message carries one primary attachment.
for (const ent of doc.ent) {
const data = ent.data || {};
const asImage = () => {
// This deployment's Drafty media carries its upload URL in `refurl` (both webapp-sent and
// script/iOS-sent messages -- see attachments.ts), with tinode's classic `ref`/inline `val`
// as the other accepted carriers. Missing all three -> not locally renderable.
const ref =
typeof data.refurl === 'string' ? data.refurl : typeof data.ref === 'string' ? data.ref : undefined;
const val = typeof data.val === 'string' ? data.val : undefined;
return ref || val
? { image: { ref, val, mime: typeof data.mime === 'string' ? data.mime : undefined } }
: null;
};
switch (ent.tp) {
case 'IM': {
const image = asImage();
if (image) return { kind: 'image', ...image };
break;
}
case 'VD': {
const image = asImage();
if (image) return { kind: 'video', ...image };
break;
}
case 'AU': {
const durationMs = typeof data.duration === 'number' ? data.duration : undefined;
return { kind: 'voice', durationMs };
}
case 'EX':
return { kind: 'file' };
default:
break;
}
}
return null;
}
/** Resolves locally-renderable media for a quoted message (mobile flat content or web Drafty
* entities). Null when the message is text/not locally available in a recognizable shape. */
export function quotedMediaOf(msg: StoredMessage | undefined): QuoteMedia | null {
if (!msg || msg.content == null) return null;
const mobile = mobileAttachmentContentOf(msg);
if (mobile) {
if (mobile.kind === 'image' || mobile.kind === 'video') {
const ref = mobile.previewRef || mobile.ref;
return ref ? { kind: mobile.kind, image: { ref, mime: mobile.mime } } : { kind: mobile.kind };
}
if (mobile.kind === 'voice') return { kind: 'voice', durationMs: mobile.duration };
return { kind: 'file' };
}
return draftyEntityMedia(msg);
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Sender display label for group chat surfaces (bubbles, quoted replies, history search).
*
* Two distinct paths used to leak the raw `usr...` id into the message list (Zentao #32):
* - the sender is no longer in the member list (left/was removed) so there is no resolved
* name at all, and the row fell back to `msg.from` verbatim;
* - the member IS in the list but has no nickname/remark anywhere, so the server's own
* display-name fallback in userSummaryPayload is the user id itself.
* Both must render a fixed placeholder instead -- a uid is an internal identifier, not a
* user-facing name. Membership of a uid is decided by shape (`usr…` followed by id charset),
* because the server's fallback means the raw id can arrive *inside* `senderName`.
*/
const UID_SHAPE = /^usr[A-Za-z0-9_-]+$/;
export const UNKNOWN_SENDER_LABEL = '未知用户';
/**
* Accepts multiple name candidates in priority order (e.g. the current member list's
* `display_name`, then a standalone profile lookup for a sender who has since left the group)
* and returns the first one that isn't uid-shaped. A left-group member's own profile can
* usually still be resolved by id and is a real name worth showing instead of the placeholder;
* a still-present member whose `display_name` IS the uid (the second bug path above) has no
* better candidate to fall back to and correctly lands on the placeholder either way.
*/
export function groupSenderLabel(...candidates: Array<string | undefined>): string {
for (const candidate of candidates) {
const name = candidate?.trim();
if (name && !UID_SHAPE.test(name)) return name;
}
// No usable name -- never show the raw uid even when it IS available; it's not a user-facing name.
return UNKNOWN_SENDER_LABEL;
}
+19 -10
View File
@@ -2,7 +2,8 @@ import { Drafty, type Topic } from 'tinode-sdk';
import { uploadFile, upgradeToHttps } from '@/lib/api/upload';
import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config';
import { newClientMid } from '@/lib/tinode/send';
import type { ReplyInfo } from '@/lib/messages/reply';
import { newClientMid, publishWithReattach } from '@/lib/tinode/send';
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
@@ -29,6 +30,14 @@ function imageDimensions(file: File): Promise<{ width: number; height: number }>
});
}
/** Merges the standard send headers with the optional pending quoted-reply snapshot, so media
* messages armed with a quote carry the same `x-im-reply` head as text replies. */
function mediaHead(type: string, reply?: ReplyInfo | null): Record<string, unknown> {
const head: Record<string, unknown> = { 'x-im-type': type, 'x-im-client-mid': newClientMid() };
if (reply) head['x-im-reply'] = reply;
return head;
}
/**
* Small files go inline as base64 (Drafty.insertImage's `bits` field) -- no upload round trip at
* all. Larger ones upload first, then reference the returned URL (`refurl`). Simplified from the
@@ -36,7 +45,7 @@ function imageDimensions(file: File): Promise<{ width: number; height: number }>
* the upload happen in the background): this awaits the upload before creating the message at all,
* trading that "shows immediately while uploading" nicety for meaningfully simpler code.
*/
export async function sendImage(topic: Topic, file: File): Promise<unknown> {
export async function sendImage(topic: Topic, file: File, reply?: ReplyInfo | null): Promise<unknown> {
const { width, height } = await imageDimensions(file);
const content =
file.size <= MAX_INBAND_ATTACHMENT_SIZE
@@ -50,18 +59,18 @@ export async function sendImage(topic: Topic, file: File): Promise<unknown> {
size: file.size,
});
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
msg.head = Object.assign(msg.head || {}, mediaHead('image', reply));
return publishWithReattach(topic, msg);
}
export async function sendFile(topic: Topic, file: File): Promise<unknown> {
export async function sendFile(topic: Topic, file: File, reply?: ReplyInfo | null): Promise<unknown> {
const content =
file.size <= MAX_INBAND_ATTACHMENT_SIZE
? Drafty.attachFile(undefined, { mime: file.type, data: await fileToBase64(file), filename: file.name, size: file.size })
: Drafty.attachFile(undefined, { mime: file.type, refurl: upgradeToHttps((await uploadFile(file, topic.name)).url), filename: file.name, size: file.size });
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
msg.head = Object.assign(msg.head || {}, mediaHead('file', reply));
return publishWithReattach(topic, msg);
}
/**
@@ -72,7 +81,7 @@ export async function sendFile(topic: Topic, file: File): Promise<unknown> {
* field this app actually displays from doesn't depend on it, only a browser's native <audio>
* scrubber would).
*/
export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): Promise<unknown> {
export async function sendVoice(topic: Topic, blob: Blob, durationMs: number, reply?: ReplyInfo | null): Promise<unknown> {
const file = new File([blob], `voice.${blob.type.includes('mp4') ? 'm4a' : 'webm'}`, { type: blob.type });
const content =
file.size <= MAX_INBAND_ATTACHMENT_SIZE
@@ -85,6 +94,6 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P
filename: file.name,
});
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
msg.head = Object.assign(msg.head || {}, mediaHead('voice', reply));
return publishWithReattach(topic, msg);
}
+9 -4
View File
@@ -1,6 +1,6 @@
import type { DraftyContent, StoredMessage, Topic } from 'tinode-sdk';
import { newClientMid } from '@/lib/tinode/send';
import { newClientMid, publishWithReattach } from '@/lib/tinode/send';
/**
* Call signaling send-side helpers. Confirmed shape (docs/IM-前后端对接文档.md §0.5.4.7/§8, lines
@@ -21,8 +21,13 @@ export interface CallContent {
}
/** `head.webrtc` values a call message cycles through, replacing the SAME `seq` in place (doc line
* 1953) -- `started` is the live invite; the rest are terminal results the server writes over it. */
export type CallWebrtcState = 'started' | 'accepted' | 'finished' | 'disconnected' | 'missed' | 'declined';
* 1953) -- `started` is the live invite; the rest are terminal results the server writes over it.
* `busy` was previously excluded here (assumed to be a client-only, never-persisted value -- see
* the now-superseded reasoning this replaced in stores/calls.ts/dispatch.ts) until reading the real
* iOS/Android app source (2026-09-25 cross-client audit) confirmed it's a genuine enum member both
* platforms' own SDKs declare for this exact server-controlled field (`ServerMessages.swift`'s
* 7-case enum, `MsgServerData.java`'s `BUSY`), not a web-only invention. */
export type CallWebrtcState = 'started' | 'accepted' | 'finished' | 'disconnected' | 'missed' | 'declined' | 'busy';
export function callContentOf(msg: StoredMessage): CallContent | null {
const webrtc = msg.head?.webrtc;
@@ -63,7 +68,7 @@ export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' |
// not guessed. This cast is deliberately lying to the type system for that one confirmed reason.
const msg = topic.createMessage(content as unknown as DraftyContent, false);
msg.head = Object.assign(msg.head || {}, { mime: 'application/json', webrtc: 'started', 'x-im-type': 'call', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>;
return publishWithReattach(topic, msg) as Promise<{ code: number; params?: { seq: number } }>;
}
/**
+86 -9
View File
@@ -39,6 +39,24 @@ export function onReauthenticated(callback: () => void): () => void {
return () => reconnectAuthListeners.delete(callback);
}
/**
* Fired at the top of resetTinode() (logout/"切换企业"/kicked). Same "plain listener set, not a
* direct import" reasoning as onReauthenticated above -- topics.ts's own module-level caches
* (confirmedReadSeq, previewPrefetched, messageSnapshots, goneTopicNames, chatListSnapshot) used to
* outlive a logout entirely (real, reproduced bug: 'slf' is the same literal topic name for every
* account, so confirmedReadSeq['slf'] from account A surviving into account B's session -- switched
* to in the SAME tab, no full reload -- silently marked B's genuinely-unread self-chat messages as
* read the next time reconcileMeSubs() ticked). This module has no reason to know what "reset" means
* for topics.ts's own state, only that resetTinode() is the one place every teardown path (logout,
* switch-tenant, kicked) already funnels through.
*/
const resetListeners = new Set<() => void>();
export function onTinodeReset(callback: () => void): () => void {
resetListeners.add(callback);
return () => resetListeners.delete(callback);
}
function setStatus(next: ConnectionStatus) {
if (status === next) return;
if (next === 'authenticated') wasAuthenticated = true;
@@ -136,13 +154,31 @@ export function clearKickedInfo() {
}
/**
* The server extends the stock {hi} packet with a `tenant` field the vendored JS SDK has no API for
* (resolved via POST /v1/im/tenants/resolve; see server/datamodel.go). The packet builder is private,
* so the only usable hook is wrapping the connection's raw sendText and injecting the field into the
* already-serialized JSON. Ported as-is from the old app's tnSetup/installTenantHiPatch -- this is a
* real backend protocol extension, not an old-codebase-specific workaround.
* Two backend protocol extensions the vendored JS SDK has no API for -- both fields the packet
* builders (#initPacket/login(), both private) never write, so the only usable hook for either is
* wrapping the connection's raw sendText and injecting the field into the already-serialized JSON:
* - {hi}.tenant (resolved via POST /v1/im/tenants/resolve; see server/datamodel.go). Ported as-is
* from the old app's tnSetup/installTenantHiPatch.
* - {login}.login_type: confirmed live (server/user.go validateLoginTenant, called from every
* session.login()) that scheme:"basic" logins now hard-require this field -- 0 enterprise /
* 1 personal -- and fail with a 422 LOGIN_TYPE_REQUIRED without it (Zentao bug #33). tenant_code
* is deliberately left unset: validateLoginTenant falls back to the session's own tenant (already
* established by the {hi} patch above) when {login} omits it, for both the personal and
* enterprise branches, so login_type is the only field this client needs to add. token-scheme
* logins (restoreSession/reconnect re-auth) are untouched -- validateLoginTenant only requires
* login_type when scheme is "basic".
*/
function installTenantHiPatch(t: Tinode) {
/** The tenant the server session actually resolves to, captured when {hi} is sent (the server
* falls back to 'default' when {hi}.tenant is absent). login_type is decided against THIS value,
* not a fresh store read: the two decisions happen at different moments (hi on connect, login on
* submit), and a store change in between would make login_type disagree with the session tenant,
* which the server rejects with 422 policy violation / TENANT_CODE_FORBIDDEN -- rendered by
* clients as "当前操作不符合安全策略要求". */
let sessionTenantCode: string | null = null;
/** One-shot override used by loginBasic's FORBIDDEN self-heal retry (see there). */
let forcedLoginType: 0 | 1 | null = null;
function installOutgoingPacketPatches(t: Tinode) {
const conn = t._connection;
if (!conn || conn.__tenantPatched) return;
const original = conn.sendText.bind(conn);
@@ -152,10 +188,28 @@ function installTenantHiPatch(t: Tinode) {
const parsed = JSON.parse(msg);
if (parsed.hi) {
const tenant = useSessionStore.getState().tenant;
// Whichever tenant (or absence of one) this {hi} declares, the server fixes the session
// tenant from it right now -- so remember exactly that, for login_type decisions later.
sessionTenantCode = (tenant?.code || 'default').trim().toLowerCase();
if (tenant?.code) {
parsed.hi.tenant = tenant.code;
msg = JSON.stringify(parsed);
}
msg = JSON.stringify(parsed);
}
} catch {
// Not valid JSON -- send as is.
}
} else if (typeof msg === 'string' && msg.indexOf('"login":') >= 0) {
try {
const parsed = JSON.parse(msg);
if (parsed.login && parsed.login.scheme === 'basic') {
if (forcedLoginType !== null) {
parsed.login.login_type = forcedLoginType;
} else {
const tenantCode = sessionTenantCode ?? (useSessionStore.getState().tenant?.code || 'default');
parsed.login.login_type = tenantCode.trim().toLowerCase() === 'default' ? 1 : 0;
}
msg = JSON.stringify(parsed);
}
} catch {
// Not valid JSON -- send as is.
@@ -173,7 +227,7 @@ function createTinode(): Tinode {
const t = new Tinode({ appName: APP_NAME, host, apiKey: API_KEY, transport: 'ws', secure, persist: false });
t.setHumanLanguage('zh');
t.enableLogging(LOGGING_ENABLED, true);
installTenantHiPatch(t);
installOutgoingPacketPatches(t);
t.onConnect = () => {
const wasReconnecting = reconnectState.retryInSeconds > 0 || reconnectState.attemptInFlight;
@@ -268,11 +322,17 @@ export function getTinode(): Tinode {
* since those are keyed by nothing but "is there currently a pending one" -- confirmed reachable via
* TenantEntryPage.tsx's "切换企业" flow racing a slow-to-respond old host. */
export function resetTinode() {
resetListeners.forEach((cb) => cb());
tinode?.disconnect();
tinode = null;
connectPromise = null;
restorePromise = null;
wasAuthenticated = false;
// Both belong to the torn-down connection's session: the next getTinode() re-derives them from
// the freshly-sent {hi}, and a stale non-null sessionTenantCode here would misclassify the very
// first login_type of the rebuilt connection (which sends hi before any store read matters).
sessionTenantCode = null;
forcedLoginType = null;
setStatus('disconnected');
}
@@ -335,7 +395,24 @@ export function loginBasic(login: string, password: string): Promise<LoginCtrl>
loginPromise = (async () => {
const t = getTinode();
if (!t.isConnected()) await connect();
return t.loginBasic(login, password);
const ctrl = await t.loginBasic(login, password);
// Self-heal the exact failure mode observed live (server validateLoginTenant): an enterprise-
// meaning login_type=0 against a session whose tenant is 'default' is rejected with 422 policy
// violation, reason=TENANT_CODE_FORBIDDEN -- users see "当前操作不符合安全策略要求". That can
// only mean the session tenant is 'default', so retry once with the personal meaning (1) before
// giving up; a success also re-syncs sessionTenantCode so later logins on this connection don't
// repeat the mistake.
if (ctrl.code === 422 && (ctrl.params as { reason?: string } | undefined)?.reason === 'TENANT_CODE_FORBIDDEN') {
forcedLoginType = 1;
try {
const retry = await t.loginBasic(login, password);
if (retry.code < 300) sessionTenantCode = 'default';
return retry;
} finally {
forcedLoginType = null;
}
}
return ctrl;
})().finally(() => {
loginPromise = null;
});
+9
View File
@@ -60,6 +60,15 @@ export function loginErrorMessage(err: unknown): string {
const known = translateCtrlError(err);
if (known) return known;
const code = (err as { code?: number })?.code;
// 429 account locked: im_server's ErrAuthLocked puts the user-facing Chinese message (including
// the remaining-minutes wording, e.g. "登录错误次数过多,账号已锁定,请 15 分钟后再试") into ctrl.text,
// so the raw SDK message is that text with a status-code suffix baked in. Strip the suffix and show
// the server copy as-is; the static fallback only fires if the server somehow sent no text. Kept as
// a hardcoded zh string (no i18n key) to match every other entry in this file.
if (code === 429) {
const cleaned = (err as Error)?.message?.replace(/\s*\(\d+\)\s*$/, '').trim();
return cleaned || '登录错误次数过多,请稍后再试';
}
if (code === 401 || code === 403) return '账号或密码错误';
return (err as Error)?.message || '登录失败,请重试';
}
+47 -40
View File
@@ -1,11 +1,6 @@
import { Drafty, type DraftyContent, type StoredMessage, type Topic } from 'tinode-sdk';
import { QUOTED_REPLY_LENGTH } from '@/lib/config';
function toDraftyContent(content: DraftyContent | string | null | undefined): DraftyContent {
if (content == null) return Drafty.init('');
return typeof content === 'string' ? Drafty.init(content) : content;
}
import { buildReplyInfo } from '@/lib/messages/reply';
/**
* docs/IM-前后端对接文档.md:486/952/1128 -- every `{pub}` must carry a client-generated
@@ -28,6 +23,30 @@ export function newClientMid(): string {
: `mid_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
}
/**
* Wraps `topic.publishDraft` with a single retry after a forced re-subscribe. Confirmed live,
* 2026-09-21, on an independent native-Cluster verification deployment: killing the Pod holding a
* topic's Cluster master tears down that topic's server-side proxy session on the surviving nodes
* (`server/session.go`'s `s.publish` logs "must attach first" and replies with a 409/`ErrAttachFirst`)
* WITHOUT notifying the client -- the SDK's local `_attached` flag is never reset on this path (only
* `_gone()`/topic-deleted flows clear it), so `isSubscribed()`/`ensureTopicAttached()` keep reading a
* stale `true` and can't catch this in advance. Every already-open conversation kept failing to send
* until a fresh `subscribe()` was issued by hand. `publishDraft`'s own `_noForwarding` guard (set on
* the first call) makes calling it again on the SAME `msg` object safe -- it re-sends the `{pub}`
* without re-inserting a duplicate local echo, and the message's `x-im-client-mid` is unchanged so a
* network-level duplicate is still deduped server-side. Only 409 + this exact message is treated as
* recoverable; any other rejection (permissions, malformed content, network down) passes through
* unchanged so it still surfaces via the caller's existing `showError` handling.
*/
export function publishWithReattach(topic: Topic, msg: StoredMessage): Promise<unknown> {
return topic.publishDraft(msg, Promise.resolve()).catch((err: unknown) => {
const code = err instanceof Error ? (err as Error & { code?: number }).code : undefined;
const recoverable = code === 409 && err instanceof Error && err.message.includes('attach');
if (!recoverable) throw err;
return topic.subscribe().then(() => topic.publishDraft(msg, Promise.resolve()));
});
}
/**
* Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old
* app's own choice -- publishDraft inserts the message into the local cache and fires onData
@@ -38,7 +57,7 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
return publishWithReattach(topic, msg);
}
/**
@@ -51,7 +70,7 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
export function sendPrebuiltContent(topic: Topic, content: DraftyContent | string, head?: Record<string, unknown>): Promise<unknown> {
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
return publishWithReattach(topic, msg);
}
/**
@@ -61,9 +80,11 @@ export function sendPrebuiltContent(topic: Topic, content: DraftyContent | strin
* cache (already flushed, or never existed).
*
* Reuses the ORIGINAL failed message's own `x-im-client-mid` rather than minting a fresh one -- see
* newClientMid()'s own comment above for why a retry specifically must not get a new id. Falls back
* to a fresh one only for a locally-cached failed message from before this field existed at all
* (there's no original id to recover in that one case).
* newClientMid()'s own comment above for why a retry specifically must not get a new id. The whole
* original head is copied verbatim, which also keeps `x-im-reply` identical across retries (the
* server requires head parity for a mid-deduped resend). Falls back to a fresh one only for a
* locally-cached failed message from before this field existed at all (no original id to recover
* in that one case).
*/
export function retryMessage(topic: Topic, seq: number): Promise<unknown> | undefined {
const failed = topic.flushMessage(seq);
@@ -73,7 +94,7 @@ export function retryMessage(topic: Topic, seq: number): Promise<unknown> | unde
msg.head = Object.assign(msg.head || {}, failed.head, {
'x-im-client-mid': typeof existingMid === 'string' ? existingMid : newClientMid(),
});
return topic.publishDraft(msg, Promise.resolve());
return publishWithReattach(topic, msg);
}
export function messageIsMine(msg: StoredMessage, myUserId: string | null): boolean {
@@ -81,33 +102,19 @@ export function messageIsMine(msg: StoredMessage, myUserId: string | null): bool
}
/**
* Reply/quote (old app's messages-view.jsx handlePickReply/sendMessage): the quoted body is
* truncated (Drafty.replyContent, QUOTED_REPLY_LENGTH) and wrapped with the sender's name
* (Drafty.quote), then the whole quote block is INLINED into the outgoing content ahead of the
* new text (Drafty.append) -- the receiving side never has to look up the original message to show
* a quote preview. `head.reply` carries the original seq separately, purely so a click on the quote
* block can scroll back to it.
* Quoted reply (cross-client `x-im-reply` contract, aligned with iOS 2026-09-23): the quote is a
* lightweight SNAPSHOT carried in `head["x-im-reply"]` (`{seq,type,text,from}`), NOT a Drafty QQ
* block inlined into the content -- content stays the clean reply body. The receiving side renders
* its own quote bar and locally re-fetches thumbnails/durations by seq. Builds the snapshot from
* the quoted message via buildReplyInfo(); throws when the message isn't quotable (failed/pending/
* system notice), which the UI gates before arming the reply state.
*/
export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quotedSenderLabel: string): Promise<unknown> {
const trimmed = Drafty.replyContent(toDraftyContent(quoted.content), QUOTED_REPLY_LENGTH);
const quoteBlock = Drafty.quote(quotedSenderLabel, quoted.from || '', trimmed);
const sanitized = Drafty.sanitizeEntities(JSON.parse(JSON.stringify(quoteBlock)) as DraftyContent);
const finalContent = Drafty.append(sanitized, Drafty.parse(text));
const msg = topic.createMessage(finalContent, false);
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
}
/**
* Single-message forward, sent directly to one target topic. Simplified from the old app's own
* flow (which stages the forwarded content and lets the user add a comment via the normal composer
* before sending) -- this sends immediately on picking a target, trading that one nicety for a
* much smaller "pick a contact" dialog. `head.forwarded` records the source for traceability; it
* has no reader anywhere yet, matching the old app (no confirmed call site reads it back either).
*/
export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise<unknown> {
const content = Drafty.forwardedContent(toDraftyContent(msg.content));
const newMsg = target.createMessage(content, false);
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() });
return target.publishDraft(newMsg, Promise.resolve());
export function sendReply(topic: Topic, text: string, quoted: StoredMessage): Promise<unknown> {
const reply = buildReplyInfo(quoted);
if (!reply) throw new Error('该消息不可引用');
const parsed = Drafty.parse(text);
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-reply': reply, 'x-im-client-mid': newClientMid() });
return publishWithReattach(topic, msg);
}
+19 -1
View File
@@ -183,6 +183,24 @@ declare module 'tinode-sdk' {
getMeta(query: unknown): Promise<unknown>;
startMetaQuery(): MetaGetBuilder;
/**
* Private/internal SDK method (underscore prefix, not part of the documented API) -- normally only
* ever called by the SDK itself when a real `{meta desc}` server response arrives. Declared here
* and used deliberately, NOT as a first choice: `getMeta(startMetaQuery().withDesc().build())` (the
* sanctioned public API for exactly this "re-pull desc from server" need) was live-instrumented
* against this backend and found to be genuinely unreliable for group topics -- consistently a
* 504 timeout (~5.4s, every attempt, including 10+s after the write) on a brand-new topic, and on
* an established topic a fast-but-STALE response whose `public.fn` still reflected the pre-rename
* name (confirmed by round-tripping a real REST rename immediately followed by this call). Calling
* this directly with data already known-fresh (from a REST response we trust) sidesteps that
* broken path entirely -- confirmed by reading the SDK's own source that it merges `e.public` onto
* the topic's existing `public` object (other fields like `version` survive) and, for a non-`me`
* topic, forwards the update to the `me` topic's own subscription cache via `onMetaSub`/
* `onSubsUpdated` -- i.e. the exact same fan-out a genuine server push would trigger. Live-confirmed
* this reaches `me.contacts()` (same object reference as `topic.public`, verified live).
*/
_processMetaDesc(desc: { public?: Partial<TopicDescPublic> }): void;
/** Builds the `{pub}` packet shape but does not send it -- pair with `publishDraft`/`publishMessage`. */
createMessage(content: DraftyContent | string, noEcho?: boolean): StoredMessage;
/**
@@ -270,7 +288,7 @@ declare module 'tinode-sdk' {
/** Single-slot callbacks the app wires fresh per "currently open" topic and tears down on leave. */
onData?: (data?: StoredMessage) => void;
onAllMessagesReceived?: (count: number) => void;
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string; event?: string; payload?: Record<string, unknown> }) => void;
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call' | 'im'; seq?: number; from?: string; event?: string; payload?: Record<string, unknown> }) => void;
onMetaDesc?: (desc: unknown) => void;
/** `me`-topic-only in practice (verified tinode.dev.js:4654-4657): fired with the just-subscribed
* Topic itself the moment a brand-new contact's subscribe resolves -- notably including the
+166 -5
View File
@@ -12,9 +12,10 @@
// getParams -- so this always leaves first when already subscribed, then resubscribes.
import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
import { getGroup } from '@/lib/api/groups';
import { MESSAGES_PAGE } from '@/lib/config';
import { getTinode, onReauthenticated } from './connection';
import { getTinode, onReauthenticated, onTinodeReset } from './connection';
const topicListeners = new Map<string, Set<() => void>>();
const anyTopicListeners = new Set<() => void>();
@@ -86,10 +87,65 @@ export function markConfirmedRead(topicName: string, seq: number) {
if (seq > prev) confirmedReadSeq.set(topicName, seq);
}
// Live-confirmed root cause of a real reported bug ("聊天消息框滚动到顶部或底部时UI会抖动闪烁"):
// `topic.onData = notify` (wireTopicCallbacks below) fires once per INDIVIDUAL message, not once per
// batch -- confirmed by instrumenting a real `loadOlder()` page fetch (24 older messages): the DOM
// mutated in ~14 separate small steps over ~400ms, each one growing `scrollHeight` a little and each
// one racing the OTHER open effect that reads it (loadOlder's own scroll-compensation, and
// ConversationView's separate "autoscroll to bottom" effect) -- neither gets a stable, final
// `scrollHeight` to work with until the whole page has actually finished arriving, so each of the ~14
// intermediate re-renders nudges `scrollTop` again, which is what actually reads as "shaking" rather
// than one clean jump. This wasn't reproducible by reasoning about a single `getMessagesPage()` call
// in isolation -- it only showed up instrumenting `scrollTop` across a real fetch.
// Fix: coalesce any notifications that land within the same animation frame into one flush, so a
// burst of N `onData` calls (initial page load, `loadOlder()`, or a fast run of incoming messages)
// triggers at most one re-render per frame instead of N -- imperceptible as a delay, but it means
// `scrollHeight` is only ever read by a re-render once it reflects everything that arrived so far in
// that frame, not some in-between partial state. `messageSnapshots.delete` stays synchronous (it's
// pure cache invalidation, not a re-render) so a snapshot read between two notifications in the same
// frame still sees the latest data, only the LISTENER callbacks (which is what schedules React's own
// re-render) are what get batched.
const pendingTopicNotifications = new Set<string>();
let pendingAnyNotification = false;
let notifyFrameScheduled = false;
function flushTopicNotifications() {
notifyFrameScheduled = false;
const topics = [...pendingTopicNotifications];
pendingTopicNotifications.clear();
const flushAny = pendingAnyNotification;
pendingAnyNotification = false;
for (const topicName of topics) {
topicListeners.get(topicName)?.forEach((cb) => cb());
}
if (flushAny) {
anyTopicListeners.forEach((cb) => cb());
}
}
function notifyTopic(topicName: string) {
messageSnapshots.delete(topicName);
topicListeners.get(topicName)?.forEach((cb) => cb());
anyTopicListeners.forEach((cb) => cb());
pendingTopicNotifications.add(topicName);
pendingAnyNotification = true;
if (!notifyFrameScheduled) {
notifyFrameScheduled = true;
// Live-confirmed real bug (surfaced while testing an unrelated group-rename fix, in a webview whose
// frame compositing was paused, i.e. genuinely not painting -- not just an ordinary backgrounded
// browser tab): a `requestAnimationFrame` scheduled while the page isn't compositing frames can go
// uncalled indefinitely rather than merely deferred-until-visible. Since `notifyFrameScheduled` only
// resets to false inside the callback, one dropped frame permanently wedges this guard, silently
// freezing EVERY future topic-list update (not just the group name) until a full page reload -- data
// kept arriving and updating correctly underneath (confirmed live: `me.contacts()` had the right
// data the whole time), only the UI never found out. `document.hidden` costs nothing to check and
// `setTimeout` is never silently dropped the way this rAF was, so route through it whenever the page
// isn't visible; keep `requestAnimationFrame` for the visible case since that's what actually gives
// the intended one-flush-per-paint batching this function exists for.
if (typeof document !== 'undefined' && document.hidden) {
setTimeout(flushTopicNotifications, 0);
} else {
requestAnimationFrame(flushTopicNotifications);
}
}
}
export function subscribeTopicChanges(topicName: string, callback: () => void): () => void {
@@ -110,7 +166,7 @@ export function subscribeAnyTopicChange(callback: () => void): () => void {
}
export interface RawTopicInfo {
what: 'read' | 'recv' | 'kp' | 'call';
what: 'read' | 'recv' | 'kp' | 'call' | 'im';
seq?: number;
from?: string;
event?: string;
@@ -128,6 +184,35 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI
return () => infoListeners.delete(callback);
}
/**
* The SDK only routes an incoming {info} packet into Topic._routeInfo (and from there to
* topic.onInfo -> the infoListeners fanout above) when the packet's topic name is already in the
* client's topic cache -- tinode.dev.js's dispatcher does `#cacheGet('topic', pkt.info.topic)` and
* silently drops the frame on a miss. Friend-request signals (Zentao #3) arrive with `topic` set to
* the RECIPIENT'S OWN user id (publishIMRelationEvent -> sessionStore.DeliverToUser, i.e. addressed
* to the user's `me` topic), but the me topic is cached under the literal "me" -- so that lookup
* misses every time and the frame never reached any info listener. Live-confirmed 2026-09-18: the
* SDK's own logger showed the raw `friend.request.rejected` frame arriving on the socket while
* AppShell's subscribeTopicInfo callback never fired. The connection-level onInfoMessage hook sees
* EVERY info packet regardless of cache state, so forward ONLY the frames the topic-level path will
* not deliver (isTopicCached false) to the same infoListeners: cached topics keep flowing through
* topic.onInfo exactly once (no duplicates), uncached ones stop being silently lost. Both members
* are real SDK runtime members absent from the hand-maintained .d.ts (same cast rationale as the
* read/unread reads in getChatListSnapshot). Installed from subscribeMeTopic -- the one point every
* login flows through -- and re-installed there after resetTinode() rebuilds the instance.
*/
function installUncachedInfoFallback(t: unknown) {
const sdk = t as {
onInfoMessage?: ((info: RawTopicInfo & { topic: string }) => void) | null;
isTopicCached?: (topicName: string) => boolean;
};
sdk.onInfoMessage = (info) => {
if (info && sdk.isTopicCached && !sdk.isTopicCached(info.topic)) {
infoListeners.forEach((cb) => cb(info.topic, info));
}
};
}
const goneListeners = new Set<(topicName: string) => void>();
const newGroupListeners = new Set<(topicName: string) => void>();
@@ -240,6 +325,43 @@ function wireTopicCallbacks(topic: Topic) {
if (groupEvent?.what === 'group' && groupEvent.event === 'group.dissolved') {
markTopicGoneAndNotify(topic.name);
}
// docs/IM-前后端对接文档.md:416,1256: recall/edit push a custom `{info what:"im",
// event:"message.recalled"|"message.edited", payload:{seq,version}}` to every other ONLINE
// topic member -- explicitly NOT a native Tinode mechanism (`_routeInfo`'s switch has no `"im"`
// case, confirmed reading tinode.dev.js the same way the group.dissolved gap above was), so
// nothing here ever picked it up before. `notify()` above still fired (it's unconditional), so a
// component watching this topic WOULD re-render -- but the SDK's own local message cache was
// never told the content changed, so it re-rendered the SAME stale pre-recall/pre-edit bubble.
// The doc's own instruction is explicit: "按 topic+seq+version 刷新本地消息,不要等待用户重新
// 进入会话才更新" -- live-confirmed this was exactly what was happening instead (recall/edit only
// ever showed up for the OTHER party after something unrelated caused a full resync, e.g.
// reopening the conversation). Reuses the exact `getMeta(...).withData(seq,seq+1,1)` targeted
// single-message refetch ConversationView's own jump-to-message feature already established.
if (info?.what === 'im' && (info.event === 'message.recalled' || info.event === 'message.edited')) {
const seq = info.payload?.seq;
if (typeof seq === 'number') {
topic.getMeta(topic.startMetaQuery().withData(seq, seq + 1, 1).build()).catch(() => {});
}
}
// docs/IM-前后端对接文档.md:1542 documents this push as `group.profile.updated`, but a real rename,
// live-instrumented (logging the raw `info` object as it arrived), showed the actual wire event is
// `{what:"group", event:"group.settings.updated"}` -- same doc-vs-wire drift already seen elsewhere
// in this backend. Matching the real event name fixes only half the bug: the sidebar/header name
// comes from `contact.public?.fn` (native SDK state, see ChatListColumn.tsx), which a REST-only
// mutation never touches for anyone, and the "obvious" fix -- `getMeta(startMetaQuery().withDesc()
// .build())`, the SDK's own sanctioned way to re-pull desc from the server -- was live-instrumented
// too and found to be genuinely broken against this backend: a consistent 504 timeout (~5.4s, every
// attempt, still failing 10+s after the write) on a brand-new topic, and on an established topic a
// fast-but-stale response whose `public.fn` still held the pre-rename name. Using the already-
// reliable REST `getGroup()` instead (confirmed live to return the fresh name immediately after a
// rename, every time) and applying it locally via `_processMetaDesc` -- the same internal method the
// SDK itself calls when a real `{meta desc}` response arrives, live-confirmed to correctly reach
// `me.contacts()` -- sidesteps the broken path entirely instead of routing through it.
if (groupEvent?.what === 'group' && groupEvent.event === 'group.settings.updated') {
getGroup(topic.name)
.then((data) => topic._processMetaDesc({ public: { fn: data.name, name: data.name } }))
.catch(() => {});
}
};
topic.onMetaDesc = notify;
// Only ever actually invoked on the `me` topic (the SDK reads `me.onMetaSub`/`me.onSubsUpdated`
@@ -283,7 +405,9 @@ let inFlightMeSubscribe: Promise<unknown> | null = null;
/** Call once at session start (after login) -- chat-list metadata only, no message bodies. */
export function subscribeMeTopic(): Promise<unknown> {
if (inFlightMeSubscribe) return inFlightMeSubscribe;
const me = getTinode().getMeTopic();
const tinode = getTinode();
installUncachedInfoFallback(tinode);
const me = tinode.getMeTopic();
wireTopicCallbacks(me);
// Read/recv/unread updates (e.g. from noteRead() when opening a conversation) travel through
// TopicMe's OWN single-slot callback, separate from onData/onInfo/onPres -- wireTopicCallbacks
@@ -552,6 +676,25 @@ onReauthenticated(() => {
}
});
/**
* Real, reproduced bug (2026-09-25 review): every module-level cache below is scoped by topic NAME,
* not by account -- and topic names collide across accounts by construction, not edge case (`'slf'`
* is the literal self-chat topic name for every account, `isChatListTopicName` below). Without this,
* logging out and into a DIFFERENT account in the same tab (no full reload -- resetTinode() itself
* never reloads the page) carries the previous account's cached state forward: `confirmedReadSeq`
* specifically was confirmed to silently mark the new account's genuinely-unread 'slf' messages as
* read the next time reconcileMeSubs() ticked, since it read the OLD account's higher confirmed seq
* as a floor for the new account's much-lower real one. Same reasoning extends to every other cache
* here: none of them should outlive the session that populated them.
*/
onTinodeReset(() => {
confirmedReadSeq.clear();
previewPrefetched.clear();
messageSnapshots.clear();
goneTopicNames.clear();
chatListSnapshot = null;
});
const ME_RECONCILE_INTERVAL_MS = 30_000;
let meReconcileTimer: ReturnType<typeof setInterval> | null = null;
@@ -626,6 +769,24 @@ function reconcileMeSubs() {
c.unread = prev.unread;
notifyTopic(c.topic);
}
// Separate, real bug this same guard doesn't cover (live-confirmed 2026-09-24, reported
// directly): sending your OWN message into a topic that's already open bumps `confirmedReadSeq`
// synchronously (ConversationView's noteRead effect), long before the async `{note read}` this
// relies on has actually reached the server. A `{get}` already in flight since before that
// moment can come back with a real, ADVANCED `seq` (the new message the server just accepted)
// paired with a `read` that hasn't caught up server-side yet -- `seq <= prev.seq` above is
// false (seq genuinely changed), so that guard correctly doesn't fire, and this spurious
// `unread:1` gets applied verbatim even though the user is the one who just sent it. Clamp
// `read` to what THIS client already knows it has confirmed seeing, regardless of what the
// fetch says -- a floor, never pushes `read` past the server's own value when the server is
// genuinely ahead of this record (e.g. read from a different, already-synced device).
const confirmed = confirmedReadSeq.get(c.topic) || 0;
if (confirmed > (c.read || 0)) {
c.read = confirmed;
if ((c.recv || 0) < confirmed) c.recv = confirmed;
c.unread = Math.max(0, (c.seq || 0) - confirmed);
notifyTopic(c.topic);
}
}
})
.catch(() => {});
+32 -12
View File
@@ -35,11 +35,17 @@ interface CallState {
// dynamic Agora SDK import -> client.join() -> createCameraVideoTrack()) hadn't resolved yet, and
// never re-fire once it did, leaving the local preview box permanently blank for the whole call.
hasLocalVideo: boolean;
// `'busy'` is never a real PERSISTED `head.webrtc` value (confirmed live, see dispatch.ts's own
// comment) so it's deliberately NOT part of `CallWebrtcState` -- but it IS a real value this app's
// own `sendCallHangup(..., 'busy')` puts on the LIVE, ephemeral `{info hang-up}` signal a second
// caller receives, which flows straight into this same field.
endReason: CallWebrtcState | 'busy' | null;
// Set once `joinCall()` actually resolves (mic track created and published, or for video, mic+
// camera) -- distinct from `phase==='connected'`, which flips the instant the SIGNALING accept
// arrives, before any media has actually joined. `CallWindow`'s status line previously showed a
// ticking duration + "网络良好" from the moment `phase` became 'connected', which live-confirmed
// stays true even when the mic never actually attaches at all (permission denied/no device) -- a
// real user reported this as "看起来接通了" for a call where no audio ever worked on either side.
mediaJoined: boolean;
// `busy` is now part of CallWebrtcState itself (2026-09-25: confirmed against real iOS/Android
// source that the server genuinely uses this value, not just this app's own live
// `sendCallHangup(..., 'busy')` signal a second caller receives) -- no separate union needed here.
endReason: CallWebrtcState | null;
/** Was THIS session the one that placed the call (via startCall), not the one that received it --
* needed to pick "已取消" vs "未接听" for the same wire value the same way dispatch.ts's
* callResultDisplay already does for the permanent chat-bubble record of the same call. */
@@ -58,7 +64,7 @@ interface CallState {
switchCamera: () => Promise<void>;
setHasRemoteVideo: (v: boolean) => void;
_reset: () => void;
_endWithReason: (reason: CallWebrtcState | 'busy') => void;
_endWithReason: (reason: CallWebrtcState) => void;
}
const IDLE = {
@@ -74,6 +80,7 @@ const IDLE = {
connectedAt: null,
hasRemoteVideo: false,
hasLocalVideo: false,
mediaJoined: false,
endReason: null,
wasCaller: false,
mediaError: null,
@@ -123,7 +130,7 @@ export const useCallStore = create<CallState>((set, get) => ({
const join = await joinRtcCall(topic, callId, seq, callType, 'callee');
if (get().callId !== callId) return;
await joinCall(join, callType, (v) => get().setHasRemoteVideo(v));
if (get().callId === callId) set({ hasLocalVideo: callType === 'video' });
if (get().callId === callId) set({ hasLocalVideo: callType === 'video', mediaJoined: true });
} catch {
// Same guard the structurally-identical caller-side accept handler below already has, for the
// identical reason: `callId` here is the ORIGINAL id this whole acceptCall() call was for,
@@ -138,7 +145,14 @@ export const useCallStore = create<CallState>((set, get) => ({
// just only when THIS call is still the live one.
if (get().callId !== callId) return;
const { topic: currentTopic, seq: currentSeq } = get();
if (currentTopic && currentSeq != null) sendCallHangup(getTinode().getTopic(currentTopic), currentSeq, callId);
// `reason: 'disconnected'` (not the default no-reason hang-up) so the OTHER side's own
// `info.event==='hang-up'` handler can tell this apart from a normal hang-up too -- without it,
// the peer's `connectedAt` is already truthy (set the moment accept/accept-event landed, same
// as this side's), so its own reason-mapping fell through to 'finished' and both the live
// overlay AND the permanent chat-history record showed a normal "通话时长" for a call where
// media never actually worked on either end. Live-confirmed: both sides recorded "通话时长
// 00:03" for a call that failed getUserMedia immediately after accept.
if (currentTopic && currentSeq != null) sendCallHangup(getTinode().getTopic(currentTopic), currentSeq, callId, 'disconnected');
leaveCall();
get()._endWithReason('disconnected');
}
@@ -321,7 +335,7 @@ export function initCallSignaling() {
.then(async (join) => {
if (useCallStore.getState().callId !== callId) return;
await joinCall(join, callType, (v) => useCallStore.getState().setHasRemoteVideo(v));
if (useCallStore.getState().callId === callId) useCallStore.setState({ hasLocalVideo: callType === 'video' });
if (useCallStore.getState().callId === callId) useCallStore.setState({ hasLocalVideo: callType === 'video', mediaJoined: true });
})
.catch(() => {
// Same reasoning as acceptCall()'s own catch branch: `connectedAt` was already set (right
@@ -330,7 +344,9 @@ export function initCallSignaling() {
// for a call that never actually connected media at all -- go straight to the honest
// 'disconnected' outcome instead, and still tell the peer so their UI doesn't hang.
if (useCallStore.getState().callId !== callId) return;
if (joinTopic && seq != null) sendCallHangup(getTinode().getTopic(joinTopic), seq, callId);
// `reason: 'disconnected'` -- same fix as acceptCall()'s structurally identical catch,
// so the callee's own hang-up handler maps this to 'disconnected' instead of 'finished'.
if (joinTopic && seq != null) sendCallHangup(getTinode().getTopic(joinTopic), seq, callId, 'disconnected');
leaveCall();
useCallStore.getState()._endWithReason('disconnected');
});
@@ -346,8 +362,12 @@ export function initCallSignaling() {
// already been sending a real `reason:'declined'` all along, just never read back out here),
// and a caller who cancelled before pickup made the CALLEE's overlay wrongly show "通话已结束"
// instead of "未接听" -- mirrors the SAME connectedAt-based finished-vs-missed distinction the
// local hangUp() above already makes for its own no-reason-given case.
useCallStore.getState()._endWithReason(reason === 'busy' ? 'busy' : reason === 'declined' ? 'declined' : connectedAt ? 'finished' : 'missed');
// local hangUp() above already makes for its own no-reason-given case. `'disconnected'` is the
// same fix, for the media-join-failed case: acceptCall()/the accept-handler's own catch above
// now send this explicit reason so the OTHER side shows "连接异常" too, not a fake "通话已结束".
useCallStore
.getState()
._endWithReason(reason === 'busy' ? 'busy' : reason === 'declined' ? 'declined' : reason === 'disconnected' ? 'disconnected' : connectedAt ? 'finished' : 'missed');
}
});
}
+23
View File
@@ -0,0 +1,23 @@
import { create } from 'zustand';
/**
* "Tap any avatar/name -> profile popup", app-wide (message bubbles, @mentions inside message text,
* group member rows/grid, etc.) -- one global store + a single `<UserCardRoot>` mounted once at the
* app root (App.tsx), the same shape as `stores/confirmDialog.ts`/`stores/calls.ts`: callable from any
* event handler with no natural "own a piece of JSX for this" home (a Drafty-rendered mention span
* deep inside message content, for one), not a per-component `useState`.
*/
interface UserCardState {
userId: string | null;
}
export const useUserCardStore = create<UserCardState>(() => ({ userId: null }));
export function openUserCard(userId: string) {
if (!userId) return;
useUserCardStore.setState({ userId });
}
export function closeUserCard() {
useUserCardStore.setState({ userId: null });
}
+2 -2
View File
@@ -27,8 +27,8 @@ export default defineConfig({
// straight to the tenant's real host regardless (confirmed working this way throughout the old
// app's development too).
proxy: {
'/v1/im': { target: 'https://api.118im.com', changeOrigin: true, secure: true },
'/v0/file': { target: 'https://api.118im.com', changeOrigin: true, secure: true },
'/v1/im': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
'/v0/file': { target: 'http://127.0.0.1:6060', changeOrigin: true, secure: false },
},
},
})