Compare commits

...
40 Commits
Author SHA1 Message Date
Your Name 0c5b329179 修复Zentao批量报告的6个前端bug:来电UI不弹出/黑名单发送无提示/群名校验/群人数与通话记录显示uid
用户提供了一份Zentao导出的bug清单(#21/23/24/25/26/27/28共7条),逐条实机复现
排查,修复了其中6个真正的前端bug,1个是纯后端问题(禁言不生效)已记录到
docs/IM-后端问题清单.md,1个(好友申请404/500)未能独立复现只记录了测试团队
的原始报告,另1个(离线状态推送)实测后确认并非报告描述的"永久不更新",只是
有正常的心跳超时延迟。

- **#23 来电UI完全不弹出**(根因最深的一个):initCallSignaling用的是
  `me.getMeTopic().contacts()`枚举联系人再查最新消息——但这个方法只反映`me`
  话题自己的订阅同步进度,一个P2P话题可能早就真正订阅上、能收到消息,`me`的
  同步却还没跟上,导致这个联系人在枚举里直接消失。用两个真实账号实机验证:
  被叫方`me.contacts()`只枚举出1个群,主叫方完全没出现,来电消息静默丢失。
  topics.ts里`getChatListSnapshot()`自己的注释早就写明了这个坑、并且已经改
  用`mapTopics()`规避,只是`stores/calls.ts`这个监听器当时没有跟着改。现在
  统一复用`getChatListSnapshot()`,实机验证来电卡片正确弹出。

- **#28 拉黑后发送消息无原因提示**:读tinode-sdk源码确认发送失败的具体原因
  从架构上就拿不到——`Topic#publishMessage`自己的`.catch()`把服务端拒绝原因
  吞掉、只留一个`_failed`标记,不会重新抛出。改成事前判断:新增
  `getConversationContext()`(P2P专用,`relationship`字段区分
  `blocked_by_me`/`blocked_by_peer`),发送区直接换成对应文案的横幅,两个
  方向都拿真实账号互相拉黑验证过。

- **#26 群名超长/留空自动名两个校验问题**:`MAX_TITLE_LENGTH`一直是60,但
  后端`im_http.go`真实校验的是32字(读源码确认),导致33-59字之间的群名能
  输入、能提交,然后被后端拒绝只显示通用错误。改成32,从输入框这一层就不
  再放行超长名。另外群名留空时自动拼接的成员昵称组合,理论上可能被内容审核
  拒绝——这种情况下用户根本不知道"名字"是问题所在,补了针对性提示。

- **#27 通话记录/群列表显示原始uid、群人数显示0人**(2处独立bug):
  1. `CallHistoryView.tsx`按`peer_user_id`(真实usr id)去匹配聊天列表的
     `contact.topic`(建立过真实会话后已经是服务端重命名过的p2p哈希),两个
     不是同一个id空间,永远匹配不上,一直显示原始id。改成用`item.topic`
     (已确认和chatlist的topic是同一个值)去匹配。
  2. 实测发现`GET /groups`列表接口的`member_count`会返回0,但同一个群
     `GET /groups/{topic}`单独查询正确返回4——两个接口的计数器不一致(后端
     已知问题,见问题8),前端这次统一改成"读到0就不显示人数"而不是显示一
     个已确认错误的数字。

以上改动均跑过`tsc -b --noEmit`/`oxlint`/`vite build`,除#26外全部用两个真实
账号(systest02/systest04)互相操作实机验证。
2026-09-17 21:51:00 +08:00
Your Name 50767f701f 修复已建立的P2P会话详情栏/头像404:话题名不是对方user_id
用户报告:控制台反复出现 GET /v1/im/users/p2pp77kUAkgqpVbZoiTpjPWtg/profile
404。根因:这个应用的多处代码(DetailColumn的好友资料/拉黑/备注/删除好友,以及
本会话早前刚加的头像peerProfileQuery)一直假设"P2P会话的topicId就是对方的
user_id"——这个假设只在"第一次用对方usr...地址打开、还从未真正subscribe过"
这个短暂窗口内成立。读tinode-sdk源码确认:一旦subscribe成功,SDK会把这个话题
从usr...地址重命名为服务端分配的真实p2p...哈希名,而Topic.topicType()自己的
前缀表里根本没有p2p这一项(只有usr映射到P2P类型),导致topic.isP2PType()/
topic.p2pPeerDesc()对已建立的会话恒定返回false/undefined——SDK自己内建的"取
对方身份"方法在这个场景下也是坏的,不只是这个项目自己的代码有问题。

只要一个P2P会话有过真实的消息历史(几乎所有现存对话都是这样),它的topicId
就是这个不能反解的p2p...哈希,而不是对方user_id,直接拿去查/v1/im/users/{id}/
profile、匹配好友列表的user_id、传给拉黑/删除好友接口,全部会用错id。

修复:topics.ts新增getP2PPeerUserId(topic, myUserId)——topicId本身是usr...
前缀时直接返回(还没subscribe过,本来就对),否则读topic.subscribers()真实
的订阅者列表,找出不是自己的那个人的真实user_id;订阅者列表还没加载出来时返回
undefined而不是猜一个不可能对的值,调用方原本就有的enabled门控会正确地等待
而不是提前拿错误id发请求。tinode-sdk.d.ts补充subscribers()方法和Subscription
类型声明(原来完全没声明这个方法)。DetailColumn.tsx/ConversationView.tsx改用
这个真实user_id去查资料/匹配好友/头像取色,不再直接假设topicId能用。

实机验证:真实会话grpMc5uN...不对,是p2pp77kUAkgqpVbZoiTpjPWtg(systest04↔
systest02,有真实历史消息的老会话)——修复前详情栏"ID:"整行空白、404貫穿控制
台;修复后详情栏正确显示"ID:usrp77kUAkgqpU",好友资料/备注/拉黑状态全部正确
关联到真实好友记录。
2026-09-17 12:52:44 +08:00
Your Name 425b06aa71 消息列表和聊天窗口接入真实上传头像,替换字母色块(联系人页此前已接入)
用户报告:头像上传后,联系人页面已经能正确显示,但消息列表(会话列表)和
聊天对话框(顶部头像+消息气泡头像+详情栏)仍然只显示字母色块。

根因:联系人页面(ContactsListColumn/FriendProfileView等)走的是REST
/friends、/users/{id}/profile这些接口,响应里天然带avatar_file_id字段,
接的是同一个Avatar组件。但消息列表/聊天窗口这条线读的是Tinode SDK本地维护的
话题缓存(me.contacts()/topic.public),这个对象里根本没有avatar_file_id
这个字段(那是REST专属字段,SDK的public.fn/public.photo走的是完全不同的同步
机制)——所以即使Avatar组件本身没问题,这几个位置也从来没被塞过真实的
avatar_file_id,只能落到字母兜底分支。

修复:在这几处渲染点旁路补一份轻量的id->avatar_file_id映射,全部复用已经在
发的请求(不新增网络开销):
- ChatListColumn.tsx(会话列表):群聊复用自己已有的groups查询(本来就为了
  显示"N人"徽标在发),单聊新增一个与ContactsListColumn同key的/friends查询
  (对方是好友时才有数据,走查询缓存去重)。
- ConversationView.tsx(聊天窗口顶部+消息气泡):群聊复用自己已有的group
  profile查询;P2P peer新增一个与DetailColumn同key的/users/{id}/profile
  查询;"我"发的消息复用(不再限定isSelf才拉)已有的/me查询。
- DetailColumn.tsx(P2P详情栏大头像):本来就在查/friends和/user-profile,
  只是从来没把里面的avatar_file_id接到头像上。
- GroupDetailColumn.tsx(群详情栏大头像+成员格子头像,随上一条commit一起
  提交):同样是本来就在查的group/members数据里现成的字段。

Avatar.tsx新增可选的fallbackBgClass prop——"我"发的消息气泡头像,字母兜底
色块设计上固定是强调色而不是按id哈希取色,原来内联写死bg-accent,现在通过
这个prop走统一组件不用另外复制一份渲染逻辑。

已知未覆盖(跟此前记录的已知缺口一致,本次未处理):群成员选择器/邀请弹层/
群管理弹层等曝光度更低的头像位置仍是字母色块。

重要:本次修复只是把avatar_file_id正确传给了Avatar组件——实机测试时发现
GET /v0/file/s/{id}这个下载接口目前会302重定向到一个跨域地址,
useAuthedBlobUrl用fetch+鉴权头去请求时,浏览器跟着重定向到那个跨域地址后
被CORS拦截(Failed to fetch,redirect:'manual'下确认type是opaqueredirect)。
用三个不同账号/不同file_id复测结果一致,不是个例。也就是说这条修复目前
"接线是对的",但只要这个后端问题不解决,任何地方(含联系人页、头像上传页
本身)新上传的头像实际都不会真的显示成图片,只会退回字母色块——这是后端
问题,按惯例记录不在这个仓库里修。
2026-09-16 23:37:31 +08:00
Your Name 804618ad63 修复群聊解散通知覆盖不全:主动退出/解散群聊会误报"该群聊已解散"且留下幽灵会话
前情:上一版修复只覆盖了"被动看着群被别人解散"这一种场景(后端走自定义
{info what:"group" event:"group.dissolved"}事件而非原生{pres what:"gone"},
需要单独识别)。这次用户实测发现同一个"话题彻底消失后清理不干净"的问题在另外
两条路径上都复现了:自己点"退出群聊"、自己(群主)点"解散群聊"。

根因:这两个操作各自的mutation.onSuccess只是navigate('/messages'),没有做任何
本地缓存清理;而服务端在REST调用成功后,通常还会异步地给这条已经不再属于你的
订阅补发一个原生/自定义的"gone"信号——这个信号触发的是topics.ts里那条本该只用
于"被动围观"场景的goneListeners通知链,于是ConversationView的通用"该群聊已
解散"提示被复用到了并不贴切的"我自己退出/解散"场景上,且同一个SDK缓存stub没
被清理的老毛病(getTopic()被再次调用就会重建一个空壳)也照样在这两条路径上
重演,表现为消息列表里多出一个显示原始群ID、"1970/1/1"的幽灵会话。

修复:
- topics.ts的markTopicGoneAndNotify新增goneTopicNames去重判断——只要
  forgetTopic已经先标记过这个话题,稍后到达的原生/自定义gone信号就直接跳过
  (包括不再触发goneListeners),避免重复通知。
- GroupDetailColumn.tsx的leaveMutation/disbandMutation改成onSuccess里先调用
  forgetTopic(topicId)(清缓存+加入排除名单+让上面的守卫生效)再navigate,
  不再空等异步信号来做清理。

实机验证:systest04建群拉systest02入群 -> systest02主动退出,返回消息列表后
侧栏不再残留幽灵会话;systest04(群主)解散同一个群,同样返回消息列表且侧栏
干净。顺带在GroupDetailColumn.tsx里把群头像/成员格子头像接上了真实上传头像
(见下一条commit的头像修复,这个文件因为改动集中放在了一起)。
2026-09-16 23:36:35 +08:00
Your NameandClaude Sonnet 5 df22f26628 修复刷新/深链进入已解散的群聊时显示错乱,改成直接跳回消息列表
用户实机截图报告:一个群被解散后,如果直接刷新页面(或深链)停留在这个群的
URL上,标题栏/侧边栏显示原始topic id(不是群名)、"1970/1/1"的时间戳、
"0人"成员数,而不是跳转离开。

这跟已有的"正在查看时被实时解散"处理(subscribeTopicGone/onDeleteTopic)
不是同一条路径——那个只覆盖"已经订阅成功、活跃看着的时候被解散",覆盖不了
"这次页面加载压根没订阅成功过"的情况。实机复现确认:openConversation()对
一个已解散群的{sub}请求会正确收到404 CommError拒绝,但ConversationView
原来的catch只弹一个通用的"加载会话失败,请重试"提示,不会跳转,会话视图就
停留在一个半初始化的空壳状态。

修复:新增isTopicGone()识别这个404,命中后跟"实时解散"走一样的用户体验
(toast提示+跳转回/messages)。同时发现并修复了一个连带的清理问题——单纯
调用getTinode().getTopic(name)(openConversation内部一开始就会调,不管
后面订阅成不成功)就已经在SDK本地缓存里留下一个空壳对象,光是这个空壳存在
就足以让侧边栏显示出这条错乱的记录;而清理这个空壳时又跟ConversationView
自己卸载时的closeConversation()清理逻辑(它自己也会重新调用getTopic())
产生竞态——直接删除后又被重新创建。改成维护一个"确认已消失"的名单,让聊天
列表的过滤逻辑直接按名字永久排除,不再依赖谁先执行、谁后执行。

实机验证:真实解散一个群后,直接deep-link/刷新回这个群的URL——确认页面
立即跳转回/messages,且侧边栏不再出现这条已解散群的残留记录。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:49:17 +08:00
Your NameandClaude Sonnet 5 c53e6552eb 补全群邀请功能的接收方一侧:系统消息里的群邀请通知现在能真正接受
追查"邀请功能不完善"时发现的更大缺口:群邀请在需要审批的情况下(群自身的
进群验证,或被邀请人自己的隐私设置要求验证)不会立即拉人入群,而是创建一条
真正的邀请记录,通过系统消息投递给对方(event_type: group.invitation.created,
action.type: group_invitation_accept,带invitation_id)——但这条系统消息
此前完全没有专门处理,走的是所有group.*事件通用的"查看群聊"跳转逻辑,直接
导航到一个对方根本还不是成员的会话,大概率打不开。

协议文档把POST /v1/im/group-invitations/{id}/accept标成"待实现",和今天
早些时候发现的草稿持久化接口是同一种文档滞后于后端实际进度的情况——读了
im_be的serveIMGroupInvitationResource源码确认这个接口真实可用。

actionRoute()现在对这个特定事件类型不再返回通用的导航链接,卡片里改成渲染
一个真正的"接受邀请"按钮(阻止事件冒泡,避免和卡片自身的整体点击-标记已读
逻辑冲突),点击后调用accept接口,成功直接跳转到这个群(这时候已经是成员
了),失败在卡片上原地显示失败原因。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:26:29 +08:00
Your NameandClaude Sonnet 5 66dad284c7 修复邀请入群交互:点击邀请要直接进入邀请,而不是先看成员列表再点第二次
用户实测反馈的真实bug:GroupDetailColumn里那个专门的"+邀请"图标(虚线边框
方块,只有canInvite为真才显示——这个权限门控本身一直是对的)点击后走的是
setOpenModal('members'),和"查看全部成员"是完全同一个入口——弹出的是成员
列表,标题栏里那个真正的"邀请"按钮还要再点一次才能看到可邀请的联系人列表。

GroupMembersModal新增initialView属性,"invite"直接以邀请视图打开(跳过
成员列表这一步),"查看全部"/"查看全部成员"两处不受影响仍然打开成员列表。

同时补全了邀请结果的反馈——原来邀请成功后只做了查询失效,无论是"立即加入"
还是需要对方/群设置审批的"待确认",界面上都没有任何区别于点击前的视觉反馈,
容易让人怀疑是不是点了没反应从而重复点击。读了im_be的serveIMGroupInvitations
源码(不是协议文档的单一示例)确认真实的单人邀请结果有joined/already_member/
pending/pending_approval/failed五种可能,现在每种都有对应的行内文案,
"failed"不计入"已处理"状态,允许原地重试。

实机验证:全新建群后点"+邀请"直接进入邀请视图(标题"邀请成员",不经过成员
列表);邀请一个全新好友后确认成功加入、群成员数正确从2变3、界面正确显示
结果反馈。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:26:12 +08:00
Your NameandClaude Sonnet 5 3fab8b08b9 被拉入新群时标题栏/桌面通知没有任何提示——扩展兜底同步机制覆盖这个场景
reconcileMeSubs()本来就能发现"此前完全不认识的话题"(今天早些时候为解决群聊
被动成员看不到新群加的兜底机制),但发现后只是原样信任服务端返回的read/unread
字段——服务端对一个刚加入的新成员,默认给的读游标经常是"视为已读"(read==seq),
如果这个群目前恰好一条消息都没有,unread就会是0,导致今天新加的标题栏滚动/
favicon角标(由同一个未读数驱动)完全没有反应,被拉群这件事本身悄无声息。

改成:对每一个新发现的话题,不管服务端给的read游标是什么,一律按"我自己完全
没确认过"处理(read清零,unread按当前seq计算);如果是群,额外通过
subscribeNewGroupDiscovered(新增的发布-订阅,push.ts订阅它去弹真正的桌面
通知"你被拉入了这个群聊",因为topics.ts不能反向依赖push.ts)触发一条独立的
桌面通知,不依赖未读数是否恰好大于0。

实机验证:systest04建一个只邀请systest05的全新群,systest05这边模拟切回
标签页触发兜底同步后,确认收到桌面通知(title/body/tag均正确)且标题栏
未读数正确增加。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:25:55 +08:00
Your NameandClaude Sonnet 5 f570483b62 切换到会话时输入框自动聚焦
匹配主流IM客户端的习惯——切进一个聊天窗口应该能直接开始打字,不需要先手动
点一下输入框。只在topicId变化时聚焦一次,不依赖messages(否则每来一条新
消息就会把正在做其他操作的用户的焦点抢走)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:25:38 +08:00
Your NameandClaude Sonnet 5 3df61e9e59 接入服务端消息草稿持久化,修复切换会话/刷新页面丢失未发送草稿(禅道#19)
协议文档docs/IM-前后端对接文档.md:1003/1247把`PUT /v1/im/topics/{topic}/draft`
标注成"待实现"、把本地保存写成默认方案——但读im_be自己的源码(server/im_http.go
的serveIMTopicDraft,接的是store.Conversations.GetDraft/PutDraft)确认这个
接口其实真实可用,文档只是没跟上后端进度。之前草稿只有draftByTopic这一份纯
内存状态,没有任何持久化,刷新页面或者(取决于路由是否把ConversationView
整个卸载)切换会话都可能把草稿丢光。

实现:打开一个会话时(每个topic每个会话只拉一次)用GET拉取服务端草稿,如果
本地这个session里还没有内容才用它填充(本地正在打的字优先,不会被服务端的
旧快照打断);输入停止1.5秒后用PUT保存(带乐观锁version),不是按键就存;
切换到别的会话或整个离开消息页面时立即flush掉还没来得及等到防抖的最新内容,
不会因为切太快而丢最后几个字。加了"最后一次确认同步的内容"记录,避免"打开
会话→加载已有草稿→内容变化触发保存"这个误判成"用户编辑了"从而立刻把同一份
内容又存一遍的多余请求。

实机验证(systest04账号,因为systest01/systest02当时正被用户在真实浏览器里
测试):
- 刷新页面:输入框正确恢复之前的草稿内容
- 切到另一个具体会话再切回:正确恢复(走的是服务端往返,不是本地状态留存,
  切换瞬间有个最多一两秒的短暂加载间隙,之后正确填充)
- 输入后不到1.5秒就切走:flush机制确认立即保存,没有丢内容
- 加载已有草稿本身不会触发多余的重复保存(验证了修复前这个问题真实存在——
  版本号无缘无故多跳了一次)
- 发送消息后:本地和服务端的草稿都正确清空

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 21:55:55 +08:00
Your NameandClaude Sonnet 5 4bf4e75cac 修复会话开着但标签页在后台时消息被SDK自动标记已读、未读提示永远不触发的问题
用户实测报告:systest01打开着与systest02的会话,切到别的标签页后,systest02
发消息过来,systest01这边的标签页标题完全没反应。追查后发现根因比表面看到的
更深——这不是标题栏本身的bug,是更底层的已读状态被静默污染了。

读tinode-sdk源码确认:TopicMe自己的_routePres在处理服务端推给"me"话题的
`{pres what:"msg"}`通知时(这本来是让未在查看的会话也能收到未读更新的机制),
调用`cont._updateReceived(pres.seq, pres.act)`,只要`act`是假值或者等于自己,
就会无条件把`read`设成`seq`——也就是直接标记成已读。而一个只是保持"打开"状态
的会话(ConversationView挂载着、话题处于订阅状态),哪怕标签页在后台完全没人
看,一样会收到这条推送,被自动标记已读——跟标签页是否可见毫无关系。之前只
在ConversationView自己的显式noteRead()调用上做了可见性判断,根本拦不住这条
更底层、完全独立的SDK自身机制。

修复:在topics.ts里新增一套独立于SDK自身read/unread字段的"真正已读"记录
(markConfirmedRead/confirmedReadSeq),只在会话真正被看到时(打开且标签页
可见)才推进;me.onPres处理`{pres what:"msg"}`时,如果受影响的会话不是当前
正在被查看的那个,就用这份独立记录把SDK刚自动标记的已读状态纠正回去。
openConversation()打开会话时会拿当前seq给这份记录打个初始基准,但同样加了
可见性判断——直接刷新/深链到一个标签页本身就在后台的会话这种边界情况下,
不能假设"打开=看过"。activeConversationTopic的追踪从push.ts挪到了topics.ts
(两边都要用,且topics.ts不能反向依赖push.ts)。

实机验证(systest04/systest05全新账号对,因为systest01/systest02当时正被
用户自己在真实浏览器里同时测试,避免互相顶号干扰):会话保持打开、标签页
确认处于hidden状态时,对方发消息——未读数正确从0变成1,标题栏数字正确跟着
增加;随后模拟标签页重新变为可见——未读数正确清零、标题恢复正常,走完了
用户报告的完整场景闭环。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:57:55 +08:00
Your NameandClaude Sonnet 5 3d6ae089a1 修复今天新增的兜底同步会让已读会话的未读角标无故重新出现的回归
根因:tinode-sdk自己的合并工具mergeObj(utils.js)对Date类型字段有"保留更新
的值"的防护,但普通数字字段(seq/read/recv/unread)完全没有防护,谁的数据
后到就直接覆盖——读源码逐行确认。startMeReconciliation()新增的定时/切换焦点
触发的withLaterSub()增量拉取一旦命中"服务端返回的read游标还没跟上客户端刚
发出的已读回执"这种时序,就会把已经读过的会话悄悄改回未读:角标无缘无故重新
出现,且重新聚焦窗口也不会清除,因为对这个会话来说根本没有发生任何新事情去
重新触发标记已读。

修复:reconcileMeSubs()改成在发起这次增量拉取前,先给所有已知会话的
seq/read/recv/unread拍个快照;拉取完成后,对每个"拉取前就已经认识"的会话,
如果发现seq没变但unread反而变多了,直接把read/recv/unread改回快照里的值并
触发通知——只有真正全新的会话(拉取前不认识)才信任这次拉取回来的数据,这
本来就是这个兜底机制存在的唯一目的。

实机验证:monkey-patch me.getMeta,让它在真实网络请求返回后额外注入一条
过期的sub记录(模拟服务端读游标滞后),再用真实的focus事件触发
reconcileMeSubs()整条链路——确认最终状态仍然正确显示已读,而不是被污染。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:32:49 +08:00
Your NameandClaude Sonnet 5 cd6b7675d4 新消息提示:标签页标题滚动+未读数、favicon角标、桌面通知(含何时不弹的判断)
三件套,参照Slack/Telegram Web/WhatsApp Web等主流IM网页客户端的标准做法:

- lib/notifications/tabIndicator.ts:标签页被切到后台且有未读时,标题在
  "📨 (N条新消息) <原标题>"上做左向滚动,用户切回来的瞬间(visibilitychange)
  立即恢复原标题,不等下一次未读数变化才发现。favicon右上角画一个红色圆点+
  白色数字角标(canvas绘制成PNG data URL,写回现有<link rel="icon">,不是
  另加一个可能被浏览器忽略的竞争链接)。两者都由AppShell.tsx里已经算好的
  同一个unreadMessages号驱动,不另起一套可能对不上的计数。

- lib/notifications/push.ts:真正的浏览器桌面通知(Notification API)。只
  在标签页还开着(哪怕最小化/切到后台)时生效——做不到浏览器整个关掉也能收到,
  那需要Service Worker+推送订阅+后端配合,是完全另一个量级的工程。

  抑制规则("什么时候不弹"):
  · 消息来自自己(比如另一个标签页/设备同步过来的回显)
  · 正在看的就是这个会话本身(会话已打开且标签页确实可见——两者都要满足,
    一个被切到后台的标签页哪怕挂载着这个会话也不算"正在看")
  · 该会话设置了"消息免打扰"(contact.private.mute_notifications)
  · 浏览器未授予通知权限
  同一会话的连续通知用tag合并显示为一条,不会叠加刷屏;点击通知会聚焦标签页
  并跳转到对应会话(通过stores/notificationNav.ts中转,push.ts在React之外,
  没有useNavigate可用,做法与stores/calls.ts、messageJump.ts是同一个模式)。

  判断"是否是真的新消息"用的是防抖后的seq对比(防止刚打开一个从未见过的会话时,
  历史消息分批到达被逐条误判成"新消息")——第一次见到某个会话时记录基准而不是
  照单全收。**这里踩到一个真实bug并当场修复**:最初版本把"第一次见到这个会话"
  一律当成"历史消息,不通知",结果全新好友的第一条消息(对这个会话来说seq=1,
  同时也是"第一次见到")被误判成历史消息静默吞掉——用两个全新互加好友的测试
  账号实机复现后,改成额外看消息时间戳:一分钟以内的算真正新消息,无论是不是
  第一次见到这个会话,否则才当历史记录处理。

实机验证(systest01/systest02两个全新互加好友的测试账号,全部通过mock
Notification类捕获+SDK本地状态直接读取的方式验证,因为这个自动化浏览器环境
本身把Notification.permission锁死成denied,没法测试真正弹出的系统通知):
- 真实新消息:通知的title/body/tag均正确(对方昵称/消息预览文本/会话id)
- 免打扰会话:确认不弹
- 已打开且标签页可见:确认不弹
- 标题栏滚动+favicon角标:实机确认能正确开始/停止滚动、favicon生成的是
  真实可解码的64×64 PNG

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:14:12 +08:00
Your NameandClaude Sonnet 5 8a593f50c3 系统消息:无跳转链接的通知永远停留在"新",改为整张卡片可点击标记已读
readMutation.mutate()此前只挂在"查看XXX"跳转按钮的onClick上,而这个按钮只在
actionRoute()能解析出目标时才渲染——好友申请已通过/拒绝、多数群聊动态类通知
根本没有可跳转目标,导致整张卡片没有任何东西可点,无论过多久都停留在"新"。

改为整张卡片本身是点击目标:有action的card点击后标记已读+跳转,没有action的
只标记已读。原来的"查看…"跳转按钮改成纯文本span(它现在处于卡片自己的点击
区域内,再嵌一层可交互元素会构成非法的按钮嵌套/双重触发,与之前修过的收藏夹
语音播放器按钮嵌套是同一类问题)。

实机验证:一条"好友申请已处理"(无action)点击后"新"角标消失;一条"新的好友
申请"(有action)点击后正确标记已读且跳转到/contacts/requests,无回归。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 20:13:41 +08:00
Your NameandClaude Sonnet 5 ab3843dbdb 群聊生命周期事件对被动成员实时不可见——前端兜底同步(真正的根因在后端,无法单靠前端代码彻底解决)
读im_be的server/im_group_events.go确认:群聊创建/加人/踢人/解散/转让群主等全部事件都走
publishIMGroupRealtimeEvent,这个函数只会广播给"当前已订阅该群topic的会话"——对比P2P场景专门
有一条deliverIMConversationSystemMessageToP2PSessions,会无视订阅状态强制投递给双方的session,
群聊从未做过同等实现。实机验证:一个全新创建、成员从未打开过的群,即使对方账号完全在线、页面就
停留在/messages上,群条目和未读角标都不会出现,等多久都不会自愈,必须手动刷新页面。

由于这个topic客户端本来就不知道存在,没有任何东西可以"响应",前端无法真正修复,只能缓解:复用
subscribeMeTopic()/onReauthenticated()已经在用的withLaterSub()增量语义,改用getMeta()(不像
subscribe()那样在_attached为true时直接短路)定时(30秒)+ 切回标签页时主动拉一次"me"话题的最新
订阅列表。

实机验证:创建一个全新群、只邀请一个从未见过这个群的被动成员,在其页面保持被动、不做任何操作/
刷新的情况下——模拟切换标签页触发的focus/visibilitychange事件后约十几秒内,群条目正确出现在
列表顶部;另一次未受干预的测试中,纯靠30秒定时器本身也确认能自愈。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:55:04 +08:00
Your NameandClaude Sonnet 5 d1f3aea26e 群聊消息记录中点击发送者头像/名字可跳转查看详情
isGroup且非本人消息、非多选态时,头像与名字改为Link跳转到/contacts/{msg.from},
其余情况(含选中模式,避免中断正在进行的批量选择)保持原有纯展示span不变。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:03:25 +08:00
Your NameandClaude Sonnet 5 8431f83ede 修复从好友资料页"发消息"直接进入新P2P会话时名称/在线状态永久缺失的问题
根因比此前提交(ff69e454)修复的isCommType()问题更深一层:即使会话能正确
出现在列表里,openConversation()走的withLaterDesc()增量查询在这条特定
路径(从未经过整页刷新,纯SPA内跳转)下会订阅成功但不返回desc/presence,
topic.public永久为null、在线状态永久为"离线",等待再久也不会自愈——整页
刷新之所以从未复现过这个问题,是因为subscribeMeTopic()自己的全量重新
订阅逻辑无条件覆盖了这个缺口。

改为在topic.public缺失时,用me.getMeta(withOneSub(topic))主动拉取——这
是SDK自己的presence路由内部用于"刚得知这个话题"场景的同一个调用,一次
返回desc+在线状态两者,且me话题的onMetaSub/onSubsUpdated回调早已在登录
时挂好,响应到达后会自动使聊天列表失效重新渲染。

已用全新测试账号对(systester9+systester2)在开发服务器实机验证:通过
真实DOM点击(非navigate()整页刷新)从通讯录进入全新会话,标题栏与侧边栏
立即正确显示对方名称与"在线"状态。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 18:03:18 +08:00
Your Name c455644cca NavRail左上角头像补充点击跳转个人资料页
此前是纯展示的Avatar,没有包任何可点击元素。包一层Link指向
/settings/profile,和设置页里"个人资料"入口是同一个目的地。
2026-09-16 16:58:21 +08:00
Your Name 823a47e182 修复群公告发布通知消息正文完全不显示、聊天列表预览显示"undefined"
群公告发布后自动生成的通知消息,真实content是{text:"群公告\n<内容>"}这种
扁平对象(不是纯字符串,也不是标准Drafty的{txt,fmt,ent}结构),且
head['x-im-type']是"text"而不是已处理的"system_notice",落到Drafty处理
分支后既渲染不出气泡正文、列表预览也变成字面的"undefined"字符串。

抽出dispatch.ts里原本只服务system_notice的{text}扁平内容识别逻辑
(重命名为flatTextContentOf并导出),在renderDrafty和
formatMessagePreview的Drafty分支之前都接上——不改变已有的居中系统提示
样式(这条消息本来就该是普通气泡,发送者信息一直是正确的,只是正文
丢了),只是把"文本内容是扁平{text}对象"这一种情况也当作合法纯文本处理。

实测:真实发布一条群公告后,气泡正文和列表预览都正确显示"群公告
全功能测试公告内容",不再是空白或"undefined"。
2026-09-16 16:37:48 +08:00
Your Name 92892e3e7b 修复两个严重崩溃:群管理页面查询键冲突导致整个会话崩溃、禁言列表读取
不存在的嵌套字段崩溃

- ConversationView.tsx和GroupAdminModal.tsx对群禁言列表用了完全相同的
  React Query缓存键['group-mutes', topicId],但分别调用getGroupMutes
  (单页,{items:[]}包装对象)和getAllGroupMutes(全部页,展开后的纯数组)
  两个返回形状不同的函数。两个组件同时挂载时后写入的一方会覆盖缓存,
  另一方读到错误形状后对undefined取.length直接崩溃,导致只要打开某个
  群的"群管理"面板整个会话界面就白屏。改成两处统一用getAllGroupMutes,
  真正共享同一份、同一种形状的数据
- GroupMute类型声明的是嵌套的user:{user_id,display_name},但真实响应
  是扁平的{user_id,muted_by,muted_at,reason,status,version},且完全没有
  display_name字段。这个字段错配从来没被暴露过,因为此前从未有真实账号
  真正禁言过人后去看这个列表——本轮实测"禁言→查看列表"时才第一次真正
  用真实数据渲染,立刻崩溃。改成扁平字段+交叉查群成员列表解析真实姓名
  (同一模式ConversationView.tsx的memberByUserId、GroupFilesModal的
  uploader_id都已经在用)

用全新账号在全新群里完整实测:禁言成员1人正确显示头像+姓名"systester2"
(不再是原始uid或崩溃),解除禁言后列表正确清零,两处此前必崩的入口
(群管理面板本身、禁言列表渲染)全部验证通过。
2026-09-16 16:23:06 +08:00
Your Name 8013c1675f 修复禅道Bug清单中的前端问题:好友申请source校验、群解散二次验证缺失、
群解散事件未处理、错误码翻译缺失、恢复登录页误导性提示

- 好友申请首次必失败30000(P0):AddFriendModal把手机号搜索单独标记为
  source:"phone"上报,但后端serveIMFriendRequestCreate的source校验只认
  search/qr/group,命中default分支返回"invalid friend request source"。
  改为手机号搜索也统一上报source:"search"(后端本就不区分具体搜索渠道),
  用两个全新、此前无任何关系的测试账号验证首次申请200成功
- 解散群聊需要短信二次验证但完整流程从未实现(P0):新增
  SecurityChallengeModal通用二次验证弹层(POST /security/challenges→
  验证码→verify→action_token)+ requestActionToken()(stores/
  securityChallenge.ts,与requestConfirm同一模式),接入解散群聊和转让
  群主(后者此前也因缺X-Action-Token必然404/400)。同时发现并修复
  imDelete对无body请求不显式传空对象体,导致携带Content-Type:
  application/json但请求体真正为空时后端JSON解码失败返回400——实测确认
  两个问题都解决后DELETE返回200
- 群聊被解散但当前正查看该群的用户界面不会跳转(P1):新增
  topics.ts的subscribeTopicGone()监听SDK自身的onDeleteTopic回调,
  ConversationView据此在当前会话被解散时弹出提示并跳转回消息列表
- P2P会话发起方标题栏显示原始topic id、对方明明在线却显示离线:确认
  为上一轮已修复的isCommType()根因问题的同一症状,本轮实测(新建从未
  聊过天的账号对)已不再复现;群内消息发送者显示原始uid的关联描述同样
  实测未复现
- 好友关系冲突(已是好友/申请正在处理中)报错英文原文透出:翻译表补充
  FRIEND_REQUEST_CONFLICT
- 深链/刷新页面时的"改为重新登录"链接此前在任何恢复登录过程中都会
  立即显示(不管是否超时),造成短暂白屏时的误导性提示:改为仅在真正
  超时后才显示该链接,与其上方的提示文案保持一致的显示条件
2026-09-16 15:42:41 +08:00
Your Name ff69e4548c 修复P2P会话完全不出现在聊天列表、标题栏显示原始ID+离线的根因
根因:tinode-sdk自带的Topic.topicType()前缀表只认'usr'为P2P类型前缀,没有
'p2p'这个条目,而本后端真实分配的P2P话题名是`p2p<hash>`形式——导致
isCommType()对每一个P2P会话都恒定返回false,MeTopic.contacts()因此把所有
单聊会话过滤掉。用真实账号实测确认这不是新会话/顶号账号特有的问题:任何
账号的任何P2P好友关系都受影响,只是群聊和"slf"自聊天不受影响所以此前一直
没被发现。

聊天列表改用Tinode#mapTopics()(不做类型过滤的原始遍历)+ 本项目自己正确
的前缀判断(p2p/grp/slf),不再依赖SDK自身的isCommType()。

实测(e2etester1/e2etester2真实新建好友关系,此前从未聊过天):修复前
标题栏显示原始topic hash+"离线"(对方实际在线)、侧边栏"暂无会话"(消息
其实已正确送达,只是不可见);修复后标题栏正确显示对方昵称+"在线",侧边栏
正确出现该会话及消息预览。
2026-09-16 13:41:26 +08:00
Your Name 29a1f1c2fd 修复收藏/系统消息/通话记录批次的代码审查问题
- 通话结果"已拒绝"文案按视角区分(拒绝方看到"已拒绝"而非"对方已拒绝")
- 通话记录页补充移动端返回头部与加载失败重试态
- 挂断时按是否已接通传递正确的挂断原因(cancelled)
- 系统消息"全部标为已读"翻页加循环护栏,防止游标卡死;按钮可见性改用真实未读总数
- 收藏批量删除/转发在部分失败时保留失败项以便重试,而非无条件退出多选模式
- 收藏卡片边框/分类文字/侧栏图标颜色token修正
2026-09-16 12:54:40 +08:00
Your NameandClaude Sonnet 5 97e8d7144f fix(web): 第9轮审查批次4——设置域一批问题
- 头像上传从未带purpose=user_avatar:之前是照抄一个已经修复的旧后端
  bug(问题28)绕开的写法,现在确认接口早就支持这个purpose了,补上——
  uploadFile()新增可选purpose参数,不影响其它已有调用点(聊天附件/群
  文件继续用topic的chat_file路径)
- 桌面卡片行分隔线6处错用了移动端专属的border-list-item token,改成
  桌面正确的border-row
- 退出登录按钮外边距/设置目录导航图标尺寸(18px→20px)与设计稿对齐
- 地区字段补上maxLength兜底
- 修改密码没有用<form>包裹,输入框按回车没反应——补上表单+onSubmit
- 记录一条此前没进清单的后端缺口:"加我为好友时需要验证"这个隐私开关
  设计稿画了,但响应体里根本没有对应字段

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:46:48 +08:00
Your NameandClaude Sonnet 5 18d21a03bb fix(web): 第9轮审查批次3——通讯录域一批问题
- 手机号搜索加好友时来源永远上报"search"(账号搜索),NewFriendsView
  早就有phone的文案映射但一直用不到——现在按searchType正确区分
- 添加好友弹层三个快捷按钮布局改成横向(原来是纵向堆叠,和设计稿方向
  不一致),图标颜色改成强调色
- "已选择"状态标签的圆角从胶囊改成6px小圆角,和同弹层其它按钮风格一致
- 通讯录头部"加好友"按钮补上移动端尺寸(40px/桌面36px),之前和旁边
  FIXED_ENTRIES的断点处理不一致
- 我的二维码/名片二维码生成失败没有重试入口,只能关掉弹层重开——补上
  重试按钮,同时处理"铸造失败"和"canvas绘制失败"两种情况
- 通讯录域几乎所有列表(好友列表/新朋友/群聊列表/黑名单/好友主页/加
  好友搜索)都把请求失败误判成"没有数据",统一补上isError态+重试按
  钮;好友主页额外区分403(资料保护)/404(真的不存在)/其它错误三种情况,
  之前网络错误也会显示"未找到该用户"

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:41:20 +08:00
Your NameandClaude Sonnet 5 8f4b0de636 fix(web): 第9轮审查批次2——群聊域一批问题
- 建群留空群名字直接被后端拒绝:placeholder写的"未填写时默认为成员
  昵称组合"从未真正实现,现在留空时用已选成员昵称拼接生成默认名,
  按MAX_TITLE_LENGTH截断
- 群文件上传按钮不受"群文件上传权限"治理开关约束:新增canUpload
  (和canInvite同一个算法),仅管理员时非管理员看不到上传入口
- 群管理弹层5个治理开关(进群验证/全员禁言/文件上传/私聊权限/邀请
  权限)都没有读取后端返回的对应_editable字段,现在全部接上
- 禁言成员列表只拉第一页:新增getAllGroupMutes复用fetchAllPages,
  和getAllGroupMembers同一个模式,人数多的群不会再静默截断
- 添加管理员没有前端数量上限校验:达到上限时按钮禁用并提示"已达
  管理员人数上限",不再让用户选完人才被后端拒绝
- 清空聊天记录没有错误处理,失败时静默无提示,现在补上

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:37:46 +08:00
Your NameandClaude Sonnet 5 ab96ae1fee fix(web): 第9轮审查批次1——认证/图标CSS/顶号竞态/消息核心一批问题
认证域:
- 找回密码验证码请求存在竞态:请求发出后如果用户改了手机号,旧请求
  返回时仍会用旧手机号的challenge�covers新手机号的输入框,现在按
  发起请求时的手机号快照判断,作废过期响应
- 切换企业码时"退出登录"确认框可被绕过:判断依据只有内存里的
  myUserId,直接刷新在/tenant页会让这个值变回null,导致确认框不弹
  就静默销毁一个其实还有效的会话;现在同时检查sessionStorage里的
  token
- 企业码不存在错误文案缺后半句",请核实后重试",和其它两处文案对齐
- 营业执照上传补上前端体积/类型校验(仿照问题反馈页截图校验的写法),
  不再无条件发一次注定被拒的上传请求
- 找回密码页移动端"重置密码"标题重复渲染两次,桌面版标题补上
  min-[900px]:block hidden
- 企业码输入框补maxLength=64

图标CSS:Material Icons的.im-icon规则漏抄了Google原始样式表的两行
(-webkit-font-feature-settings/-webkit-font-smoothing),补齐让
"1:1 copy"真正成立

顶号踢人 vs 已有401登出的竞态:两条路径会同时清空同一个token,如果
401先触发,用户会被直接甩回登录页看不到"账号已在其他设备登录"的
提示——forceReauth()现在先等一小段时间看kickedInfo是否被顶号流程
设置,设置了就让顶号流程自己的按钮触发跳转,避免抢跑

通话信令健壮性:sendCallRinging/Accept/Hangup底层用的{note}包没有id
字段,连接断开时会同步抛异常而不是走Promise reject——之前hangUp()
在这类异常后直接中断,导致后续的leaveCall()清理和UI收尾全部被跳过。
统一加了try/catch兜底

消息核心:
- 名片消息/通话结果消息(非Drafty的扁平自定义对象)之前可以被引用/
  复制/转发,会把这些对象错误地喂给Drafty的解析函数,产生格式错误
  的内容——现在这两类消息只保留收藏/撤回/删除/多选
- MessagesLayout.tsx自己独立计算的peerName没有对slf(自聊天)做
  isSelf特殊处理,会显示裸topic id"slf"而不是昵称,和已经修过的
  ConversationView头部不一致,现在补齐同样的处理
- 发送失败图标/文案错用了danger-text(撤回删除等破坏性操作的语义
  token),改成语义正确的unread token;录音条文字被连带染红,只保留
  指示点是红色
- 群成员数徽标形状(应为4px圆角矩形不是胶囊)和颜色(应为text-tertiary-4
  不是text-tertiary-3)都和设计稿不符,已修正

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 12:33:23 +08:00
Your NameandClaude Sonnet 5 fb1fda689c fix(web): 图标尺寸覆盖(text-[Npx])在任何地方都不生效——CSS层级冲突
根因:Material Icons的Google Fonts样式表(index.html的<link>)自己的
.material-icons规则完全没有@layer包裹(纯普通CSS),而Tailwind v4的
每一个工具类都在@layer utilities里——CSS Cascade Layers规范规定,任
何未分层的声明,不管选择器优先级多高、样式表加载顺序多晚,永远优先于
任何分层声明。实测坐实:<Icon className="text-[14px]">的计算样式恒
定是font-size:24px,不管试图覆盖成几px都没用,因为Google那条未分层
规则永远赢。这不是某一处的问题,是Icon组件全局的、此前从未被发现的
系统性bug——历史上多轮视觉审查用截图核对布局,没人具体去量过某个
图标的computed font-size。

修复:Icon.tsx改用自己的im-icon类名,不再用material-icons这个
Google样式表实际瞄准的类名,这样那条未分层规则从一开始就不会匹配到
这些元素。index.css里用@layer base原样照抄了Material Icons自己那条
规则的视觉默认值(24px/line-height:1等),套上分层之后,Tailwind自己
的@layer utilities(在Tailwind的层注册顺序里排在base之后)就能正常
覆盖它了——和每一处写`text-[Npx]`时本来就该有的效果一致。

全局仓库搜索确认material-icons这个类名只有Icon.tsx一处引用,改动范围
可控、不涉及其它代码路径。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 11:51:16 +08:00
Your NameandClaude Sonnet 5 776131b1de fix(web): 全新好友关系不会立即出现在会话列表/正确显示在线状态
上一条commit修的是"消息本身发不出去",这条修的是同一个根因链条上更早
一环:SDK只对`me`话题的onMetaSub/onSubsUpdated两个回调做了这个"联系
人变化了"的通知(读tinode.dev.js确认:无论是哪个话题触发的订阅,SDK
永远只调用me.onMetaSub/me.onSubsUpdated,不是触发订阅的那个话题自己
的回调),但这个项目的wireTopicCallbacks只接了onSubsUpdated,漏了
onMetaSub——这正是brand-new P2P话题从usr别名被服务端改名成真实
p2p...哈希的那一刻,SDK唯一会触发的两个回调之一。

现在两个回调都接上、都指向同一个notify(),会话列表和ConversationView
自己的contact查找(决定头部显示真实昵称还是裸id、在线还是离线)应该
能在好友关系刚建立、还没有任何一方发过消息时就正确更新,不用等一次不
相关的全量重连才被动捡回正确状态。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 11:47:50 +08:00
Your NameandClaude Sonnet 5 0824502ddd fix(web): 全新单聊(usr别名)会话消息收不到/在线状态错误——URL没跟上SDK重命名
真实两账号复现:好友刚加上、双方都还没发过消息的单聊,发消息表现为"发了
和没发一样"(无失败提示,但对方收不到),会话头部同时显示错误的裸ID和
"离线"(即使对方明明在线)。

根因(读tinode-sdk真实源码+原生协议脚本交叉验证,非猜测):这类会话从
"对方user_id"(usr...)这个地址进入,服务端订阅成功后会返回一个不同的
真实内部话题名(p2p...哈希)。SDK的Topic#subscribe在topic对象处于
`_new`状态时(每个新建对象默认如此)会把本地缓存从usr...改名到这个真
实名字——但本组件的submit()/头部展示/MessageBubble的retry()全都各自
独立调用`tinode.getTopic(topicId)`,用的是URL里那个没变过的usr...字符
串。改名发生后再调用`getTopic('usr...')`是一次缓存未命中,会凭空造出
一个全新的、从未真正订阅过的"替身"对象——发送/已读/在线状态全部作用在
这个空对象上,而真正收到数据的是已经改名的那个对象,两者从此互不相干。

用一个独立的原生WS协议脚本验证了根因:直接订阅+发布到同一个usr别名,
服务端确认消息真的送达并持久化(用一次全新订阅的历史记录能拉到),证
明"消息发不出去"是假象,真正的问题在于客户端读的从来不是收到数据的那
个对象。

修复:openConversation()解析出的topic对象一旦.name和URL参数不一致
(即刚刚被服务端改名),立刻用real name替换当前URL(replace,不留历
史记录)。这样一来,本组件后续所有`getTopic(topicId)`都会读到同一个、
真正接了收数据的对象,而不是每次都对着URL里那个过期字符串重新变出一
个空壳。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 11:43:56 +08:00
Your NameandClaude Sonnet 5 9adadaa57d feat(web): 实现强制下线通知与顶号互踢的客户端感知(Ctrl 205 evicted)
按docs/强制下线通知与顶号互踢-后端接口文档.md §2实现。该文档§2.4声称
"SDK侧已内置:收到后关闭autoreconnect并回调onKick(reason, ctrl)"——读
实际安装的tinode-sdk(0.25.3)真实源码确认这个说法不成立:SDK对
code:205/text:"evicted"只有topic级的内部处理(topic._resetSub()),账
号级(topic为空)完全没有特殊逻辑,也没有onKick这个回调。

改为用SDK真实存在、此前项目里完全没用过的onCtrlMessage钩子(每条
{ctrl}包都会触发,含账号级的)在connection.ts里自行判定
"code===205 && text==='evicted' && !topic"。检测到后立即:清空
sessionStorage的token、清空myUserId、调用resetTinode()——其内部的
disconnect()会把SDK自己的#boffClosed标记置真,这是唯一能真正阻止
backoff计时器安排下一次重连的方式(读真实bundle的Connection#init_ws
确认,不存在另外一个"关闭autoreconnect"的方法)。只有面向用户的弹窗
和跳转延后到用户点确定,逻辑上是"会话立刻终止,通知是补充说明",不
是"通知完才终止"。

新增KickedNoticeRoot(挂载在App.tsx顶层,和ConfirmDialogRoot/
GlobalToastRoot同一层级,保证任意页面被踢都能弹出),文案原样照抄文
档§2.4的表格(reason→标题/正文),单一"确定"按钮(不是取消/确定二
选一——已经被踢了,没有"取消"这个选项,给出会误导)。确认后用
window.location.href硬跳转/login,和client.ts的forceReauth()同一个
理由:保证内存里的每一处状态(TanStack Query缓存等)都从头重置,不用
指望每个组件各自正确响应"你已经被登出了"。

topic非空的205(群内移出等topic级事件)不受影响,走原有的SDK内部
处理,不弹这个全局提示。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 11:13:28 +08:00
Your NameandClaude Sonnet 5 c3e8f91364 fix(web): 修复自聊天显示为原始topic id、主发送/重试路径未重新订阅话题
自聊天(slf)会话头部/侧边栏/聊天列表此前直接回退显示原始topic id "slf"
(而不是自己的昵称),且会话头部还错误显示了通话/视频按钮和"离线"状态——
这三者都源自同一处:peerName的计算只查好友列表(不含自己),没有对slf
做特殊处理。DetailColumn此前已经用isSelf正确处理了详情栏内部的各项操
作(commit 9271a0a9),但ConversationView自己的头部渲染和ChatListColumn
的列表行都没有跟进。改为isSelf时读取AppShell/SettingsLayout已经在用的
同一个['me']缓存查询,取自己的真实昵称,并隐藏通话按钮和在线状态行。

真实两账号联调时发现更严重的问题:主输入框发送消息(ConversationView
submit())和失败重试(MessageBubble retry())都直接调用sendText/
retryMessage,从未像转发流程(见ensureTopicAttached自己的注释)那样先
确保话题处于attached状态——连接抖动一次(断线重连会重置每个话题的
attached状态)之后,不管是正常发送还是点击重试都会直接失败,不会自
动恢复。转发流程本来就有这个防护,这次给主发送路径和重试路径补上同
一个防护。

用两个真实测试账号(asn494180 与新建的e2etester1,跨web.118im.com/
web2.118im.com两个域名分别登录)完整验证:加好友、接受好友申请、
P2P发消息全链路,复现问题后确认修复生效。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 10:44:19 +08:00
Your NameandClaude Sonnet 5 e78437cb2a fix(web): upgrade http OSS URLs to https to fix mixed-content block
Aliyun OSS sometimes returns presigned URLs as http:// even though every
page here is served over https:// -- the browser silently blocks these
as mixed content, which is why images (e.g. avatar) failed to display
after upload. Added upgradeToHttps() and wired it into fileDownloadUrl()
(the single chokepoint every image/attachment fetch in the app resolves
through) plus the three chat-attachment send paths, so both freshly
fetched and newly-persisted URLs get the scheme fix.

Also fixed ProfileSettingsPage's avatar upload handler, which extracted
the file id by parsing the upload response's `url` with .split('/').pop()
instead of just reading the `file_id` field the response already
provides -- fragile against exactly this kind of full-URL response and
the most likely actual trigger for the reported bug.

vite.config.ts: dev server port now respects $PORT if set, falls back
to 8094 -- unrelated cleanup, needed it to run a second local dev
server for verification alongside another concurrent session's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 00:18:31 +08:00
Your NameandClaude Sonnet 5 70cc977e16 fix(web): 左上角自己头像接入真实数据,会话订阅失败不再静默吞错
NavRail 的自己头像此前是硬编码的"我"字,AppShell 从没往下传过真实的
user_id/nickname/avatar_file_id,所以无论是谁登录、有没有上传头像都不会变;
现在改为拉取 /v1/im/me 并用 Avatar 组件渲染,和设置页/联系人等处一致。

ConversationView 打开会话失败时原来是 .catch(() => {}) 整个吞掉,用户会看到
空白的消息列表且没有任何提示;由于已读回执只在拿到消息后才会发,未读角标
也会因此永久卡住。现在失败会弹出错误提示,不再静默。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 23:28:53 +08:00
Your NameandClaude Sonnet 5 d5fc03db0e ci: retarget deploy workflow at the k3s test cluster
Old VM's blue-green im-webapp is decommissioned; build+push now
targets the mutable :test tag the cluster's kustomize overlay
already tracks, then rolls the k3s deployment instead of running
/opt/apps/im-webapp/deploy.sh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 05:40:03 +08:00
Your NameandClaude Sonnet 5 f9d4d48148 fix: kill zombie pre-rewrite service worker
The old service-worker.js (repo root, removed when web/ was rewritten)
cached every same-origin GET keyed only by frontend PACKAGE_VERSION, so
a backend-only change never invalidated it. Browsers that had visited
this origin before the rewrite kept getting stale cached API responses
(bootstrap/session calls) served instead of hitting the real backend --
looked like "login succeeds then immediately bounces back to login".

Adds a same-path kill-switch service-worker.js that purges all caches
and unregisters itself, plus an explicit update() check on app boot so
already-registered browsers don't have to wait for the browser's own
lazy update schedule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 05:27:49 +08:00
Your NameandClaude Sonnet 5 37d08e8989 feat(web): 用符合系统风格的组件替换全部 window.alert/confirm
全项目搜出15处 window.confirm、9处 window.alert(其中3处是各文件
各自复制的 notImplemented() 辅助函数,文案还都一样)用在退出登录、
删除消息/收藏/好友、清空聊天记录、解散群聊、转让群主、危险链接
二次确认等场景——全是浏览器原生弹窗,样式和整个应用的设计语言完全
脱节。

新增两个全局UI基础设施(Zustand store + 挂载在App.tsx根节点的
渲染组件,参照stores/calls.ts/messageJump.ts已有的"任何组件都能调用"
惯例,不是每个弹窗各自管理一份本地状态):

- stores/confirmDialog.ts + app/ConfirmDialogRoot.tsx:替换
  window.confirm。requestConfirm()返回Promise<boolean>,和
  window.confirm的返回值语义一致,所以原来"if (!window.confirm(...))
  return"这一行改成"if (!(await requestConfirm({message:...})))
  return",外层函数标成async,不用大改周围逻辑。危险/不可恢复操作
  (删除、解散群聊、注销账号等)传danger:true,确认按钮变红色,
  弹窗视觉沿用转发弹窗已有的"描边取消+实心确认"按钮搭配。
- stores/globalToast.ts + app/GlobalToastRoot.tsx:替换window.alert,
  复用应用里本来就有、每个页面各自本地状态管理的Toast组件。顺手把
  3个文件里各自重复定义、文案完全一样的notImplemented()合并成
  一个共享导出。

实机验证了非危险确认(清空缓存,蓝色确认按钮)、危险确认(删除
消息,红色确认按钮)、取消按钮、Escape键取消、全局提示(点"投诉
举报")——均正常工作,且原有页面各自的Toast错误提示不受影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 00:08:15 +08:00
Your NameandClaude Sonnet 5 76156a1c75 fix(web): 点表情后光标不再跳出输入框,需要重新点击才能回车发送
点表情面板里的emoji这个操作本身没有做"点完之后把焦点还给输入框"
这件事——emoji按钮是个真实的<button>,点击后浏览器会把焦点停在
这个按钮上,导致输入框失焦,这时候按回车自然没反应(键盘事件根本
没送到输入框),要用户重新点一下输入框才能继续打字/发送。

参照"@"提及按钮已有的"点完之后把焦点和光标位置还给输入框"这套写法
(insertMentionTrigger/pickMention),给emoji选择也接上同一套逻辑,
并且改成插入到光标所在位置,而不是永远追加到文本末尾。

顺手把这三处(含原来的两处"@"相关逻辑)从 requestAnimationFrame
换成 setTimeout(fn, 0):实测确认 requestAnimationFrame 在页面处于
后台/不可见状态时完全不会触发(浏览器的标准省电行为),而这个"点击
按钮后把焦点还给输入框"的操作没有理由依赖页面必须处于前台可见状态
才生效,setTimeout 不受这个限制。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 21:49:57 +08:00
Your NameandClaude Sonnet 5 4e1f1cbf0f refactor(web): 提取共享的圆形多选勾选框组件,统一5处重复实现
创建群聊、消息多选、我的收藏多选、黑名单多选、转发选择器——这5个
地方各自手写了一份几乎一样的"圆形+选中变实心+里面check图标"勾选框
markup,样式上有细微且无意的差异(比如有的选中态多画了一圈边框、
用的灰色深浅不完全一致),上次只修了转发选择器一处,用户指出创建
群聊那里同样丑、而且问了"这种组件不该统一做吗"——確实该統一。

新增 `app/SelectionCheckbox.tsx`,把上次在转发选择器里验证过的
矢量勾选图标方案抽成共享组件,5处全部换成调用它。以后这个视觉样式
要再调整,改一个文件就够了,不会再出现"修好了一处,另外四处还是
老样子"的情况。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:47:08 +08:00
Your NameandClaude Sonnet 5 3280b92a09 fix(web): 转发选择器勾选样式改用矢量图标,好友/群聊可分别展开收起
原来的勾选图标是Material Icons字体里的"check"字形,在20px圆圈里塞
14px字体图标,字体本身没有针对这么小的尺寸做过精细调校,稍微放大
一点看就是个不规整的糊状图案。换成手绘SVG勾选路径(矢量、任何缩放
比例下都清晰),并去掉了选中态多余的一层border(原来选中时背景色
和边框色叠在一起,等于多画了一圈没必要的描边)。

好友、群聊两个分组标题现在都可以点击收起/展开(各自独立),列表长
的时候可以先收起不relevant的那一类,收起状态下已经选中的项不会被
清空,"全选"按钮无论展开收起都能点。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:25:36 +08:00
76 changed files with 2816 additions and 399 deletions
+18 -9
View File
@@ -1,10 +1,10 @@
name: Deploy Webapp (Blue-Green)
name: Deploy im-webapp (k3s test cluster)
on:
workflow_dispatch:
inputs:
image_tag:
description: '镜像tag,留空则使用commit短哈希'
description: '镜像tag,默认为 test(与集群 kustomization 里的 newTag 一致)'
required: false
jobs:
@@ -17,14 +17,23 @@ jobs:
id: vars
run: |
TAG="${{ github.event.inputs.image_tag }}"
[ -z "$TAG" ] && TAG=$(git rev-parse --short HEAD)
[ -z "$TAG" ] && TAG=test
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: 构建镜像
run: |
docker build -t im-webapp:${{ steps.vars.outputs.tag }} \
--build-arg TINODE_API_KEY="${{ secrets.TINODE_API_KEY }}" .
- name: 登录镜像仓库
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.118im.com -u admin --password-stdin
- name: 蓝绿部署
- name: 构建并推送镜像
run: |
IMAGE=im-webapp:${{ steps.vars.outputs.tag }} /opt/apps/im-webapp/deploy.sh
docker build \
--build-arg TINODE_API_KEY="${{ secrets.TINODE_API_KEY }}" \
-t git.118im.com/im_team/im-webapp:${{ steps.vars.outputs.tag }} .
docker push git.118im.com/im_team/im-webapp:${{ steps.vars.outputs.tag }}
- name: 部署到k3s测试集群
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG }}" > ~/.kube/config
chmod 600 ~/.kube/config
kubectl rollout restart deployment/im-webapp -n im-webapp-system
kubectl rollout status deployment/im-webapp -n im-webapp-system --timeout=120s
+24
View File
@@ -0,0 +1,24 @@
// Kill switch for the pre-rewrite service worker (formerly at repo root, see git history).
// That worker cached every same-origin GET (including session/bootstrap API calls) keyed only
// by frontend PACKAGE_VERSION -- a backend-only change (deploy, migration, cutover) never
// invalidated it, so browsers that had visited this origin before the rewrite kept getting
// stale API responses served instead of hitting the new backend, which looked like "login
// succeeds then immediately bounces back to the login page".
//
// This file's only job is to replace that worker in already-registered browsers and remove
// it entirely: purge its caches, unregister, then reload any open tabs so they load uncontrolled.
// The rewritten app (web/) does not use a service worker for anything -- do not add fetch
// caching logic here.
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', event => {
event.waitUntil((async () => {
const keys = await caches.keys();
await Promise.all(keys.map(key => caches.delete(key)));
await self.registration.unregister();
const clientsList = await self.clients.matchAll({ type: 'window' });
for (const client of clientsList) {
client.navigate(client.url);
}
})());
});
+11
View File
@@ -2,7 +2,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AppShell } from '@/app/AppShell';
import { ConfirmDialogRoot } from '@/app/ConfirmDialogRoot';
import { GlobalToastRoot } from '@/app/GlobalToastRoot';
import { KickedNoticeRoot } from '@/app/KickedNoticeRoot';
import { RequireAuth } from '@/app/RequireAuth';
import { SecurityChallengeModal } from '@/app/SecurityChallengeModal';
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
import { LoginPage } from '@/features/auth/LoginPage';
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage';
@@ -97,6 +101,13 @@ export default function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
{/* Mounted above the router (not inside AppShell) -- window.confirm()/window.alert()
replacements need to work on unauthenticated pages too (e.g. TenantEntryPage's "switch
tenant" confirmation), not just the logged-in app tree. */}
<ConfirmDialogRoot />
<GlobalToastRoot />
<KickedNoticeRoot />
<SecurityChallengeModal />
</QueryClientProvider>
);
}
+34 -2
View File
@@ -1,11 +1,15 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Outlet, useNavigate } from 'react-router-dom';
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 { initCallSignaling } from '@/stores/calls';
import { useNotificationNavStore } from '@/stores/notificationNav';
import { BottomTabBar } from './BottomTabBar';
import { NavRail } from './NavRail';
@@ -17,8 +21,15 @@ import { NavRail } from './NavRail';
* columns within a section.
*/
export function AppShell() {
const navigate = useNavigate();
const contacts = useChatList();
const bootstrap = useBootstrap();
// Same `['me']` query SettingsLayout already runs -- sharing the key means whichever mounts
// first fetches it and the other just reads the cache, not two redundant requests. This was
// never wired up here before, so the rail's self-tile (NavRail below) had no avatar/name data
// at all and fell back to a permanently-hardcoded "我" glyph regardless of who was logged in or
// what avatar they'd uploaded -- see Avatar.tsx's own comment for the wider version of this gap.
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
// Once per app session, regardless of which page is open -- an incoming call must surface even
// while browsing contacts/settings/etc, not only while a conversation happens to be open.
useEffect(() => {
@@ -34,10 +45,31 @@ export function AppShell() {
contacts.reduce((sum, c) => (c.topic === 'sys' ? sum : sum + (c.unread || 0)), 0) +
(bootstrap.data?.entry_badges?.system_unread_count || 0);
// Same canonical number the rail/tab-bar badges above already show -- keeps the browser tab's
// favicon badge and (while backgrounded) scrolling title in sync with it rather than tracking a
// second, potentially-divergent count.
useEffect(() => {
updateTabIndicator(unreadMessages);
}, [unreadMessages]);
// Consumes a desktop-notification click (lib/notifications/push.ts has no React Router access of
// its own -- see stores/notificationNav.ts for why this goes through a store instead).
const pendingNotificationTopic = useNotificationNavStore((s) => s.pendingTopic);
useEffect(() => {
if (!pendingNotificationTopic) return;
navigate(`/messages/${pendingNotificationTopic}`);
useNotificationNavStore.getState().clear();
}, [pendingNotificationTopic, navigate]);
return (
<div className="flex h-dvh w-full overflow-hidden bg-surface-page">
<div className="hidden sm:flex sm:h-full">
<NavRail unreadMessages={unreadMessages} />
<NavRail
unreadMessages={unreadMessages}
selfId={meQuery.data?.user_id}
selfName={meQuery.data?.profile.nickname || '我'}
selfAvatarFileId={meQuery.data?.profile.avatar_file_id}
/>
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-hidden">
+8 -2
View File
@@ -9,6 +9,10 @@ interface AvatarProps {
/** Sizing/rounding/font-size only (e.g. "h-20 w-20 rounded-[20px] text-2xl") -- the fallback tile's
* own color/centering/weight are applied internally, not expected from the caller. */
className?: string;
/** Overrides the hashed `avatarBgClass(id)` fallback color -- e.g. "bg-accent" for the current
* user's own message-row avatar, which by design is always the fixed accent color rather than a
* per-id hash like every other tile. No effect once avatarFileId resolves to a real image. */
fallbackBgClass?: string;
}
/**
@@ -20,12 +24,14 @@ interface AvatarProps {
* `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already
* used for chat attachments) expects.
*/
export function Avatar({ id, name, avatarFileId, className = '' }: AvatarProps) {
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}`} />;
}
return (
<span className={`grid place-items-center font-medium text-white ${avatarBgClass(id)} ${className}`}>{avatarLetter(name)}</span>
<span className={`grid place-items-center font-medium text-white ${fallbackBgClass || avatarBgClass(id)} ${className}`}>
{avatarLetter(name)}
</span>
);
}
+42
View File
@@ -0,0 +1,42 @@
import { useEscapeKey } from '@/lib/hooks';
import { useConfirmDialogStore } from '@/stores/confirmDialog';
/** Mounted once at the app root (App.tsx) -- renders whatever `requestConfirm()` (confirmDialog.ts)
* most recently asked for. Visual language matches this app's other small action dialogs (e.g. the
* forward picker's outlined-gray "取消" + solid colored confirm button, `02-messages.dc.html`'s own
* spec for that pairing) rather than inventing a new button treatment just for this. */
export function ConfirmDialogRoot() {
const pending = useConfirmDialogStore((s) => s.pending);
const respond = useConfirmDialogStore((s) => s.respond);
// Always called (hooks can't be conditional) -- a no-op onEscape while nothing is pending is
// harmless, matching how every other modal in this app registers unconditionally too.
useEscapeKey(() => respond(false));
if (!pending) return null;
const { title, message, confirmLabel = '确定', cancelLabel = '取消', danger } = pending;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={() => respond(false)}>
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal" onClick={(e) => e.stopPropagation()}>
{title ? <strong className="mb-2 block text-base font-semibold text-text-heading">{title}</strong> : null}
<p className="m-0 text-sm leading-relaxed text-text-body">{message}</p>
<div className="mt-5 flex justify-end gap-3">
<button
type="button"
onClick={() => respond(false)}
className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
>
{cancelLabel}
</button>
<button
type="button"
onClick={() => respond(true)}
className={`grid h-9 place-items-center rounded-lg px-4 text-sm font-medium text-white ${danger ? 'bg-danger-text' : 'bg-btn'}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { Toast } from '@/app/Toast';
import { useGlobalToastStore } from '@/stores/globalToast';
/** Mounted once at the app root (App.tsx) -- renders whatever `notify()` (globalToast.ts) most
* recently set. `key={token}` forces Toast to remount (not just re-render) so its own auto-dismiss
* timer restarts even when the exact same message is fired twice in a row, which a plain `message`
* string prop wouldn't detect as a change. */
export function GlobalToastRoot() {
const message = useGlobalToastStore((s) => s.message);
const variant = useGlobalToastStore((s) => s.variant);
const token = useGlobalToastStore((s) => s.token);
if (!message) return null;
return <Toast key={token} message={message} variant={variant} onDone={() => useGlobalToastStore.setState({ message: '' })} />;
}
+7 -2
View File
@@ -1,10 +1,15 @@
/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html).
* Always `aria-hidden` -- the glyph's DOM text is the icon's internal ligature name (e.g.
* "arrow_back_ios_new"), never something a screen reader should announce. An icon-only
* interactive element (button/link with no visible text) must set its own `aria-label`. */
* interactive element (button/link with no visible text) must set its own `aria-label`.
*
* Deliberately `im-icon`, NOT Google Fonts' own `.material-icons` class -- see index.css's own
* comment on that rule for why: Google's stylesheet ships that class completely unlayered, which
* under CSS Cascade Layers always outranks Tailwind's layered utilities regardless of specificity
* or source order, silently ignoring every `text-[Npx]` size override across the whole app. */
export function Icon({ name, className = '', style }: { name: string; className?: string; style?: React.CSSProperties }) {
return (
<i aria-hidden="true" className={`material-icons select-none ${className}`} style={style}>
<i aria-hidden="true" className={`im-icon select-none ${className}`} style={style}>
{name}
</i>
);
+42
View File
@@ -0,0 +1,42 @@
import { clearKickedInfo } from '@/lib/tinode/connection';
import { kickedNoticeText } from '@/lib/tinode/errors';
import { useKickedInfo } from '@/lib/tinode/hooks';
/**
* Mounted once at the app root (App.tsx), same placement as ConfirmDialogRoot/GlobalToastRoot --
* an eviction can land on any authenticated page, not just ones that happen to render their own
* connection-state UI. By the time this renders, connection.ts's onCtrlMessage handler has already
* torn down the session (token cleared, Tinode singleton disconnected+reset) -- this component's
* only job is showing the user WHY before sending them to /login, per 强制下线通知与顶号互踢-后端
* 接口文档.md §2.4's "弹出中文提示后跳转登录页".
*
* A single acknowledgment button, not ConfirmDialogRoot's 取消/确定 pair -- there is no "cancel" for
* an eviction that already happened, offering one would be misleading. Hard `window.location.href`
* navigation (not an in-SPA `navigate()`), matching client.ts's forceReauth() for the identical
* reason: every piece of in-memory state (TanStack Query cache, any component's local state) should
* reset from scratch, not rely on each one separately noticing "you're logged out now".
*/
export function KickedNoticeRoot() {
const kicked = useKickedInfo();
if (!kicked) return null;
const { title, message } = kickedNoticeText(kicked.reason);
const acknowledge = () => {
clearKickedInfo();
window.location.href = '/login';
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25">
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal">
<strong className="mb-2 block text-base font-semibold text-text-heading">{title}</strong>
<p className="m-0 text-sm leading-relaxed text-text-body">{message}</p>
<div className="mt-5 flex justify-end">
<button type="button" onClick={acknowledge} className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white">
确定
</button>
</div>
</div>
</div>
);
}
+14 -4
View File
@@ -1,5 +1,6 @@
import { Link, useLocation } from 'react-router-dom';
import { Avatar } from './Avatar';
import { Icon } from './Icon';
interface NavRailItem {
@@ -54,13 +55,22 @@ function NavRailButton({ item }: { item: NavRailItem }) {
);
}
interface NavRailProps {
unreadMessages?: number;
/** The logged-in user's own id/name/avatar (`GET /v1/im/me`) -- drives the rail's top self-tile.
* All optional so the rail still renders (as a letter-tile fallback) before that request settles. */
selfId?: string;
selfName?: string;
selfAvatarFileId?: string;
}
/** Persistent 64px icon rail, visible at >=640px (see App.tsx for the <640 bottom-tab fallback). */
export function NavRail({ selfInitial = '我', unreadMessages = 0 }: { selfInitial?: string; unreadMessages?: number }) {
export function NavRail({ unreadMessages = 0, selfId, selfName, selfAvatarFileId }: NavRailProps) {
return (
<nav className="flex h-full w-16 flex-none flex-col items-center border-r border-[#e6e9ee] bg-surface-rail pb-3 pt-3.5">
<div className="mb-[22px] grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-semibold text-white">
{selfInitial}
</div>
<Link to="/settings/profile" title="个人资料" aria-label="个人资料" className="mb-[22px]">
<Avatar id={selfId || ''} name={selfName} avatarFileId={selfAvatarFileId} className="h-[38px] w-[38px] rounded-[10px] text-base" />
</Link>
<div className="flex flex-1 flex-col items-center gap-1.5">
{TOP_ITEMS.map((item) => (
<NavRailButton key={item.to} item={item.to === '/messages' ? { ...item, badge: unreadMessages } : item} />
+26 -1
View File
@@ -3,9 +3,10 @@ import { useEffect, useRef, useState } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { Toast } from '@/app/Toast';
import { initMessageNotifications } from '@/lib/notifications/push';
import { restoreSession } from '@/lib/tinode/connection';
import { isTenantMismatch } from '@/lib/tinode/errors';
import { subscribeMeTopic } from '@/lib/tinode/topics';
import { startMeReconciliation, subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
/** No connect-timeout exists anywhere in the SDK (its WS promise only settles on the underlying
@@ -81,6 +82,22 @@ export function RequireAuth() {
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
}, [myUserId]);
// Separate from the one-time subscribeMeTopic effect above on purpose: this one has real
// start/stop semantics (a timer + listeners, not a non-idempotent WS packet) and needs to
// restart cleanly across StrictMode's mount->cleanup->remount, not be guarded against it. See
// startMeReconciliation's own comment for what backend gap this is mitigating.
useEffect(() => {
if (!myUserId) return undefined;
return startMeReconciliation();
}, [myUserId]);
// Same start/stop shape as the reconciliation effect above, same reason it isn't merged into the
// one-time subscribeMeTopic effect.
useEffect(() => {
if (!myUserId) return undefined;
return initMessageNotifications();
}, [myUserId]);
if (!tenant) return <Navigate to="/tenant" replace />;
if (myUserId) {
return (
@@ -106,9 +123,17 @@ export function RequireAuth() {
return (
<div className="flex h-dvh flex-col items-center justify-center gap-3 bg-surface-page text-sm text-text-tertiary-3">
{restoreTimedOut ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'}
{/* Only past the same timeout the message text itself waits for -- a normal restore usually
resolves in well under a second (real WS round trip), and this link showing unconditionally
from the very first frame made every ordinary reload/deep-link flash a "重新登录" escape
hatch that had nothing to escape from (a real, reported false-alarm: "短暂白屏并误显改为
重新登录"). The escape hatch is only actually useful once restoreTimedOut has already told
the user this is taking longer than normal. */}
{restoreTimedOut ? (
<a href="/login" className="text-accent no-underline">
改为重新登录
</a>
) : null}
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
import { useEffect, useRef, useState } from 'react';
import { useEscapeKey } from '@/lib/hooks';
import { ImApiError } from '@/lib/api/client';
import { requestSecurityChallenge, verifySecurityChallenge } from '@/lib/api/security';
import { useSecurityChallengeStore } from '@/stores/securityChallenge';
/** Mounted once at the app root (App.tsx), same placement/pattern as ConfirmDialogRoot -- renders
* whatever `requestActionToken()` (stores/securityChallenge.ts) most recently asked for: the
* protocol's sensitive-action SMS re-verification (docs/IM-前后端对接文档.md:762-766), needed before
* a group can actually be dissolved (or its ownership transferred) -- both require `X-Action-Token`,
* and there was previously no UI anywhere in this app that could produce one. */
export function SecurityChallengeModal() {
const pending = useSecurityChallengeStore((s) => s.pending);
const respond = useSecurityChallengeStore((s) => s.respond);
const [challengeId, setChallengeId] = useState('');
const [phoneMask, setPhoneMask] = useState('');
const [code, setCode] = useState('');
const [seconds, setSeconds] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const timerRef = useRef<number | undefined>(undefined);
useEscapeKey(() => respond(null));
const tick = (initial: number) => {
window.clearInterval(timerRef.current);
setSeconds(initial);
timerRef.current = window.setInterval(() => {
setSeconds((s) => {
if (s <= 1) {
window.clearInterval(timerRef.current);
return 0;
}
return s - 1;
});
}, 1000);
};
// Fires the first SMS the moment the modal opens -- the doc's own flow has no separate "get code"
// click for this endpoint (unlike VerificationCodeButton's register/reset-password scenes), since
// the phone number isn't something the user enters here at all (read server-side from the login
// identity), so there's nothing to wait on the user to fill in first.
useEffect(() => {
window.clearInterval(timerRef.current);
setChallengeId('');
setCode('');
setError('');
setSeconds(0);
if (!pending) return undefined;
let cancelled = false;
requestSecurityChallenge(pending.scene)
.then((data) => {
if (cancelled) return;
setChallengeId(data.challenge_id);
setPhoneMask(data.phone_mask);
tick(data.resend_after_seconds || 60);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试');
});
return () => {
cancelled = true;
window.clearInterval(timerRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pending?.scene]);
if (!pending) return null;
const resend = () => {
if (busy || seconds > 0) return;
setError('');
setBusy(true);
requestSecurityChallenge(pending.scene)
.then((data) => {
setChallengeId(data.challenge_id);
setPhoneMask(data.phone_mask);
tick(data.resend_after_seconds || 60);
})
.catch((err) => setError(err instanceof ImApiError ? err.message : '验证码发送失败,请重试'))
.finally(() => setBusy(false));
};
const submit = async () => {
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请输入 6 位验证码');
setBusy(true);
setError('');
try {
const verified = await verifySecurityChallenge(challengeId, code);
respond(verified.action_token);
} catch (err) {
setError(err instanceof ImApiError ? err.message : '验证码错误或已过期');
setBusy(false);
}
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-[#22262d]/25" onClick={() => respond(null)}>
<div className="w-[340px] rounded-xl bg-white p-5 shadow-modal" onClick={(e) => e.stopPropagation()}>
<strong className="mb-2 block text-base font-semibold text-text-heading">安全验证</strong>
<p className="m-0 text-sm leading-relaxed text-text-body">
即将{pending.actionDescription},请验证手机号{phoneMask ? ` ${phoneMask}` : ''} 收到的短信验证码
</p>
<div className="mt-4 flex items-center gap-2">
<input
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="请输入 6 位验证码"
inputMode="numeric"
autoFocus
className="h-9 min-w-0 flex-1 rounded-lg border border-border-panel px-3 text-sm outline-none focus:border-accent"
/>
<button
type="button"
onClick={resend}
disabled={busy || seconds > 0 || !challengeId}
className="h-9 w-28 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
>
{seconds > 0 ? `重新获取(${seconds}s)` : '重新获取'}
</button>
</div>
{error ? <p className="m-0 mt-2 text-xs text-danger-text">{error}</p> : null}
<div className="mt-5 flex justify-end gap-3">
<button
type="button"
onClick={() => respond(null)}
className="grid h-9 place-items-center rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary"
>
取消
</button>
<button
type="button"
onClick={submit}
disabled={busy || !challengeId || code.length !== 6}
className="grid h-9 place-items-center rounded-lg bg-btn px-4 text-sm font-medium text-white disabled:opacity-50"
>
{busy ? '验证中…' : '确定'}
</button>
</div>
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
interface SelectionCheckboxProps {
selected: boolean;
className?: string;
}
/**
* Shared circular multi-select indicator -- every list in this app that lets you pick multiple
* items (forward targets, message multi-select, create-group contact picker, favorites, blocklist)
* used to hand-copy this same circle-that-fills-with-a-checkmark markup into its own file, each
* with small, accidental class differences (a stray extra border in the selected state here, a
* slightly different gray there) -- fixing the same visual bug five separate times is exactly the
* failure mode a shared component exists to prevent.
*
* Draws the checkmark as a hand-drawn SVG path, not the Material Icons font glyph `Icon` uses
* everywhere else: the font's hinting isn't tuned for a ~13px glyph inside a 20px circle, so above
* 1x pixel density it renders visibly soft/asymmetric where a vector path stays crisp at any scale.
* A deliberate one-off exception to this app's usual `Icon`-for-everything convention, not a second
* icon system -- scoped to this one selection indicator.
*/
export function SelectionCheckbox({ selected, className = '' }: SelectionCheckboxProps) {
return (
<span
className={`grid h-5 w-5 flex-none place-items-center rounded-full ${selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2] bg-white'} ${className}`}
>
{selected ? (
<svg viewBox="0 0 16 16" fill="none" className="h-3 w-3 text-white" aria-hidden="true">
<path d="M3.5 8.5L6.5 11.5L12.5 4.5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : null}
</span>
);
}
@@ -105,7 +105,21 @@ export function EnterpriseRegisterPage() {
<input
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
onChange={(e) => {
const selected = e.target.files?.[0];
e.target.value = '';
if (!selected) return;
// `accept` is only a file-picker hint, not enforcement -- a user can still pick
// an oversized or wrong-type file via "所有文件". Matching TenantFeedbackPage's
// own screenshot validation rather than silently uploading and waiting on the
// backend's FILE_TOO_LARGE/UNSUPPORTED_MEDIA_TYPE rejection for the same check.
if (selected.size > 10 * 1024 * 1024 || !['image/jpeg', 'image/png', 'image/webp', 'application/pdf'].includes(selected.type)) {
setError('营业执照上传失败:仅支持 JPG/PNG/WebP/PDF,且不超过 10MB');
return;
}
setError('');
setFile(selected);
}}
className="hidden"
/>
<Icon name="upload_file" className="text-[22px] text-accent" />
+1 -1
View File
@@ -100,7 +100,7 @@ export function ResetPasswordPage() {
<div className="flex flex-1 items-center justify-center px-6 py-8">
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
<div className="flex flex-col gap-1">
<h1 className="m-0 text-[22px] font-bold text-text-title">重置密码</h1>
<h1 className="m-0 hidden text-[22px] font-bold text-text-title min-[900px]:block">重置密码</h1>
<p className="m-0 text-sm text-text-tertiary-1">
通过已绑定的手机号验证身份。未绑定手机号的账号请联系企业管理员重置。
</p>
+10 -2
View File
@@ -7,6 +7,7 @@ import { Icon } from '@/app/Icon';
import { ImApiError } from '@/lib/api/client';
import { resolveTenant } from '@/lib/api/tenant';
import { resetTinode } from '@/lib/tinode/connection';
import { requestConfirm } from '@/stores/confirmDialog';
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
export function TenantEntryPage() {
@@ -24,8 +25,14 @@ export function TenantEntryPage() {
if (!code.trim() || submitting) return;
// Reachable by navigating back here while already logged in (no route guard keeps an
// authenticated user out of /tenant) -- resetTinode() below would otherwise silently tear down
// that live session with no warning at all.
if (myUserId && !window.confirm('切换企业将退出当前登录,确定继续吗?')) return;
// that live session with no warning at all. `myUserId` alone isn't enough: it's an in-memory
// Zustand value that resets to null on any fresh mount of this module (a page reload while
// sitting on /tenant, or opening this URL directly in a new tab) even though the session token
// is still sitting in sessionStorage and would otherwise restore fine -- confirmed by reading
// RequireAuth.tsx, which is the ONLY place that re-populates `myUserId` from that token, and it
// never mounts for this route. Checking the token directly closes that gap.
const hasLiveSession = myUserId || sessionStorage.getItem('im-session-token');
if (hasLiveSession && !(await requestConfirm({ message: '切换企业将退出当前登录,确定继续吗?' }))) return;
setSubmitting(true);
setError('');
try {
@@ -87,6 +94,7 @@ export function TenantEntryPage() {
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="请输入企业码"
maxLength={64}
className="w-full bg-transparent text-[15px] outline-none"
/>
</label>
@@ -47,12 +47,20 @@ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: V
const click = async () => {
if (busy || seconds > 0 || !PHONE_RE.test(phone)) return;
// The phone number this request is actually FOR -- captured now, not read again after the
// `await`. Without this, editing the phone field (e.g. fixing a typo) while the request is in
// flight lets a stale response apply a challenge_id/countdown for the OLD number to whatever
// number is now showing, with nothing on screen indicating the mismatch -- the eventual
// verification would silently operate on an account the user never intended.
const requestedPhone = phone;
setBusy(true);
try {
const data = await requestVerificationCode(scene, phone);
const data = await requestVerificationCode(scene, requestedPhone);
if (requestedPhone !== phone) return;
onChallenge(data.challenge_id);
tick(data.resend_after_seconds || 60);
} catch (err) {
if (requestedPhone !== phone) return;
onError(err instanceof ImApiError ? err.message : '获取验证码失败');
// Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it.
const details = err instanceof ImApiError ? (err.details as Record<string, unknown> | undefined) : undefined;
+34 -6
View File
@@ -1,5 +1,5 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls';
@@ -28,9 +28,12 @@ function statusLabel(item: CallHistoryItem): string {
/** README §7 doesn't spec a dedicated call-history screen beyond the "最近通话" idea implicit in
* NavRail's own 通话记录 entry -- this is a plain list off the confirmed real `GET /v1/im/calls`
* endpoint, whose response shape (see calls.ts's own comment) turned out completely different
* from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id, unlike
* `topic`, which comes back `p2p...`-hashed and unusable for navigation/re-dialing) is resolved to
* a display name via the chat list, same lookup ChatListColumn itself uses.
* from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id) is used
* to navigate/re-dial (any valid Tinode P2P address works there, resolved by the SDK same as opening
* a friend's profile does); `topic` (the same opaque `p2p...` form the chat list itself caches once
* a conversation has real history) is what display-name resolution matches against instead -- the
* two are different identifier spaces and matching name lookup against `peer_user_id` was a real,
* live-confirmed bug (Zentao #27) that always fell through to the raw uid.
*/
export function CallHistoryView() {
const navigate = useNavigate();
@@ -45,12 +48,30 @@ export function CallHistoryView() {
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
<header className="flex h-15 flex-none items-center border-b border-border-panel bg-white px-6">
{/* `/calls`, like `/favorites`, is only reachable on a narrow viewport via 设置(我的)>通话记录
(SettingsLayout.tsx's MOBILE_SHORTCUTS) -- same "back returns there" convention
FavoritesLayout's own mobile header already uses, and the same gap this file didn't have
until now (there was no way back from here at all short of a system back gesture). */}
<header className="grid h-15 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-panel bg-white px-1 md:hidden">
<Link to="/settings" className="grid h-9 w-9 place-items-center text-text-heading">
<Icon name="arrow_back_ios_new" className="text-[18px]" />
</Link>
<strong className="truncate text-center text-base font-semibold text-text-heading">通话记录</strong>
<span />
</header>
<header className="hidden h-15 flex-none items-center border-b border-border-panel bg-white px-6 md:flex">
<strong className="text-base font-semibold text-text-heading">通话记录</strong>
</header>
<div className="flex flex-col gap-2 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无通话记录</div>
) : (
@@ -62,7 +83,14 @@ export function CallHistoryView() {
// something the viewer missed and should feel warned about. This used to redden EVERY
// non-'finished' result alike.
const isMissed = result === 'missed' && incoming;
const name = contacts.find((c) => c.topic === item.peer_user_id)?.public?.fn || item.peer_user_id;
// NOT `c.topic === item.peer_user_id` -- live-confirmed real bug (Zentao #27): `peer_user_id`
// is a real `usr...` id, but an established P2P conversation's `contact.topic` is the SDK's
// renamed, opaque `p2p...` form (see getP2PPeerUserId's own doc comment in topics.ts for the
// full mechanism) -- the two are different identifier spaces and this comparison never
// matched for any call with real history, always falling through to the raw uid. `item.topic`
// is that same `p2p...` form already (confirmed live: identical string to what the chat list
// caches for this conversation), so matching on it directly is the correct, direct fix.
const name = contacts.find((c) => c.topic === item.topic)?.public?.fn || item.peer_user_id;
const ts = item.started_at || item.created_at;
return (
<div key={`${item.call_id}`} className="flex items-center gap-3 rounded-xl bg-white px-4 py-3">
+4 -1
View File
@@ -375,8 +375,11 @@ function EndedCallOverlay() {
return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false };
case 'missed':
return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true };
// `wasCaller` here is `false` exactly when this device is the one that just clicked
// "拒绝" (rejectCall() only runs during `phase==='incoming'`) -- same distinction `missed`
// right above already makes, "对方已拒绝" is backwards on the decliner's own screen.
case 'declined':
return { label: '对方已拒绝', danger: true };
return { label: wasCaller ? '对方已拒绝' : '已拒绝', danger: true };
case 'disconnected':
return { label: '通话连接异常,已结束', danger: true };
case 'busy':
+23 -7
View File
@@ -12,13 +12,10 @@ import { searchUsers, sendFriendRequest, type UserSearchResult } from '@/lib/api
import { submitGroupJoinRequest } from '@/lib/api/groups';
import type { QRResolveGroup, QRResolveUser } from '@/lib/api/qrcodes';
import { useEscapeKey } from '@/lib/hooks';
import { notImplemented } from '@/stores/globalToast';
const MAX_MESSAGE_LENGTH = 50;
function notImplemented() {
window.alert('该功能暂未开放,如需联系客服。');
}
interface AddFriendModalProps {
onClose: () => void;
onSent?: () => void;
@@ -197,9 +194,9 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
key={action.label}
type="button"
onClick={action.onClick}
className="flex flex-col items-center gap-1.5 rounded-lg bg-surface-input py-3 text-xs text-text-secondary"
className="flex items-center justify-center gap-2 rounded-lg bg-surface-input py-3 text-xs text-text-secondary"
>
<Icon name={action.icon} className="text-xl text-text-tertiary-1" />
<Icon name={action.icon} className="text-xl text-accent" />
{action.label}
</button>
))}
@@ -211,6 +208,13 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
<div className="mt-4 flex flex-col gap-2">
{searchQuery.isLoading ? (
<div className="py-6 text-center text-sm text-text-tertiary-4">搜索中…</div>
) : searchQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-6 text-sm text-danger-text">
<span>搜索失败,请重试</span>
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !searchQuery.data?.items.length ? (
<div className="py-6 text-center text-sm text-text-tertiary-4">未找到相关用户</div>
) : (
@@ -223,6 +227,18 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
type="button"
disabled={!selectable}
onClick={() => {
// Real, live-confirmed backend validation (im_http.go's
// serveIMFriendRequestCreate): the `source` field's switch only recognizes
// `search`/`qr`/`group` -- there is no `phone` case, so it falls to
// `default` and 400s with "invalid friend request source". A prior pass
// here tried to report phone-originated searches as their own `phone`
// source (for NewFriendsView's SOURCE_LABEL table to show "通过手机号搜索"),
// which broke every phone-search-based friend request outright -- reproduced
// live with two brand-new accounts before this fix (first-ever request,
// never a retry/idempotency issue as it first looked). Phone and account
// search both go through the same `search` channel from the backend's own
// point of view; there is no server-side data to recover that distinction
// from, so `SOURCE_LABEL`'s dead `phone` entry is removed alongside this.
setSelectedSource('search');
setSelected(item);
}}
@@ -234,7 +250,7 @@ export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModa
{item.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {item.public_account}</span> : null}
</span>
{selectable ? (
<span className={`flex-none rounded-full px-2.5 py-1 text-xs ${isSelected ? 'bg-accent-soft-bg text-accent' : 'bg-surface-input text-text-tertiary-2'}`}>
<span className={`flex-none rounded-md px-2.5 py-1 text-xs ${isSelected ? 'bg-accent-soft-bg text-accent' : 'bg-surface-input text-text-tertiary-2'}`}>
{isSelected ? '已选择' : '选择'}
</span>
) : (
+9 -9
View File
@@ -5,6 +5,7 @@ import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { batchUnblockUsers, getBlocks, unblockUser } from '@/lib/api/friends';
@@ -102,6 +103,13 @@ export function BlocklistView() {
<div className="flex flex-col gap-2.5 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">黑名单是空的</div>
) : (
@@ -114,15 +122,7 @@ export function BlocklistView() {
onKeyDown={selectMode ? (e) => e.key === 'Enter' && toggleSelected(b.user.user_id) : undefined}
className={`flex items-center gap-3 rounded-xl bg-white px-4 py-3 shadow-bubble ${selectMode ? 'cursor-pointer' : ''}`}
>
{selectMode ? (
<span
className={`grid h-5 w-5 flex-none place-items-center rounded-full border text-xs text-white ${
selected.has(b.user.user_id) ? 'border-accent bg-accent' : 'border-[#d5d9df] bg-white'
}`}
>
{selected.has(b.user.user_id) ? <Icon name="check" className="text-[14px]" /> : null}
</span>
) : null}
{selectMode ? <SelectionCheckbox selected={selected.has(b.user.user_id)} /> : null}
<Avatar
id={b.user.user_id}
name={b.user.display_name}
@@ -65,7 +65,7 @@ export function ContactsListColumn() {
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">通讯录</h1>
<button type="button" onClick={() => setAddingFriend(true)} className="grid h-8 w-8 place-items-center text-text-heading">
<button type="button" onClick={() => setAddingFriend(true)} className="grid h-10 w-10 place-items-center text-text-heading md:h-9 md:w-9">
<Icon name="person_add_alt" />
</button>
</header>
@@ -119,6 +119,13 @@ export function ContactsListColumn() {
)}
{friendsQuery.isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
) : friendsQuery.isError ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => friendsQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
<Icon name={trimmedQuery ? 'search_off' : 'people_outline'} className="!text-4xl" />
@@ -70,12 +70,28 @@ export function FriendProfileView() {
// Confirmed live 2026-09-09: a 403 here comes back with reason PERMISSION_DENIED and message
// "profile is private" -- a real privacy setting on the other side, not a missing/deleted
// account. Collapsing it into "未找到该用户" would misrepresent an intentional privacy choice
// as if the user doesn't exist; every other failure (404, network error) keeps that fallback.
// as if the user doesn't exist.
const isPrivate = query.error instanceof ImApiError && query.error.status === 403;
// A genuine 404 is the only case that should actually read as "this user doesn't exist" --
// every OTHER failure (network error, 5xx, a transient timeout) used to fall into that same
// "未找到该用户" bucket too, telling the user an account is gone/never existed when the real
// problem is just that this one request failed and could succeed on retry.
const isNotFound = query.error instanceof ImApiError && query.error.status === 404;
return (
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
<div className="relative flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-sm text-text-tertiary-4">
{backLink}
{isPrivate ? '对方设置了资料保护,暂时无法查看' : '未找到该用户'}
{isPrivate ? (
'对方设置了资料保护,暂时无法查看'
) : isNotFound ? (
'未找到该用户'
) : (
<>
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</>
)}
</div>
);
}
+12 -1
View File
@@ -66,6 +66,13 @@ export function GroupListView() {
<div className="flex flex-col gap-2.5 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : filtered.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群聊</div>
) : (
@@ -89,7 +96,11 @@ export function GroupListView() {
) : null}
</span>
<span className="truncate text-xs text-text-tertiary-2">
{g.member_count} 人 · 群号 {g.topic}
{/* `GET /groups`'s own `member_count` can come back as a bare 0 for a real, non-empty
group (confirmed live -- same backend dual-counter split as ChatListColumn's own
"N人" pill, 问题8 in the backend issue list) -- no real group has zero members, so
an exact zero here means "don't trust this field", not "empty group". */}
{g.member_count > 0 ? `${g.member_count} 人 · ` : null}群号 {g.topic}
</span>
</span>
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
+13 -1
View File
@@ -11,7 +11,12 @@ import { acceptFriendRequest, getFriendRequests, rejectFriendRequest, type Frien
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加', phone: '通过手机号搜索' };
// `phone` deliberately excluded -- the backend's own `source` validation (im_http.go's
// serveIMFriendRequestCreate) only accepts `search`/`qr`/`group`; a phone-number search reports the
// same `search` value an account-name search does, since the server has no separate channel for it
// (see AddFriendModal.tsx's own selection handler for the live-confirmed 400 this caused when it
// tried to report `phone` as its own source instead).
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加' };
/** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝")
* inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches
@@ -69,6 +74,13 @@ export function NewFriendsView() {
<div className="flex flex-col gap-3 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无新朋友申请</div>
) : (
+13 -2
View File
@@ -35,15 +35,23 @@ export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProp
queryFn: (): Promise<MyQRCode | GroupQRCode> => (groupTopic ? createGroupQRCode(groupTopic) : identifier ? createUserQRCode(identifier) : createMyQRCode()),
});
useEffect(() => {
const draw = () => {
if (!query.data || !canvasRef.current) return;
const canvas = canvasRef.current;
setDrawError('');
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-generating library
// isn't in every visitor's main bundle when most sessions never open this modal.
import('qrcode')
.then(({ default: QRCode }) => QRCode.toCanvas(canvas, query.data.qr_payload, { width: 200, margin: 1, color: { dark: '#22262d', light: '#ffffff' } }))
.catch(() => setDrawError('二维码生成失败'));
}, [query.data]);
};
useEffect(draw, [query.data]);
const retry = () => {
if (query.isError) query.refetch();
else draw();
};
const profile = query.data && 'display_profile' in query.data ? query.data.display_profile : undefined;
const group = query.data && 'group_snapshot' in query.data ? query.data.group_snapshot : undefined;
@@ -117,6 +125,9 @@ export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProp
<div className="flex flex-col items-center gap-2 px-4 text-center">
<Icon name="error_outline" className="text-2xl text-danger-text" />
<span className="text-xs text-text-tertiary-4">{drawError || '二维码生成失败,请重试'}</span>
<button type="button" onClick={retry} className="text-xs text-accent">
重试
</button>
</div>
) : (
<canvas ref={canvasRef} />
+22 -17
View File
@@ -4,6 +4,7 @@ import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-q
import { Link } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { Toast } from '@/app/Toast';
import { AudioAttachment } from '@/features/messages/AttachmentPreview';
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
@@ -20,6 +21,7 @@ import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks';
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useChatList } from '@/lib/tinode/hooks';
import { requestConfirm } from '@/stores/confirmDialog';
const CATEGORIES: { key: FavoriteType | 'all'; label: string; icon: string }[] = [
{ key: 'all', label: '全部', icon: 'all_inbox' },
@@ -189,17 +191,9 @@ function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite
>
<div className="relative">
{body}
{selectMode ? (
<span
className={`absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full text-[13px] text-white ${
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2] bg-white'
}`}
>
{selected ? <Icon name="check" className="text-[14px]" /> : null}
</span>
) : null}
{selectMode ? <SelectionCheckbox selected={selected} className="absolute -right-1 -top-1" /> : null}
</div>
<div className="flex items-center justify-between border-t border-border-list-item pt-2.5 text-xs text-text-tertiary-3">
<div className="flex items-center justify-between border-t border-border-row pt-2.5 text-xs text-text-tertiary-3">
<SourceLine source={item.source} />
<span className="flex-none">{formatEventTimestamp(new Date(item.collected_at))}</span>
</div>
@@ -277,8 +271,16 @@ export function FavoritesLayout() {
},
onSuccess: (data) => {
invalidateAll();
// Exiting select mode unconditionally used to clear `selected` even on a PARTIAL failure --
// the error toast named a count ("N项删除失败") but gave no way to act on it, so retrying
// meant re-selecting from scratch by memory. Only actually-failed ids stay selected instead,
// primed for an immediate retry.
if (data?.failed?.length) {
setSelected(new Set(data.failed.map((f) => f.favorite_id)));
setError(`${data.failed.length} 项删除失败,请重试`);
} else {
exitSelectMode();
if (data?.failed?.length) setError(`${data.failed.length} 项删除失败,请重试`);
}
},
onError: (err) => setError(err instanceof Error ? err.message : '删除失败'),
});
@@ -290,10 +292,13 @@ export function FavoritesLayout() {
forwardFavorites([...selected], targetTopics, comment),
onSuccess: (data) => {
setForwarding(false);
exitSelectMode();
// Same `failed[]` gap as deleteMutation above (favorites.ts:101) -- forwarding to 3 targets can
// succeed for 2 and fail for 1, and silently reporting "success" either way hides that.
// succeed for 2 and fail for 1, and silently reporting "success" either way (including
// clearing the favorites selection) hid that AND left nothing to retry from. The picker
// itself still closes (which of several targets failed isn't tracked here) but the source
// favorites stay selected so re-opening "转发" immediately can retry against the same set.
if (data.failed?.length) setError(`${data.failed.length} 项转发失败,请重试`);
else exitSelectMode();
},
onError: (err) => setError(err instanceof Error ? err.message : '转发失败'),
});
@@ -327,9 +332,9 @@ export function FavoritesLayout() {
setSelectMode(false);
setSelected(new Set());
};
const bulkDelete = () => {
const bulkDelete = async () => {
if (selected.size === 0) return;
if (!window.confirm(`删除已选择的 ${selected.size} 项收藏?`)) return;
if (!(await requestConfirm({ message: `删除已选择的 ${selected.size} 项收藏?`, danger: true }))) return;
deleteMutation.mutate([...selected]);
};
@@ -341,7 +346,7 @@ export function FavoritesLayout() {
type="button"
onClick={() => switchCategory(c.key)}
className={`flex-none whitespace-nowrap rounded-2xl px-3.5 py-1.5 text-[13px] ${
category === c.key ? 'bg-accent text-white' : 'bg-[#f1f2f4] text-text-body'
category === c.key ? 'bg-accent text-white' : 'bg-[#f1f2f4] text-text-tertiary-1'
}`}
>
{c.label}
@@ -385,7 +390,7 @@ export function FavoritesLayout() {
category === c.key ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
}`}
>
<Icon name={c.icon} className="text-[18px]" />
<Icon name={c.icon} className={`text-[18px] ${category === c.key ? '' : 'text-text-tertiary-4'}`} />
<span className="flex-1 text-left">{c.label}</span>
</button>
))}
+27 -8
View File
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends';
import { createGroup, getGroupGovernance, updateGroupGovernance } from '@/lib/api/groups';
@@ -61,13 +62,37 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps)
setSubmitting(true);
setError('');
let result: Awaited<ReturnType<typeof createGroup>>;
// The input's own placeholder promises "未填写时默认为成员昵称组合" (defaults to a combination
// of member nicknames when left blank), but `name` has no such fallback server-side (unlike
// `description`, which the protocol doc explicitly allows empty) -- confirmed live: submitting a
// blank name gets rejected outright with MALFORMED_REQUEST, not silently defaulted. Declared
// outside the try block since the catch below needs it too.
const usedAutoName = !name.trim();
try {
// Truncated the same way the input field itself is (MAX_TITLE_LENGTH), with an ellipsis when
// it had to cut names short, so this can't itself produce an over-length name the server would
// also reject.
const trimmedName = name.trim();
let finalName = trimmedName;
if (!finalName) {
const joined = [...selected.values()].join('、');
finalName = joined.length > MAX_TITLE_LENGTH ? `${joined.slice(0, MAX_TITLE_LENGTH - 1)}…` : joined;
}
result = await createGroup({
name: name.trim(),
name: finalName,
member_user_ids: [...selected.keys()],
});
} catch (err) {
// Real, live-confirmed gap (Zentao #26): a name the user never typed themselves -- the
// "未填写时默认为成员昵称组合" fallback just above -- can still get rejected (e.g. content
// moderation flagging a purely-numeric nickname combination), and the generic error message
// gives someone who left the field blank no clue that "the name" is even what's wrong, let
// alone that they can fix it by typing one in themselves.
if (usedAutoName) {
setError('自动生成的群名未通过审核,请手动输入一个群名后重试');
} else {
setError(err instanceof Error ? err.message : '创建群聊失败');
}
setSubmitting(false);
return;
}
@@ -133,13 +158,7 @@ export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps)
onClick={() => toggle(f.user_id, label)}
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-surface-rail"
>
<span
className={`grid h-5 w-5 flex-none place-items-center rounded-full ${
checked ? 'bg-accent text-white' : 'border-[1.5px] border-[#c5cad2]'
}`}
>
{checked ? <Icon name="check" className="text-[13px]" /> : null}
</span>
<SelectionCheckbox selected={checked} />
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}
>
+47 -21
View File
@@ -10,7 +10,7 @@ import {
getAllGroupMembers,
getGroup,
getGroupGovernance,
getGroupMutes,
getAllGroupMutes,
getGroupRoleLimits,
getGroupSummary,
muteGroupMembers,
@@ -23,6 +23,8 @@ import {
} from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { requestConfirm } from '@/stores/confirmDialog';
import { requestActionToken } from '@/stores/securityChallenge';
import { useSessionStore } from '@/stores/session';
interface GroupAdminModalProps {
@@ -105,7 +107,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
queryFn: () => getAllGroupMembers(topicId, { role: 'admin' }),
enabled: isOwner,
});
const mutesQuery = useQuery({ queryKey: ['group-mutes', topicId], queryFn: () => getGroupMutes(topicId) });
const mutesQuery = useQuery({ queryKey: ['group-mutes', topicId], queryFn: () => getAllGroupMutes(topicId) });
const govMutation = useMutation({
mutationFn: (patch: Parameters<typeof updateGroupGovernance>[1]) => updateGroupGovernance(topicId, patch),
@@ -132,7 +134,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
onClose={() => setView('main')}
/>
);
if (view === 'mutes') return <MutesSubView topicId={topicId} muted={mutesQuery.data?.items || []} onClose={() => setView('main')} />;
if (view === 'mutes') return <MutesSubView topicId={topicId} muted={mutesQuery.data || []} onClose={() => setView('main')} />;
if (view === 'transfer') return <TransferOwnerSubView topicId={topicId} groupVersion={groupQuery.data?.version} onClose={() => setView('main')} />;
return (
@@ -152,7 +154,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
</div>
<Toggle
checked={!!govQuery.data?.join_approval}
disabled={!govQuery.data || govMutation.isPending}
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.join_approval_editable === false}
onChange={() => setGov({ join_approval: !govQuery.data?.join_approval })}
/>
</div>
@@ -163,7 +165,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
</div>
<Toggle
checked={!!govQuery.data?.mute_all}
disabled={!govQuery.data || govMutation.isPending}
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.mute_all_editable === false}
onChange={() => setGov({ mute_all: !govQuery.data?.mute_all })}
/>
</div>
@@ -176,7 +178,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
{ value: 'admin', label: '仅管理员' },
]}
onChange={(v) => setGov({ member_upload_files: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending}
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_upload_files_editable === false}
/>
</div>
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
@@ -188,7 +190,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
{ value: 'friends', label: '仅好友' },
]}
onChange={(v) => setGov({ member_private_chat: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending}
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_private_chat_editable === false}
/>
</div>
{/* `governance.member_invite`/`member_invite_editable` (docs/IM-前后端对接文档.md:1600) is a
@@ -205,7 +207,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
{ value: 'admin', label: '仅管理员' },
]}
onChange={(v) => setGov({ member_invite: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending}
disabled={!govQuery.data || govMutation.isPending || govQuery.data?.member_invite_editable === false}
/>
</div>
{/* Owner-only, same as 转让群主 below -- the backend's own permission check for this
@@ -225,7 +227,7 @@ export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
<button type="button" onClick={() => setView('mutes')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
<span className="text-text-body">禁言成员</span>
<span className="flex items-center gap-1 text-text-tertiary-2">
<span className="text-xs">{mutesQuery.data?.items.length ?? 0} 人</span>
<span className="text-xs">{mutesQuery.data?.length ?? 0} 人</span>
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
</span>
</button>
@@ -383,10 +385,20 @@ function AdminsSubView({
</button>
</div>
))}
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
{(() => {
const atLimit = !!maxAdminCount && admins.length >= maxAdminCount;
return (
<button
type="button"
onClick={() => setAdding(true)}
disabled={atLimit}
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent disabled:text-text-tertiary-3"
>
<Icon name="add" className="text-[18px]" />
添加管理员
{atLimit ? '已达管理员人数上限' : '添加管理员'}
</button>
);
})()}
</>
)}
<Toast message={error} variant="error" onDone={() => setError('')} />
@@ -398,6 +410,13 @@ function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: Gro
const queryClient = useQueryClient();
const [adding, setAdding] = useState(false);
const [error, setError] = useState('');
// `GroupMute` itself carries only a bare `user_id` (see its own type comment) -- resolve a real
// name/avatar the same way ConversationView.tsx's `memberByUserId` does, rather than rendering the
// raw id (this list previously crashed outright reading a `.user.display_name` that never existed
// on the real response, confirmed live: `Cannot read properties of undefined (reading 'user_id')`
// the moment a real muted member was ever rendered here).
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const memberByUserId = new Map((membersQuery.data || []).map((m) => [m.user_id, m]));
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['group-mutes', topicId] });
const muteMutation = useMutation({
mutationFn: (userId: string) => muteGroupMembers(topicId, [{ user_id: userId, expires_at: null }]),
@@ -427,22 +446,25 @@ function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: Gro
/>
) : (
<>
{muted.map((m) => (
<div key={m.user.user_id} className="flex items-center gap-3 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.user_id)}`}>
{avatarLetter(m.user.display_name)}
{muted.map((m) => {
const name = memberByUserId.get(m.user_id)?.display_name || m.user_id;
return (
<div key={m.user_id} className="flex items-center gap-3 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(name)}
</span>
<span className="flex-1 truncate text-sm text-text-body">{m.user.display_name || m.user.user_id}</span>
<span className="flex-1 truncate text-sm text-text-body">{name}</span>
<button
type="button"
onClick={() => unmuteMutation.mutate(m.user.user_id)}
onClick={() => unmuteMutation.mutate(m.user_id)}
disabled={unmuteMutation.isPending}
className="text-xs text-accent disabled:opacity-50"
>
解除禁言
</button>
</div>
))}
);
})}
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
<Icon name="add" className="text-[18px]" />
禁言成员
@@ -458,7 +480,8 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
const queryClient = useQueryClient();
const [error, setError] = useState('');
const transferMutation = useMutation({
mutationFn: (userId: string) => transferGroupOwner(topicId, userId, groupVersion ?? 1),
mutationFn: ({ userId, actionToken }: { userId: string; actionToken: string }) =>
transferGroupOwner(topicId, userId, groupVersion ?? 1, actionToken),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] });
@@ -478,8 +501,11 @@ function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: str
topicId={topicId}
cacheKey="eligible-owner"
opts={{ eligibleRole: 'owner' }}
onPick={(m) => {
if (window.confirm(`确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`)) transferMutation.mutate(m.user_id);
onPick={async (m) => {
if (!(await requestConfirm({ message: `确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`, danger: true }))) return;
const actionToken = await requestActionToken({ scene: 'group_owner_transfer', actionDescription: '转让群主' });
if (!actionToken) return;
transferMutation.mutate({ userId: m.user_id, actionToken });
}}
emptyLabel="没有可转让的成员"
disabled={transferMutation.isPending}
+73 -25
View File
@@ -3,6 +3,7 @@ import { useLayoutEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
@@ -24,8 +25,11 @@ import {
type GroupSettings,
} from '@/lib/api/groups';
import type { MediaType } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
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';
interface GroupDetailColumnProps {
@@ -78,7 +82,7 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
const [nicknameEditing, setNicknameEditing] = useState(false);
const [nicknameDraft, setNicknameDraft] = useState('');
const [errorToast, setErrorToast] = useState('');
const [openModal, setOpenModal] = useState<'members' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
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);
// Same long-lived-instance leak this exact pattern already caused twice in DetailColumn.tsx --
@@ -117,6 +121,11 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
// (no extra request needed) -- live-verified this reflects the real value (flipped it via
// GroupAdminModal's own PATCH, re-fetched `/settings`, confirmed it changed), not a frozen default.
const canInvite = isAdmin || settingsQuery.data?.governance?.member_invite === true;
// Same gap as `canInvite` right above (same governance toggle pattern, same root cause: the
// "群文件上传权限" toggle in GroupAdminModal writes `member_upload_files` but nothing ever read it
// back) -- `GroupFilesModal`'s upload button was unconditionally visible/clickable regardless of
// this setting.
const canUpload = isAdmin || settingsQuery.data?.governance?.member_upload_files === true;
// No `version` in the request -- see updateGroupMemberSettings's own doc comment for why sending
// one 400s every single call (this was the actual root cause of "member-settings always 400",
@@ -143,33 +152,57 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
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
// stub for this group after leaving/disbanding it (same lingering-stub symptom `forgetTopic` was
// originally built to fix for the refresh/404 case, just reached via a third path); (2) that same
// late signal, left unguarded, used to re-fire ConversationView's generic "该群聊已解散" toast on
// top of this action's own navigate -- wrong wording specifically for 退出群聊 (leaving yourself
// isn't the group being dissolved). `markTopicGoneAndNotify`'s own guard (topics.ts) now skips that
// late signal entirely once `forgetTopic` has already marked this name gone here.
const leaveMutation = useMutation({
mutationFn: () => leaveGroup(topicId),
onSuccess: () => navigate('/messages'),
onSuccess: () => {
forgetTopic(topicId);
navigate('/messages');
},
onError: (err) => showError(err, '退出群聊失败'),
});
const disbandMutation = useMutation({
mutationFn: () => disbandGroup(topicId),
onSuccess: () => navigate('/messages'),
mutationFn: (actionToken: string) => disbandGroup(topicId, actionToken),
onSuccess: () => {
forgetTopic(topicId);
navigate('/messages');
},
onError: (err) => showError(err, '解散群聊失败'),
});
const clearHistory = () => {
if (!window.confirm('确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。')) return;
tinode.getTopic(topicId).delMessagesAll(false);
const clearHistory = async () => {
if (!(await requestConfirm({ message: '确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。', danger: true }))) return;
try {
await tinode.getTopic(topicId).delMessagesAll(false);
} catch (err) {
showError(err, '清空聊天记录失败,请重试');
}
};
const reportAbuse = () => {
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
notify('该功能暂未开放,如需投诉举报请联系客服。');
};
const startLeaveOrDisband = () => {
const startLeaveOrDisband = async () => {
if (isOwner) {
if (!window.confirm('解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?')) return;
disbandMutation.mutate();
if (!(await requestConfirm({ message: '解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?', danger: true }))) return;
const actionToken = await requestActionToken({
scene: 'group_dissolve',
actionDescription: `解散群聊「${groupQuery.data?.name || peerName}」`,
});
if (!actionToken) return;
disbandMutation.mutate(actionToken);
} else {
if (!window.confirm('确定退出本群聊吗?')) return;
if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) return;
leaveMutation.mutate();
}
};
@@ -220,9 +253,12 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
<div className="bg-white md:contents">
<div className="flex flex-col gap-2 px-4 py-4 md:border-b md:border-border-list-item md:px-5">
<div className="flex items-center gap-3">
<span className={`grid h-13 w-13 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(topicId)}`}>
{avatarLetter(groupQuery.data?.name || peerName)}
</span>
<Avatar
id={topicId}
name={groupQuery.data?.name || peerName}
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>
<span className="text-xs text-text-tertiary-2">
@@ -257,17 +293,24 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
<div key={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). */}
<span
className={`grid h-11 w-11 place-items-center rounded-lg text-sm font-medium text-white md:h-10 md:w-10 ${avatarBgClass(m.user_id)}`}
>
{avatarLetter(m.display_name)}
</span>
<Avatar
id={m.user_id}
name={m.display_name}
avatarFileId={m.avatar_file_id}
className="h-11 w-11 rounded-lg text-sm md:h-10 md:w-10"
/>
<RoleCorner role={m.role} />
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
</div>
))}
{canInvite ? (
<button type="button" onClick={() => setOpenModal('members')} className="flex flex-col items-center gap-1">
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
// "invite"`), not the member list -- this dedicated "+邀请" tile landing on the member
// list and requiring a SECOND click on an in-modal "邀请" button was a real, reported
// UX complaint ("点击邀请那个按钮弹出的并不是直接到邀请,而是显示的成员列表,还要再次
// 点击邀请"). "查看全部"/"查看全部成员" right below/above this still correctly open the
// plain member list -- only THIS tile's whole purpose is inviting.
<button type="button" onClick={() => setOpenModal('invite')} 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="add" className="text-[18px]" />
</span>
@@ -403,13 +446,18 @@ export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }:
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
{openModal === 'members' ? (
<GroupMembersModal topicId={topicId} canInvite={canInvite} onClose={() => setOpenModal(null)} />
{openModal === 'members' || openModal === 'invite' ? (
<GroupMembersModal
topicId={topicId}
canInvite={canInvite}
initialView={openModal === 'invite' ? 'invite' : 'members'}
onClose={() => setOpenModal(null)}
/>
) : null}
{openModal === 'announcement' ? (
<GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(null)} />
) : null}
{openModal === 'files' ? <GroupFilesModal topicId={topicId} isAdmin={isAdmin} onClose={() => setOpenModal(null)} /> : null}
{openModal === 'files' ? <GroupFilesModal topicId={topicId} isAdmin={isAdmin} canUpload={canUpload} onClose={() => setOpenModal(null)} /> : null}
{openModal === 'admin' ? <GroupAdminModal topicId={topicId} onClose={() => setOpenModal(null)} /> : null}
{openModal === 'qrcode' ? <QRCodeModal groupTopic={topicId} onClose={() => setOpenModal(null)} /> : null}
</aside>
+17 -4
View File
@@ -9,6 +9,7 @@ import { fileAuthHeaders, fileDownloadUrl, uploadFile } from '@/lib/api/upload';
import { useClickOutside, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
import { formatEventTimestamp } from '@/lib/messages/time';
import { requestConfirm } from '@/stores/confirmDialog';
import { useSessionStore } from '@/stores/session';
function isPdf(f: GroupFile): boolean {
@@ -33,6 +34,11 @@ interface GroupFilesModalProps {
* owner/admin has no way to remove another member's upload from this UI even though the server
* would accept the request. */
isAdmin: boolean;
/** `governance.member_upload_files` (所有成员/仅管理员), computed by the caller alongside
* `isAdmin` the exact same way `GroupDetailColumn`'s own `canInvite` already is -- without this,
* the toggle in GroupAdminModal had zero effect on this button, the same class of bug
* `canInvite` was already fixed for. */
canUpload: boolean;
onClose: () => void;
}
@@ -59,7 +65,7 @@ function fileTypeFromMime(mime: string, name: string): string {
* more fields than the protocol doc's own minimal example (see groups.ts's header comment), ported
* from the old app's real, working upload flow rather than guessed from the doc.
*/
export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalProps) {
export function GroupFilesModal({ topicId, isAdmin, canUpload, onClose }: GroupFilesModalProps) {
const queryClient = useQueryClient();
const myUserId = useSessionStore((s) => s.myUserId);
const [query, setQuery] = useState('');
@@ -138,7 +144,11 @@ export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalPr
<strong className="text-[15px] font-semibold text-text-heading">群文件 {files.length}</strong>
<div className="flex items-center gap-1">
{/* Mock (#1h): accent-blue `upload` icon + "上传" label, not a bare icon-only button in
the neutral gray this used to be. */}
the neutral gray this used to be. Hidden (not just disabled) when governance says
only admins may upload and this viewer isn't one -- there's no "ask an admin" action
a plain member could take from here, so a visible-but-blocked button would only
invite a confusing click. */}
{canUpload ? (
<label
className={`flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-[13px] text-accent hover:bg-surface-rail ${uploading ? 'pointer-events-none opacity-50' : ''}`}
>
@@ -146,6 +156,7 @@ export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalPr
上传
<input type="file" onChange={handleUpload} disabled={uploading} className="hidden" />
</label>
) : null}
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
@@ -211,9 +222,11 @@ export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalPr
onClose={() => setOpenMenuId(null)}
onDownload={() => downloadFile(f)}
onOpen={() => openFileInNewTab(f)}
onDelete={() => {
onDelete={async () => {
setOpenMenuId(null);
if (window.confirm(`确定删除文件"${f.file_name}"吗?`)) deleteMutation.mutate(f.group_file_id);
if (await requestConfirm({ message: `确定删除文件"${f.file_name}"吗?`, danger: true })) {
deleteMutation.mutate(f.group_file_id);
}
}}
deleting={deleteMutation.isPending}
/>
+53 -8
View File
@@ -6,7 +6,7 @@ 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 GroupRole } from '@/lib/api/groups';
import { getAllGroupMembers, inviteGroupMembers, type GroupInvitationResult, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
@@ -28,6 +28,13 @@ 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;
/** "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 "邀请"
* button to actually reach this view (a real, reported UX complaint: "点击邀请那个按钮弹出的并不是
* 直接到邀请,而是显示的成员列表,还要再次点击邀请"). Defaults to "members" for every OTHER caller
* (查看全部成员 etc.), which should still land on the member list as before. */
initialView?: 'members' | 'invite';
onClose: () => void;
}
@@ -36,11 +43,27 @@ interface GroupMembersModalProps {
* 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. */
export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersModalProps) {
/** 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
* (confirmed: grepped the protocol doc), so this can only ever reflect invites sent from THIS modal
* session, not ones sent earlier and never followed up on -- reopening the modal loses this. */
type InviteOutcome = GroupInvitationResult['status'];
const OUTCOME_LABEL: Record<InviteOutcome, string> = {
joined: '已加入',
already_member: '已在群内',
pending: '邀请已发送',
pending_approval: '待对方确认',
failed: '邀请失败',
};
export function GroupMembersModal({ topicId, canInvite, initialView = 'members', onClose }: GroupMembersModalProps) {
const queryClient = useQueryClient();
const [query, setQuery] = useState('');
const [inviting, setInviting] = useState(false);
const [inviting, setInviting] = useState(initialView === 'invite');
const [error, setError] = useState('');
const [outcomeByUser, setOutcomeByUser] = useState<Record<string, InviteOutcome>>({});
useEscapeKey(onClose);
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
@@ -59,7 +82,17 @@ export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersM
const inviteMutation = useMutation({
mutationFn: (userId: string) => inviteGroupMembers(topicId, [userId]),
onSuccess: () => {
onSuccess: (data, userId) => {
// A real, previously-missing gap this fixes: the old version only ever refetched the member
// list and left it at that -- for the common "joined immediately" case that's a silent success
// with no feedback beyond the candidate eventually re-rendering as "已在群内" on its own, and
// for "pending"/"pending_approval" (requires the target's own action, or the group's/their own
// approval setting) there was NO feedback at all -- the row just sat there looking un-invited,
// inviting a confused repeat click. `items[0]` because this call always invites exactly one
// user_id at a time.
const outcome = data.items[0]?.status;
if (outcome) setOutcomeByUser((prev) => ({ ...prev, [userId]: outcome }));
if (outcome === 'failed' && data.items[0]?.reason) setError(data.items[0].reason);
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
@@ -101,22 +134,34 @@ export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersM
.filter((f) => !query.trim() || (f.remark || f.display_name || f.user_id).toLowerCase().includes(query.trim().toLowerCase()))
.map((f) => {
const label = f.remark || f.display_name || f.user_id;
// `alreadyMember` (a real member, known up front) and a THIS-SESSION invite outcome
// are two different reasons a candidate can be non-actionable -- alreadyMember wins if
// both are somehow true (the member-list refetch above should already have cleared the
// "joined" outcome to alreadyMember=true by then anyway).
const outcome = f.alreadyMember ? 'already_member' : outcomeByUser[f.user_id];
// "failed" deliberately does NOT count as settled -- could be a transient network blip,
// and there's no harm in letting the same click retry (a genuinely permanent failure,
// e.g. the target blocked invites, just fails again harmlessly). Every other outcome is
// a real, done state with nothing left to retry.
const settled = !!outcome && outcome !== 'failed';
return (
<button
key={f.user_id}
type="button"
disabled={inviteMutation.isPending || f.alreadyMember}
disabled={inviteMutation.isPending || settled}
onClick={() => inviteMutation.mutate(f.user_id)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:hover:bg-transparent ${
f.alreadyMember ? 'opacity-50' : ''
settled ? 'opacity-50' : ''
}`}
>
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}>
{avatarLetter(label)}
</span>
<span className="flex-1 truncate text-sm text-text-body">{label}</span>
{f.alreadyMember ? (
<span className="flex-none text-xs text-text-tertiary-3">已在群内</span>
{outcome ? (
<span className={`flex-none text-xs ${outcome === 'failed' ? 'text-danger-text' : 'text-text-tertiary-3'}`}>
{OUTCOME_LABEL[outcome]}
</span>
) : (
<Icon name="person_add" className="text-[18px] text-accent" />
)}
+33 -9
View File
@@ -3,12 +3,14 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends';
import { getGroups } from '@/lib/api/groups';
import { useBootstrap } from '@/lib/api/hooks';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { getMe } from '@/lib/api/profile';
import { formatMessagePreview } from '@/lib/messages/preview';
import { formatListTime } from '@/lib/messages/time';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
@@ -55,6 +57,9 @@ export function ChatListColumn() {
const location = useLocation();
const navigate = useNavigate();
const myUserId = useSessionStore((s) => s.myUserId);
// `slf` (chat with yourself) never has its own `.public.fn` -- shares AppShell's `['me']` query
// key so this is a cache read, not an extra request, in the common case that already mounted it.
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
const tinode = useTinode();
const contacts = useChatList();
const bootstrap = useBootstrap();
@@ -63,6 +68,13 @@ export function ChatListColumn() {
// regardless (docs/IM-前后端对接文档.md:1444), not one request per group row.
const groupsQuery = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
const memberCountByTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.member_count]));
const avatarByGroupTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.avatar_file_id]));
// Same real gap as the conversation screen's header/message-row avatars (see ConversationView.tsx):
// a P2P row's `contact` object comes from the Tinode SDK's own local cache, which was never taught
// to carry the REST-only `avatar_file_id` this app uploads avatars through -- shares ContactsListColumn's
// exact `['friends']` query key, so this is a cache read whenever that page has already been visited.
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
const avatarByUserId = new Map(friendsQuery.data?.map((f) => [f.user_id, f.avatar_file_id]));
const [query, setQuery] = useState('');
const [creatingGroup, setCreatingGroup] = useState(false);
const sorted = [...contacts].sort((a, b) => {
@@ -70,7 +82,13 @@ export function ChatListColumn() {
return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0);
});
const trimmedQuery = query.trim();
const filtered = trimmedQuery ? sorted.filter((c) => (c.public?.fn || c.topic || '').toLowerCase().includes(trimmedQuery.toLowerCase())) : sorted;
const filtered = trimmedQuery
? sorted.filter((c) =>
(c.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : c.public?.fn || c.topic || '')
.toLowerCase()
.includes(trimmedQuery.toLowerCase()),
)
: sorted;
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
return (
@@ -127,12 +145,20 @@ export function ChatListColumn() {
</div>
) : (
filtered.map((contact) => {
const name = contact.public?.fn || contact.topic;
const name = contact.topic === 'slf' ? meQuery.data?.profile.nickname || '我' : contact.public?.fn || contact.topic;
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
const active = topicId === contact.topic;
const isGroup = tinode.getTopic(contact.topic).isGroupType();
// `GET /groups` (this file's own groupsQuery) can return `member_count: 0` for a group
// the single-group `GET /groups/{topic}` correctly reports as several members -- the same
// dual-counter split already tracked as 问题8 in the backend issue list, newly confirmed
// live to surface as a full zero rather than a smaller mismatch. No real group has zero
// members (the owner alone is one), so the `> 0` check below treats an exact zero as
// "don't trust this" and hides the pill instead of showing a number already proven wrong.
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
const muted = !!contact.private?.mute_notifications;
const avatarFileId =
contact.topic === 'slf' ? meQuery.data?.profile.avatar_file_id : isGroup ? avatarByGroupTopic.get(contact.topic) : avatarByUserId.get(contact.topic);
return (
<Link
key={contact.topic}
@@ -141,10 +167,8 @@ export function ChatListColumn() {
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
}`}
>
<span
className={`relative grid h-11 w-11 place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(contact.topic || '')}`}
>
{avatarLetter(name)}
<span className="relative block h-11 w-11 flex-none">
<Avatar id={contact.topic || ''} name={name} avatarFileId={avatarFileId} className="h-11 w-11 rounded-lg text-base" />
{contact.online ? (
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-online" />
) : null}
@@ -154,8 +178,8 @@ export function ChatListColumn() {
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
{/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right
after the name (e.g. "项目协作群 28人"); P2P rows never have one. */}
{typeof memberCount === 'number' ? (
<span className="flex-none rounded-full bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-3">{memberCount}人</span>
{typeof memberCount === 'number' && memberCount > 0 ? (
<span className="flex-none rounded bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-4">{memberCount}人</span>
) : null}
</span>
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
+368 -42
View File
@@ -1,10 +1,11 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useOutletContext, useParams } from 'react-router-dom';
import { Link, useNavigate, useOutletContext, useParams } from 'react-router-dom';
import type { StoredMessage } from 'tinode-sdk';
import { Drafty } from 'tinode-sdk';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { EmojiPanel } from '@/features/messages/EmojiPanel';
@@ -16,9 +17,10 @@ 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, type MediaType, recallMessage } from '@/lib/api/messages';
import { getAllGroupMembers, getGroup, getGroupGovernance, getGroupMutes, getGroupSummary } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { favoriteMessages, 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';
@@ -26,10 +28,22 @@ import { isRecalled } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
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 { closeConversation, ensureTopicAttached, openConversation } from '@/lib/tinode/topics';
import {
closeConversation,
ensureTopicAttached,
forgetTopic,
getP2PPeerUserId,
markConfirmedRead,
openConversation,
setActiveConversationTopic,
subscribeTopicGone,
} from '@/lib/tinode/topics';
import { useCallStore } from '@/stores/calls';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify, notImplemented } from '@/stores/globalToast';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
@@ -50,12 +64,9 @@ interface ContextMenuState {
msg: StoredMessage;
}
function notImplemented() {
window.alert('该功能暂未开放,如需联系客服。');
}
export function ConversationView() {
const { topicId } = useParams();
const navigate = useNavigate();
const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>();
const myUserId = useSessionStore((s) => s.myUserId);
const tinode = useTinode();
@@ -72,6 +83,80 @@ export function ConversationView() {
const setDraft = (value: string) => {
if (topicId) setDraftByTopic((prev) => ({ ...prev, [topicId]: value }));
};
// Server-side draft persistence (Zentao #19: draft cleared on conversation switch/page refresh --
// draftByTopic above only survives WITHIN this one mounted session, never a refresh or a different
// device/tab). `getTopicDraft`/`putTopicDraft`'s own comment covers the real, live `PUT /v1/im/
// topics/{topic}/draft` endpoint this hits (the protocol doc marks it "待实现", which is stale).
//
// draftVersionRef, not state -- purely an implementation detail this optimistic-lock write needs to
// send back, never rendered, so it doesn't need to trigger a re-render when it changes.
const draftVersionRef = useRef<Record<string, number>>({});
// The content this app most recently confirmed IS what the server has (from either a completed load
// or a completed save) -- lets the debounce-save effect below tell "the user actually typed
// something new" apart from "draft just changed because the load effect populated it with what the
// server already had," which would otherwise queue an entirely redundant save of identical content
// the moment every conversation with an existing draft is opened.
const lastSyncedContentRef = useRef<Record<string, string>>({});
// Tracks which topics' server draft has already been fetched THIS session -- fetching again every
// time the user merely glances back at an already-open conversation would risk clobbering local text
// already being typed with whatever stale snapshot the server still has from before this session.
const draftLoadedRef = useRef<Set<string>>(new Set());
useEffect(() => {
if (!topicId || draftLoadedRef.current.has(topicId)) return;
draftLoadedRef.current.add(topicId);
const forTopic = topicId;
getTopicDraft(forTopic)
.then((d) => {
draftVersionRef.current[forTopic] = d.version;
lastSyncedContentRef.current[forTopic] = d.content;
if (!d.content) return;
// Local (this-session) text always wins over the server snapshot -- the whole point of
// fetching is recovering a draft AFTER a refresh/new session, not overwriting an edit already
// in progress if this same topic was somehow already typed into before this fetch resolved.
setDraftByTopic((prev) => (prev[forTopic] ? prev : { ...prev, [forTopic]: d.content }));
})
.catch(() => {});
}, [topicId]);
// Debounced (1.5s after the user stops typing) rather than on every keystroke -- draft content
// isn't urgent enough to warrant a network write per character. `pendingDraftRef` (not just the
// timer) is what the flush effect below actually sends, kept up to date on every keystroke
// regardless of whether ITS OWN cleanup below beat the timer to canceling it.
const pendingDraftRef = useRef<{ topic: string; content: string; timer: ReturnType<typeof setTimeout> } | null>(null);
const flushDraftSave = useCallback(() => {
const pending = pendingDraftRef.current;
if (!pending) return;
clearTimeout(pending.timer);
pendingDraftRef.current = null;
const version = draftVersionRef.current[pending.topic] ?? 0;
putTopicDraft(pending.topic, pending.content, version)
.then((d) => {
draftVersionRef.current[pending.topic] = d.version;
lastSyncedContentRef.current[pending.topic] = pending.content;
})
.catch(() => {
// A stale optimistic-lock version (edited on another device/tab in between) or a transient
// network failure -- a personal, single-user, ephemeral draft doesn't warrant a refetch-and-
// retry dance over losing the very latest keystrokes here; the next successful save (the next
// debounce tick, or the next time this conversation is opened) catches back up regardless.
});
}, []);
useEffect(() => {
if (!topicId || !draftLoadedRef.current.has(topicId)) return undefined;
if (draft === lastSyncedContentRef.current[topicId]) return undefined; // nothing actually changed
if (pendingDraftRef.current) clearTimeout(pendingDraftRef.current.timer);
const timer = setTimeout(flushDraftSave, 1500);
pendingDraftRef.current = { topic: topicId, content: draft, timer };
return () => clearTimeout(timer);
}, [topicId, draft, flushDraftSave]);
// Separate from the effect above on purpose: THIS one's cleanup fires flushDraftSave() itself (not
// just clearTimeout) so switching to a different conversation -- or closing/navigating away
// entirely -- saves whatever's pending immediately, instead of abandoning it to a 1.5s timer that
// may never get the chance to fire once its own component instance's timers are gone.
useEffect(() => {
return () => flushDraftSave();
}, [topicId, flushDraftSave]);
// Same per-topic keying as draftByTopic, and for the same reason -- a mention picked while
// composing in group A must not still be "pending" if the user switches to group B and back
// before sending (buildMentionedContent would then splice a real MN entity for a name that's
@@ -131,12 +216,103 @@ export function ConversationView() {
useEffect(() => {
if (!topicId) return undefined;
openConversation(topicId).catch(() => {});
// Unlike every other action in this component (see showError's own comment above), a failed
// subscribe here used to just vanish into an empty `.catch(() => {})` -- the user would land on
// a totally blank message list with no error, no retry, and (since the noteRead effect below
// only fires once `messages.length > 0`) a read receipt that can never be sent, leaving the chat
// list's unread badge permanently stuck for a conversation that in fact never loaded at all.
openConversation(topicId)
.then((topic) => {
// A P2P conversation reached via the OTHER user's `usr...` id (the only address that exists
// before a real conversation has ever happened -- e.g. straight from a friend's profile,
// never yet in the chat list) gets handed back a DIFFERENT real topic name by the server
// (its actual `p2p...` hash) the moment the subscribe succeeds -- confirmed live: the SDK's
// own Topic#subscribe (tinode.dev.js) renames+re-keys its local cache entry from `usr...` to
// that real name whenever the topic was `_new` (true for every freshly-constructed Topic
// object, which this always is on first open). Every OTHER piece of this component --
// submit()'s send, the header's presence lookup, MessageBubble's retry -- calls
// `tinode.getTopic(topicId)` fresh off the URL param on its own, completely independently of
// whatever object `openConversation` itself subscribed. Left alone, that second `getTopic`
// call is a cache MISS against the now-renamed key, silently manufacturing a brand new,
// never-subscribed, empty stand-in object -- every send/read/presence check from then on
// operates on that empty stand-in, not the real, subscribed, actually-receiving-data one.
// Real messages keep arriving successfully (confirmed via a raw-protocol repro: the {pub}
// lands and a fresh {sub}'s history dump shows it) but never reach this component's topic
// object, so the chat window shows nothing and looks like sending silently failed. Replacing
// the URL to the real name the instant it's known makes every later `getTopic(topicId)` in
// this component resolve the SAME object subscribe() just populated, not a fresh impostor.
if (topic.name !== topicId) navigate(`/messages/${topic.name}`, { replace: true });
// Real, live-confirmed gap on top of the rename issue above: for exactly this brand-new-P2P-
// via-alias case, `openConversation`'s own `withLaterDesc()` (an incremental "only if changed
// since I last knew" fetch) can come back with the subscribe successful but NO desc AND no
// presence at all -- `topic.public` stays permanently `null` and `topic.online` stays falsy
// (confirmed live: still true 10+ seconds later, no amount of waiting fixes either one),
// which is why the header/chat-list fall back to the raw topic id and "离线" forever on THIS
// specific path (reached live via a friend's profile "发消息" button, never through a fresh
// page load -- a full reload's own `subscribeMeTopic()` happens to paper over this by
// fetching every contact's full subscription state unconditionally, which is exactly why this
// was never caught by any test that reloaded the page as part of "opening" a conversation).
// `me.getMeta(withOneSub(topic))` is the same targeted-refresh call the SDK's own presence
// routing (`_routePres`, tinode.dev.js) uses for "just learned about this topic" -- unlike a
// plain desc fetch on the topic itself, this one comes back with BOTH desc AND online status
// in the same `sub` entry (confirmed live capturing the raw frame during an earlier
// investigation), and `TopicMe`'s own `_processMetaSubs` merges both onto this exact topic
// object before notifying -- wireTopicCallbacks already wired `me`'s `onMetaSub`/
// `onSubsUpdated` at login, so the response correctly invalidates the chat list once it lands.
if (!topic.public) {
const me = tinode.getMeTopic();
me.getMeta(me.startMetaQuery().withOneSub(undefined, topic.name).build()).catch(() => {});
}
})
.catch((err) => {
// A group dissolved before this specific page load ever subscribed to it -- distinct from
// subscribeTopicGone below, which only covers watching it get dissolved live while already
// subscribed. See isTopicGone's own comment for the real, reported bug this closes (a
// deep-link/refresh into an already-dissolved group left the screen sitting on a raw-topic-id,
// "1970/1/1", "0 members" stub instead of leaving). Same user-facing outcome as the live case
// (toast + bounce to the chat list) for consistency, not a special "this group doesn't exist"
// message -- from this user's vantage point both cases are just "this group is gone now".
if (isTopicGone(err)) {
// The failed subscribe attempt above already left a bare, empty stub for this topic in the
// SDK's cache (getTopic() creates one just by being called, before it knows whether the
// subscribe will succeed) -- without this, that stub alone is enough to make the chat list
// keep showing this now-gone group as a broken-looking row even after navigating away.
forgetTopic(topicId);
notify('该群聊已解散');
navigate('/messages', { replace: true });
return;
}
showError(err, '加载会话失败,请重试');
});
return () => {
closeConversation(topicId).catch(() => {});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topicId]);
// Switching into a chat window should let the user start typing immediately, not require an extra
// click into the input first -- matches every mainstream IM client. Deliberately NOT keyed on
// `messages` (would re-steal focus away from whatever the user is doing every time a new message
// arrives while they're already composing) -- only the initial switch itself should grab focus.
useEffect(() => {
if (!topicId) return;
textareaRef.current?.focus();
}, [topicId]);
// Someone else dissolving the group currently open here (or this account being removed from it)
// fires the SDK's `onDeleteTopic`, not `onData`/`onPres` -- those only invalidate this screen's
// cached snapshots for re-render, they don't tell it the topic itself is gone. Left unhandled, the
// member who happened to be looking at that conversation just keeps staring at a now-defunct screen
// (an actual reported bug: staying on the dissolved group's view with no way back except a manual
// nav). `notify()` (the global toast, not this screen's own `errorToast`) survives the navigate.
useEffect(() => {
return subscribeTopicGone((goneTopicName) => {
if (goneTopicName !== topicId) return;
notify('该群聊已解散');
navigate('/messages', { replace: true });
});
}, [topicId, navigate]);
// useLayoutEffect, not useEffect -- this component never remounts on topicId change (route is
// `:topicId`), so a plain useEffect (which fires after paint) can let one frame render with the
// PREVIOUS conversation's select-mode/menu/etc. still showing over the new conversation's message
@@ -179,6 +355,16 @@ export function ConversationView() {
setHighlightedSeq(null);
}, [topicId]);
// Tells lib/notifications/push.ts "the user is already looking at this one" so a message arriving
// here doesn't also pop a desktop notification for something already on screen -- cleared on
// switch/unmount so leaving this screen (closing the tab, navigating to /contacts, etc.) doesn't
// leave some OTHER, no-longer-open conversation permanently exempt from notifications.
useEffect(() => {
if (!topicId) return undefined;
setActiveConversationTopic(topicId);
return () => setActiveConversationTopic(null);
}, [topicId]);
useEffect(() => {
if (autoScrollRef.current) {
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
@@ -224,15 +410,100 @@ export function ConversationView() {
window.setTimeout(() => setHighlightedSeq((cur) => (cur === seq ? null : cur)), 2000);
}, [messages, jumpTarget]);
// Gated on the tab actually being visible, not just this conversation being mounted -- a
// conversation left open in a BACKGROUNDED tab used to have every arriving message immediately
// noteRead()'d purely because the route was still mounted, with nobody actually looking at it. That
// made the chat-list unread count (and, since today, the tab-title scroll/favicon badge/desktop
// notification it drives -- see lib/notifications/) silently stay at zero for a conversation that
// was, from the user's real vantage point, sitting unread in a background tab the whole time (a
// real, reported case: systest01 has systest02's conversation open, tabs away, systest02 sends a
// message -- the tab title never picked it up). The effect below catches back up the moment the tab
// becomes visible again while still on this same conversation, so nothing is left permanently
// unread just because this guard skipped it while hidden.
//
// markConfirmedRead alongside noteRead -- this app's own independent "genuinely seen" record (see
// its own comment in topics.ts) that topics.ts's me.onPres correction relies on to undo the SDK's
// separate, unwanted auto-read-mark on a `{pres what:"msg"}` push for a topic that ISN'T actually
// being looked at. noteRead() alone only fixes what THIS component itself would have marked read;
// it doesn't tell that other mechanism what the user has genuinely seen.
useEffect(() => {
if (!topicId || messages.length === 0) return;
if (document.visibilityState !== 'visible') return;
const topic = tinode.getTopic(topicId);
topic.noteRead(0);
markConfirmedRead(topicId, messages[messages.length - 1]?.seq ?? 0);
}, [topicId, messages, tinode]);
useEffect(() => {
if (!topicId) return undefined;
const onVisible = () => {
if (document.visibilityState !== 'visible') return;
tinode.getTopic(topicId).noteRead(0);
}, [topicId, messages.length, tinode]);
markConfirmedRead(topicId, messages[messages.length - 1]?.seq ?? 0);
};
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', onVisible);
return () => {
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', onVisible);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [topicId, tinode]);
// Computed independently of `topic` below (not `topic.isGroupType()`) so this and the group-data
// queries it gates can sit before the `if (!topicId)` early return, alongside every other hook --
// `tinode.getTopic()` is a cheap cache lookup-or-create either way, calling it twice is harmless.
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
// `slf` (chatting with yourself, née "文件助手" -- see ChatListColumn.tsx's PINNED_ENTRIES
// comment on why that framing was deliberately dropped) isn't in your own contacts/friends list,
// so `contact` below is always undefined for it -- shares AppShell's/SettingsLayout's `['me']`
// query so this doesn't add a request beyond whichever of those mounts first.
const isSelf = topicId === 'slf';
// No longer gated to `enabled: isSelf` -- every conversation (not just chatting with yourself)
// needs your OWN avatar_file_id now, for the "mine" message-row avatar below. Shares the same
// `['me']` key NavRail/ChatListColumn/SettingsLayout already keep warm, so this is a cache read in
// the normal case, not a new request.
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
// NOT topicId -- see getP2PPeerUserId's own doc comment. A P2P topic with any real history is
// addressed by a real, opaque `p2p...` name, not the peer's `usr...` id (that only holds true in
// the brief window before the very first subscribe) -- confirmed live: without this, this query
// 404s (`GET /users/{p2p...}/profile`) for every established conversation, and only ever appeared
// to work in earlier testing because that test happened to use a conversation opened for the first
// time in the same session, before the SDK's own rename kicked in.
const peerUserId = !isGroup && !isSelf && topicId ? getP2PPeerUserId(tinode.getTopic(topicId), myUserId) : undefined;
// Real gap this was written to close: the chat header/message-row avatars only ever drew the
// letter-tile fallback, even after a real custom avatar was uploaded and correctly showing up on
// the contacts pages (FriendProfileView/ContactsListColumn) -- those read this exact same REST
// profile, the conversation screen just never fetched it. Same query DetailColumn already runs
// (`['user-profile', peerUserId]`), so this dedupes with it whenever the detail panel happens to be
// open, and only fetches for a real P2P peer, never for a group or `slf`.
const peerProfileQuery = useQuery({
queryKey: ['user-profile', peerUserId],
queryFn: () => getUserProfile(peerUserId || ''),
enabled: !!peerUserId,
});
// Live-confirmed real bug this closes (Zentao #28): sending into a P2P conversation blocked in
// EITHER direction showed only the generic per-bubble "发送失败,点击重试", no reason, on both sides
// -- reading tinode-sdk source confirmed the specific rejection reason can never be recovered
// after a failed send (Topic#publishMessage's own .catch swallows it into a bare `_failed` flag and
// never rethrows), so the only real fix is checking BEFORE attempting to send, not reacting after.
// This endpoint is P2P-only and reports the relationship from either side (`blocked_by_me` on the
// blocker's own view, `blocked_by_peer` on the blocked side) -- see getConversationContext's own
// doc comment for why `getUserProfile`'s similar-looking `capabilities.can_send` can't be reused for
// this (403s entirely from the blocked party's own perspective instead of reporting false).
const conversationContextQuery = useQuery({
queryKey: ['conversation-context', topicId],
queryFn: () => getConversationContext(topicId || ''),
enabled: !!topicId && !isGroup && !isSelf,
});
const blockedNotice =
conversationContextQuery.data?.relationship === 'blocked_by_me'
? '你已将对方加入黑名单,无法发送消息'
: conversationContextQuery.data?.relationship === 'blocked_by_peer'
? '对方已将你加入黑名单,暂时无法发送消息'
: conversationContextQuery.data?.can_send === false
? '暂时无法向对方发送消息'
: null;
const membersQuery = useQuery({
queryKey: ['group-members', topicId],
queryFn: () => getAllGroupMembers(topicId || ''),
@@ -253,9 +524,20 @@ export function ConversationView() {
queryFn: () => getGroupGovernance(topicId || ''),
enabled: isGroup,
});
// Real, live-confirmed crash (not theoretical): this used to call `getGroupMutes` (single page,
// `{items, next_cursor}` shape) under query key `['group-mutes', topicId]` -- the EXACT SAME key
// `GroupAdminModal.tsx` uses for `getAllGroupMutes` (all pages, flat array). Both mounted together
// (opening 群管理 from a group's own conversation screen, which is the only way to reach it) means
// whichever query resolves last overwrites the shared cache entry with ITS shape; the other
// consumer then reads `array.items.length` -- `.items` is undefined on a plain array, and unlike
// `mutesQuery.data?.items.length`'s leading `?.`, nothing guards THAT specific property access, so
// it throws and takes down the whole conversation screen. Matching `getAllGroupMutes` here (same
// function, same shape, same key) turns this from two competing queries into one correctly-shared
// one -- also fixes this subline's own under-count for any group with more muted members than a
// single page holds, the same truncation bug `getAllGroupMutes`'s own doc comment already flags.
const mutesQuery = useQuery({
queryKey: ['group-mutes', topicId],
queryFn: () => getGroupMutes(topicId || ''),
queryFn: () => getAllGroupMutes(topicId || ''),
enabled: isGroup,
});
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
@@ -263,13 +545,21 @@ export function ConversationView() {
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
const groupSublineParts = [
governanceQuery.data?.join_approval ? '已开启进群验证' : null,
mutesQuery.data?.items.length ? `${mutesQuery.data.items.length} 位成员被禁言` : null,
mutesQuery.data?.length ? `${mutesQuery.data.length} 位成员被禁言` : null,
].filter(Boolean);
if (!topicId) return null;
const topic = tinode.getTopic(topicId);
const peerName = contact?.public?.fn || topicId;
const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contact?.public?.fn || topicId;
const groupMemberCount = groupProfileQuery.data?.member_count ?? membersQuery.data?.length ?? 0;
const myAvatarFileId = meQuery.data?.profile.avatar_file_id;
const peerAvatarFileId = isSelf ? myAvatarFileId : peerProfileQuery.data?.avatar_file_id;
const headerAvatarFileId = isGroup ? groupProfileQuery.data?.avatar_file_id : peerAvatarFileId;
// Color-hash id, not the file lookup above -- topicId is right for a group (its own identity), but
// for P2P this should be the peer's stable usr id (peerUserId), matching the same hash ContactsListColumn/
// FriendProfileView use for this same person, rather than the p2p topic name (which isP2P/self also
// never had a usr-shaped id for in the first place).
const headerAvatarId = isGroup ? topicId : peerUserId || topicId;
// `peerName` is the GROUP's own name for a group topic, not any member's -- reusing it to label
// "who is being quoted" (as both the reply-composer preview and the actual sent quote block used
// to) meant every single group-chat reply permanently displayed "回复 <群名>:…" for every member,
@@ -331,17 +621,28 @@ export function ConversationView() {
if (!text) return;
setDraft('');
setMentionTrigger(null);
if (replyTo) {
// 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.
sendReply(topic, text, replyTo, quotedSenderLabel(replyTo.from)).catch((err) => showError(err, '发送失败'));
const pendingReplyTo = replyTo;
setReplyTo(null);
} else if (pendingMentions.length > 0) {
sendPrebuiltContent(topic, buildMentionedContent(text, pendingMentions)).catch((err) => showError(err, '发送失败'));
} else {
sendText(topic, text).catch((err) => showError(err, '发送失败'));
// The composer's own topic can be detached by the time this actually fires -- e.g. a
// disconnect/reconnect cycle resets every topic's attached state (see connection.ts's
// onReauthenticated), and publishing on a detached topic is rejected outright by the SDK. The
// forward flow above already guards against exactly this (see ensureTopicAttached's own
// comment for why it's the common case, not an edge case); the primary composer -- used far
// more often than forwarding -- had no equivalent guard at all.
ensureTopicAttached(topicId)
.then((target) => {
if (pendingReplyTo) {
// 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));
}
if (pendingMentions.length > 0) {
return sendPrebuiltContent(target, buildMentionedContent(text, pendingMentions));
}
return sendText(target, text);
})
.catch((err) => showError(err, '发送失败'));
if (topicId) setMentionsByTopic((prev) => ({ ...prev, [topicId]: [] }));
};
@@ -361,14 +662,18 @@ export function ConversationView() {
setMentionTrigger(null);
setEmojiOpen(false);
// Matches insertMentionTrigger's own restore below -- without an explicit setSelectionRange
// (deferred a frame so the textarea's DOM value has actually updated to `inserted + after`
// first), the cursor doesn't reliably land right after the just-inserted "@Name ", making it
// awkward to keep typing; a bare focus() alone doesn't move the caret to a specific offset.
// (deferred so the textarea's DOM value has actually updated to `inserted + after` first), the
// cursor doesn't reliably land right after the just-inserted "@Name ", making it awkward to keep
// typing; a bare focus() alone doesn't move the caret to a specific offset. Deferred via
// setTimeout(0), not requestAnimationFrame -- rAF callbacks are paused outright while the tab is
// backgrounded/hidden (confirmed live: an rAF scheduled right after a click never fired at all
// in a hidden tab, a setTimeout(0) scheduled the same way fired normally), and this restore needs
// to run regardless of whether the tab happens to be foregrounded at that exact moment.
const el = textareaRef.current;
requestAnimationFrame(() => {
setTimeout(() => {
el?.focus();
el?.setSelectionRange(inserted.length, inserted.length);
});
}, 0);
};
/** README §4.3's composer "@" toolbar icon -- same effect as typing '@' by hand, for groups. */
@@ -381,10 +686,10 @@ export function ConversationView() {
setDraft(next);
setMentionTrigger({ start: pos + insertion.length - 1, query: '' });
setEmojiOpen(false);
requestAnimationFrame(() => {
setTimeout(() => {
el?.focus();
el?.setSelectionRange(pos + insertion.length, pos + insertion.length);
});
}, 0);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -439,9 +744,9 @@ export function ConversationView() {
setSelectedSeqs(new Set());
};
const bulkDelete = () => {
const bulkDelete = async () => {
if (selectedSeqs.size === 0) return;
if (!window.confirm(`删除已选择的 ${selectedSeqs.size} 条消息?`)) return;
if (!(await requestConfirm({ message: `删除已选择的 ${selectedSeqs.size} 条消息?`, danger: true }))) return;
topic.delMessagesList([...selectedSeqs], false);
exitSelectMode();
};
@@ -554,11 +859,7 @@ export function ConversationView() {
<Icon name="arrow_back_ios_new" />
</Link>
<button type="button" onClick={onToggleDetail} className="flex items-center gap-2.5 text-left">
<span
className={`grid h-9.5 w-9.5 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topicId)}`}
>
{avatarLetter(peerName)}
</span>
<Avatar id={headerAvatarId} name={peerName} avatarFileId={headerAvatarFileId} className="h-9.5 w-9.5 flex-none rounded-lg text-sm" />
<div className="flex flex-col">
<strong className="text-base font-semibold text-text-heading">
{peerName}
@@ -566,7 +867,7 @@ export function ConversationView() {
</strong>
{isGroup ? (
groupSublineParts.length ? <span className="text-xs text-text-tertiary-2">{groupSublineParts.join(' · ')}</span> : null
) : (
) : isSelf ? null : (
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
{contact?.online ? '在线' : '离线'}
</span>
@@ -575,7 +876,7 @@ export function ConversationView() {
</button>
</div>
<div className="flex items-center gap-1">
{isGroup ? null : (
{isGroup || isSelf ? null : (
<>
<button
type="button"
@@ -642,16 +943,19 @@ 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 mine = !!myUserId && msg.from === myUserId;
const rowAvatarFileId = mine ? myAvatarFileId : isGroup ? sender?.avatar_file_id : peerAvatarFileId;
return (
<MessageRow
key={msg.seq}
topic={topic}
msg={msg}
mine={!!myUserId && msg.from === myUserId}
mine={mine}
isGroup={isGroup}
peerName={peerName}
senderName={sender?.display_name}
senderRole={sender?.role}
avatarFileId={rowAvatarFileId}
selectMode={selectMode}
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
@@ -691,6 +995,10 @@ export function ConversationView() {
</div>
) : (
<div ref={mentionContainerRef} className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
{blockedNotice ? (
<div className="flex h-18 items-center justify-center text-sm text-text-tertiary-3">{blockedNotice}</div>
) : (
<>
{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">
@@ -717,8 +1025,24 @@ export function ConversationView() {
<div className="absolute bottom-full left-0 z-20 mb-2">
<EmojiPanel
onPick={(emoji) => {
setDraft(draft + emoji);
// Same restore-focus-and-caret dance as insertMentionTrigger/pickMention above
// -- clicking the emoji button moves DOM focus to it like any button click,
// and without this the textarea stays unfocused until the user clicks back
// into it (confirmed by a user report: Enter didn't send right after picking
// an emoji because the textarea itself had lost focus). Inserts at the actual
// caret position rather than always appending to the end, matching how a
// mention insertion already behaves, so the two don't disagree about where
// "here" is. setTimeout(0), not requestAnimationFrame -- see insertMentionTrigger's
// own comment for why (rAF never fires at all while the tab is hidden).
const el = textareaRef.current;
const pos = el?.selectionStart ?? draft.length;
const next = draft.slice(0, pos) + emoji + draft.slice(pos);
setDraft(next);
setEmojiOpen(false);
setTimeout(() => {
el?.focus();
el?.setSelectionRange(pos + emoji.length, pos + emoji.length);
}, 0);
}}
/>
</div>
@@ -803,6 +1127,8 @@ export function ConversationView() {
</div>
</>
)}
</>
)}
</div>
)}
+38 -15
View File
@@ -3,6 +3,7 @@ import { useLayoutEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
@@ -18,9 +19,13 @@ import {
updateFriendRemark,
} from '@/lib/api/friends';
import type { MediaType } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { getMe } from '@/lib/api/profile';
import { useTinode } from '@/lib/tinode/hooks';
import { getP2PPeerUserId } from '@/lib/tinode/topics';
import { useCallStore } from '@/stores/calls';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
import { useSessionStore } from '@/stores/session';
interface DetailColumnProps {
topicId: string;
@@ -78,14 +83,34 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
// error, confirmed live: this used to render "加入黑名单"/"投诉举报"/"删除好友" for your own notes
// to yourself.
const isSelf = topicId === 'slf';
const myUserId = useSessionStore((s) => s.myUserId);
// NOT topicId -- see getP2PPeerUserId's own doc comment. Every lookup below that means "the other
// user" (profile, friends-list match, block, remark) has to go through this, not the raw topic id,
// because the two stop being interchangeable the instant this conversation has any real history.
const peerUserId = isSelf ? topicId : getP2PPeerUserId(tinode.getTopic(topicId), myUserId);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
const friend = friendsQuery.data?.find((f) => f.user_id === topicId);
const friend = friendsQuery.data?.find((f) => f.user_id === peerUserId);
const profileQuery = useQuery({ queryKey: ['user-profile', topicId], queryFn: () => getUserProfile(topicId), enabled: !isSelf });
const profileQuery = useQuery({
queryKey: ['user-profile', peerUserId],
queryFn: () => getUserProfile(peerUserId || ''),
// `!isSelf` alone isn't enough now that peerUserId can genuinely be undefined for a beat (the
// subscriber list not loaded yet) -- without also gating on it, this fires with `undefined` the
// instant isSelf is false, same class of guaranteed-404 bug this whole fix targets, just with
// the literal string "undefined" as the id instead of the p2p topic name.
enabled: !isSelf && !!peerUserId,
});
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
.filter(Boolean)
.join(' · ');
// Same real gap as ChatListColumn/ConversationView's avatars -- this panel's header tile only ever
// drew the letter fallback, even though `profileQuery`/`friendsQuery` right above already carry a
// real `avatar_file_id` (that's how ContactsListColumn/FriendProfileView show it correctly). `friend`
// (the `/friends` list, same source the contacts pages already trust) takes priority since it's the
// one confirmed accurate; `profileQuery` covers a P2P peer who isn't (or is no longer) a friend.
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf });
const avatarFileId = isSelf ? meQuery.data?.profile.avatar_file_id : (friend?.avatar_file_id ?? profileQuery.data?.avatar_file_id);
const settingsQuery = useQuery({
queryKey: ['conversation-settings', topicId],
@@ -100,7 +125,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
});
const remarkMutation = useMutation({
mutationFn: (remark: string) => updateFriendRemark(topicId, remark),
mutationFn: (remark: string) => updateFriendRemark(peerUserId, remark),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['friends'] });
setRemarkEditing(false);
@@ -109,16 +134,16 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
});
const blockMutation = useMutation({
mutationFn: (blocked: boolean) => (blocked ? blockUser(topicId) : unblockUser(topicId)),
mutationFn: (blocked: boolean) => (blocked ? blockUser(peerUserId) : unblockUser(peerUserId)),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
onError: (err) => showError(err, '设置失败,请重试'),
});
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === topicId);
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === peerUserId);
const deleteFriendMutation = useMutation({
mutationFn: () => deleteFriend(topicId),
mutationFn: () => deleteFriend(peerUserId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['friends'] });
navigate('/messages');
@@ -126,18 +151,18 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
onError: (err) => showError(err, '删除好友失败'),
});
const clearHistory = () => {
if (!window.confirm('确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。')) return;
const clearHistory = async () => {
if (!(await requestConfirm({ message: '确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。', danger: true }))) return;
tinode.getTopic(topicId).delMessagesAll(false);
};
const startDeleteFriend = () => {
if (!window.confirm(`确定删除好友 ${peerName} 吗?`)) return;
const startDeleteFriend = async () => {
if (!(await requestConfirm({ message: `确定删除好友 ${peerName} 吗?`, danger: true }))) return;
deleteFriendMutation.mutate();
};
const reportAbuse = () => {
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
notify('该功能暂未开放,如需投诉举报请联系客服。');
};
return (
@@ -152,9 +177,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
</header>
<div className="flex flex-col items-center gap-1 border-b border-border-list-item px-5 pb-4 pt-6">
<span className={`grid h-18 w-18 place-items-center rounded-2xl text-2xl font-medium text-white ${avatarBgClass(topicId)}`}>
{avatarLetter(peerName)}
</span>
<Avatar id={peerUserId || topicId} name={peerName} avatarFileId={avatarFileId} className="h-18 w-18 rounded-2xl text-2xl" />
<strong className="mt-2 text-[17px] font-semibold text-text-heading">{peerName}</strong>
{idLine ? <span className="text-xs text-text-tertiary-2">{idLine}</span> : null}
{profileQuery.data?.signature ? (
@@ -3,6 +3,7 @@ import { useMemo, useState } from 'react';
import type { Contact } from 'tinode-sdk';
import { Icon } from '@/app/Icon';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
@@ -30,6 +31,9 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [comment, setComment] = useState('');
// Both start expanded -- collapsing is for a long list getting in the way of the OTHER section,
// not a default state anyone would want on first open.
const [collapsed, setCollapsed] = useState<{ friends: boolean; groups: boolean }>({ friends: false, groups: false });
useEscapeKey(onClose);
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
@@ -75,6 +79,10 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
});
};
const toggleCollapsed = (section: 'friends' | 'groups') => {
setCollapsed((prev) => ({ ...prev, [section]: !prev[section] }));
};
const send = () => {
if (selected.size === 0) return;
onPick([...selected], comment.trim());
@@ -95,15 +103,23 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
{avatarLetter(c.public?.fn)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
<span
className={`grid h-5 w-5 flex-none place-items-center rounded-full border ${isSelected ? 'border-accent bg-accent' : 'border-[#d5d9df]'}`}
>
{isSelected ? <Icon name="check" className="text-[14px] text-white" /> : null}
</span>
<SelectionCheckbox selected={isSelected} />
</button>
);
};
const renderSectionHeader = (label: string, section: 'friends' | 'groups', topics: string[], allSelected: boolean) => (
<div className="flex items-center justify-between px-3 pb-1 pt-2">
<button type="button" onClick={() => toggleCollapsed(section)} className="flex items-center gap-0.5 text-xs text-text-tertiary-2">
<Icon name="chevron_right" className={`text-sm transition-transform ${collapsed[section] ? '' : 'rotate-90'}`} />
{label}
</button>
<button type="button" onClick={() => toggleSectionSelectAll(topics, allSelected)} className="text-xs text-accent">
{allSelected ? '取消全选' : '全选'}
</button>
</div>
);
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()}>
@@ -130,32 +146,14 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
<div className="flex-1 overflow-y-auto px-2 py-2">
{friends.length ? (
<>
<div className="flex items-center justify-between px-3 pb-1 pt-2">
<span className="text-xs text-text-tertiary-2">好友</span>
<button
type="button"
onClick={() => toggleSectionSelectAll(friendTopics, allFriendsSelected)}
className="text-xs text-accent"
>
{allFriendsSelected ? '取消全选' : '全选'}
</button>
</div>
{friends.map(renderRow)}
{renderSectionHeader('好友', 'friends', friendTopics, allFriendsSelected)}
{collapsed.friends ? null : friends.map(renderRow)}
</>
) : null}
{groups.length ? (
<>
<div className="flex items-center justify-between px-3 pb-1 pt-2">
<span className="text-xs text-text-tertiary-2">群聊</span>
<button
type="button"
onClick={() => toggleSectionSelectAll(groupTopics, allGroupsSelected)}
className="text-xs text-accent"
>
{allGroupsSelected ? '取消全选' : '全选'}
</button>
</div>
{groups.map(renderRow)}
{renderSectionHeader('群聊', 'groups', groupTopics, allGroupsSelected)}
{collapsed.groups ? null : groups.map(renderRow)}
</>
) : null}
{!friends.length && !groups.length ? (
@@ -10,6 +10,8 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatEventTimestamp } from '@/lib/messages/time';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
@@ -90,14 +92,14 @@ function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string
try {
result = await safetyCheck.mutateAsync(item.url!);
} catch {
window.alert('链接安全检查失败,请稍后重试。');
notify('链接安全检查失败,请稍后重试。');
return;
}
if (result.decision === 'block') {
window.alert('该链接存在安全风险,已被拦截。');
notify('该链接存在安全风险,已被拦截。');
return;
}
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
window.open(item.url, '_blank', 'noopener,noreferrer');
};
+45 -19
View File
@@ -4,12 +4,13 @@ import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { SelectionCheckbox } from '@/app/SelectionCheckbox';
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { retryMessage } from '@/lib/tinode/send';
import { ensureTopicAttached } from '@/lib/tinode/topics';
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
@@ -47,6 +48,10 @@ interface MessageRowProps {
* for per-message attribution once there's more than one possible sender. */
senderName?: string;
senderRole?: 'owner' | 'admin' | 'member';
/** The avatar to show for whoever sent THIS row -- resolved by ConversationView per-message (its
* own `avatar_file_id`, the group member's, or the peer's, depending on `mine`/`isGroup`), not a
* single fixed prop like `peerName`, since every row in a group can have a different sender. */
avatarFileId?: string;
selectMode?: boolean;
selected?: boolean;
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
@@ -73,6 +78,7 @@ export function MessageRow({
peerName,
senderName,
senderRole,
avatarFileId,
selectMode,
selected,
menuActive,
@@ -106,7 +112,14 @@ export function MessageRow({
const contactCard = contactCardContentOf(msg);
const retry = () => {
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq)?.catch(() => onError?.('重试发送失败'));
if (typeof msg.seq !== 'number') return;
const seq = msg.seq;
// The original send most likely failed BECAUSE the topic was detached (see ConversationView's
// submit() for the same reasoning) -- retrying without re-attaching first would just fail the
// exact same way again.
ensureTopicAttached(topic.name)
.then(() => retryMessage(topic, seq))
.catch(() => onError?.('重试发送失败'));
};
return (
@@ -119,14 +132,8 @@ 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={`grid h-5 w-5 flex-none place-items-center rounded-full text-[13px] text-white ${
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2]'
}`}
>
{selected ? <Icon name="check" className="text-[14px]" /> : null}
<button type="button" onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)} className="flex-none">
<SelectionCheckbox selected={selected} />
</button>
)
) : null}
@@ -138,17 +145,36 @@ export function MessageRow({
}}
className={`flex flex-1 items-start gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
>
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
mine ? 'bg-accent' : avatarBgClass(msg.from || (isGroup ? '' : peerName))
}`}
>
{avatarLetter(mine ? '我' : isGroup ? senderName || msg.from : peerName)}
</span>
{/* Group members' avatar/name go to their profile (same destination ContactCardBubble's own
card already uses, which works for a non-friend group member too -- FriendProfileView's
relationship-aware buttons handle that case, it isn't friends-only). Not for `mine` -- that
slot always reads "我", and viewing "your own profile" via a route built around a two-party
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>
) : (
<Avatar
id={msg.from || (isGroup ? '' : peerName)}
name={mine ? '我' : isGroup ? senderName || msg.from : peerName}
avatarFileId={avatarFileId}
fallbackBgClass={mine ? 'bg-accent' : undefined}
className="h-9 w-9 flex-none rounded-lg text-sm"
/>
)}
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
{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>
) : (
<span>{mine ? '我' : senderName || msg.from}</span>
)}
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
</div>
) : null}
@@ -158,7 +184,7 @@ export function MessageRow({
-- this icon only ever appears on `mine` bubbles and belongs on the OUTER edge, away from
the avatar, which is what the DOM order [icon, bubble] already gives without reversing it. */}
<div className="flex items-center gap-1.5">
{failed ? <Icon name="error" className="flex-none text-[20px] text-danger-text" /> : null}
{failed ? <Icon name="error" className="flex-none text-[20px] text-unread" /> : null}
<div
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
@@ -179,7 +205,7 @@ export function MessageRow({
</div>
</div>
{failed ? (
<button type="button" onClick={retry} className="text-xs text-danger-text">
<button type="button" onClick={retry} className="text-xs text-unread">
发送失败,点击重试
</button>
) : (
+10 -1
View File
@@ -1,5 +1,6 @@
import { useLayoutEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Outlet, useLocation, useParams } from 'react-router-dom';
import { ChatListColumn } from '@/features/messages/ChatListColumn';
@@ -8,6 +9,7 @@ import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
import { OfflineBanner } from '@/features/messages/OfflineBanner';
import { GroupDetailColumn } from '@/features/groups/GroupDetailColumn';
import type { MediaType } from '@/lib/api/messages';
import { getMe } from '@/lib/api/profile';
import { useMediaQuery } from '@/lib/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
@@ -86,7 +88,14 @@ export function MessagesLayout() {
const showListOnNarrow = !topicId && location.pathname !== '/messages/system';
const contacts = useChatList();
const tinode = useTinode();
const peerName = contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
// `slf` (chat with yourself) isn't in the friends/contacts list `contacts` comes from, so the
// lookup below always misses for it -- same gap ConversationView.tsx's own header already had and
// fixed the same way, just computed independently here for this panel's title/detail-column
// props. Shares AppShell's `['me']` query key, so this is a cache read once anything else has
// already mounted it, not a second request.
const isSelf = topicId === 'slf';
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe, enabled: isSelf });
const peerName = isSelf ? meQuery.data?.profile.nickname || '我' : contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
const detailVisible = isDocked ? detailPanelOpen : detailOpen;
+5 -3
View File
@@ -3,6 +3,8 @@ import type { ReactNode } from 'react';
import { useMutation } from '@tanstack/react-query';
import { checkUrlSafety } from '@/lib/api/security';
import { requestConfirm } from '@/stores/confirmDialog';
import { notify } from '@/stores/globalToast';
/**
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat content must pass a server-side risk
@@ -22,14 +24,14 @@ export function SafeLink({ href, children }: { href: string | undefined; childre
try {
result = await safetyCheck.mutateAsync(href);
} catch {
window.alert('链接安全检查失败,请稍后重试。');
notify('链接安全检查失败,请稍后重试。');
return;
}
if (result.decision === 'block') {
window.alert('该链接存在安全风险,已被拦截。');
notify('该链接存在安全风险,已被拦截。');
return;
}
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
if (result.decision === 'warn' && !(await requestConfirm({ message: '该链接可能存在风险,是否仍要打开?' }))) return;
window.open(href, '_blank', 'noopener,noreferrer');
};
+2 -2
View File
@@ -123,8 +123,8 @@ export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
return (
<div className="flex h-14 items-center justify-between gap-3 rounded-lg bg-[#fff0f0] px-4">
<span className="flex flex-none items-center gap-2 text-sm font-medium text-danger-text">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-danger-text" />
<span className="flex flex-none items-center gap-2 text-sm font-medium text-text-body">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-unread" />
录音中 {formatSeconds(seconds)}
</span>
<span className="h-4 min-w-0 flex-1 rounded bg-[repeating-linear-gradient(90deg,#f3b8b8_0px,#f3b8b8_2px,transparent_2px,transparent_5px)]" />
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Toast } from '@/app/Toast';
import { requestConfirm } from '@/stores/confirmDialog';
/**
* README §8.7 mentions a "清空缓存「126 MB」" row -- confirmed no server endpoint touches cache at
@@ -16,7 +17,7 @@ export function GeneralSettingsPage() {
const [error, setError] = useState('');
const clearCache = async () => {
if (!window.confirm('确定清除本地缓存吗?不会影响你的账号数据。')) return;
if (!(await requestConfirm({ message: '确定清除本地缓存吗?不会影响你的账号数据。' }))) return;
setStatus('clearing');
try {
if (typeof caches === 'undefined') throw new Error('此浏览器不支持缓存清除');
@@ -62,7 +62,12 @@ export function PrivacySettingsPage() {
// re-enables every field a tenant policy had actually locked, until an unrelated refetch happens
// to restore it.
onSuccess: (data) => queryClient.setQueryData(['my-privacy'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data)),
onError: (err) => setError(err instanceof Error ? err.message : '设置失败'),
// Same reasoning as ProfileSettingsPage's own saveMutation: a version conflict shouldn't leave
// the cached `version` stale, or every retry just resends the same rejected value forever.
onError: (err) => {
setError(err instanceof Error ? err.message : '设置失败');
queryClient.invalidateQueries({ queryKey: ['my-privacy'] });
},
});
const patch = (partial: Partial<Omit<Parameters<typeof updateMyPrivacy>[0], 'version'>>) => {
if (!query.data) return;
@@ -113,7 +118,7 @@ export function PrivacySettingsPage() {
</button>
<div className="rounded-xl bg-white text-sm">
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
<div className="flex items-center justify-between border-b border-border-row px-7 py-4">
<span className="text-text-body">资料可见范围</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
{VISIBILITY_OPTIONS.map((opt) => (
@@ -129,7 +134,7 @@ export function PrivacySettingsPage() {
))}
</div>
</div>
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
<div className="flex items-center justify-between border-b border-border-row px-7 py-4">
<span className="text-text-body">最近在线可见范围</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
{VISIBILITY_OPTIONS.map((opt) => (
@@ -32,9 +32,14 @@ const InlineField = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLI
/**
* README §8.2 / screens/07-profile-settings.dc.html#1a. Avatar upload reuses upload.ts's native
* `/v0/file/u/` channel, NOT `POST /v1/im/public/uploads?purpose=user_avatar` -- confirmed
* live-broken (docs/IM-后端问题清单.md 问题28: that endpoint 400s on `purpose=user_avatar`
* specifically), and the old app's own real avatar-upload code never goes through it either.
* `/v0/file/u/` channel (the one every other upload in this app already goes through), NOT
* `POST /v1/im/public/uploads` -- that's a different, pre-login-only channel for enterprise
* registration/tenant feedback (`lib/api/tenant.ts`'s `uploadPublicFile`, whose own `purpose` type
* is explicitly `'enterprise_license' | 'tenant_entry_feedback'`), never meant for an authenticated
* user's own avatar. Passes `purpose: 'user_avatar'` explicitly per docs/IM-前后端对接文档.md:2293
* -- this used to be omitted entirely (traced to `purpose=user_avatar` 400ing on THIS channel too,
* docs/IM-后端问题清单.md 问题28, since confirmed fixed: a real PNG upload with that purpose now
* returns 200).
*/
export function ProfileSettingsPage() {
const queryClient = useQueryClient();
@@ -79,15 +84,21 @@ export function ProfileSettingsPage() {
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
queryClient.invalidateQueries({ queryKey: ['me'] });
},
onError: (err) => setError(err instanceof Error ? err.message : '保存失败'),
// 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.
onError: (err) => {
setError(err instanceof Error ? err.message : '保存失败');
queryClient.invalidateQueries({ queryKey: ['my-profile'] });
},
});
const avatarMutation = useMutation({
mutationFn: async (file: File) => {
const uploaded = await uploadFile(file);
const fileId = uploaded.url.split('/').pop() || '';
const uploaded = await uploadFile(file, undefined, 'user_avatar');
if (!query.data) throw new Error('尚未加载');
return updateMyProfile({ avatar_file_id: fileId, version: query.data.version });
return updateMyProfile({ avatar_file_id: uploaded.file_id, version: query.data.version });
},
onSuccess: (data) => {
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
@@ -173,7 +184,7 @@ export function ProfileSettingsPage() {
{showQrCode ? <QRCodeModal onClose={() => setShowQrCode(false)} /> : null}
<div className="rounded-xl bg-white text-sm">
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">昵称</span>
<InlineField
value={draft.nickname}
@@ -183,7 +194,7 @@ export function ProfileSettingsPage() {
<Icon name="edit" className="text-[20px] text-icon-arrow" />
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">性别</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs" style={{ width: 'fit-content' }}>
{GENDER_OPTIONS.map((opt) => (
@@ -200,7 +211,7 @@ export function ProfileSettingsPage() {
<span />
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">出生日期</span>
<InlineField
ref={birthDateRef}
@@ -214,9 +225,9 @@ export function ProfileSettingsPage() {
</button>
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-row px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">地区</span>
<InlineField value={draft.region} onChange={(e) => setDraft({ ...draft, region: e.target.value })} placeholder="未填写" />
<InlineField value={draft.region} onChange={(e) => setDraft({ ...draft, region: e.target.value })} placeholder="未填写" maxLength={50} />
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</div>
@@ -6,6 +6,7 @@ import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { getTinode, resetTinode } from '@/lib/tinode/connection';
import { requestConfirm } from '@/stores/confirmDialog';
import { useSessionStore } from '@/stores/session';
/**
@@ -66,8 +67,8 @@ export function SecuritySettingsPage() {
const deleteAccount = async () => {
if (deletingAccount) return;
if (!window.confirm('注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?')) return;
if (!window.confirm('请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。')) return;
if (!(await requestConfirm({ message: '注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?', danger: true }))) return;
if (!(await requestConfirm({ message: '请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。', danger: true }))) return;
setDeletingAccount(true);
try {
await getTinode().delCurrentUser(true);
@@ -94,7 +95,14 @@ export function SecuritySettingsPage() {
<div className="flex flex-col gap-5">
<div className="rounded-xl bg-white text-sm">
{changingPassword ? (
<div className="flex flex-col gap-3 px-7 py-4">
<form
onSubmit={(e) => {
e.preventDefault();
if (currentPassword.length === 0 || newPassword.length < minPasswordLength || passwordsMismatch || confirmPassword.length === 0 || passwordMutation.isPending) return;
passwordMutation.mutate();
}}
className="flex flex-col gap-3 px-7 py-4"
>
<span className="text-text-body">修改密码</span>
<input
type="password"
@@ -138,8 +146,7 @@ export function SecuritySettingsPage() {
取消
</button>
<button
type="button"
onClick={() => passwordMutation.mutate()}
type="submit"
disabled={
currentPassword.length === 0 ||
newPassword.length < minPasswordLength ||
@@ -152,7 +159,7 @@ export function SecuritySettingsPage() {
{passwordMutation.isPending ? '保存中…' : '保存'}
</button>
</div>
</div>
</form>
) : (
<button type="button" onClick={() => setChangingPassword(true)} className="flex w-full items-center justify-between px-7 py-4 text-left">
<span className="text-text-body">修改密码</span>
+14 -15
View File
@@ -5,8 +5,11 @@ import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { logout as revokeServerSession } from '@/lib/api/auth';
import { getMe } from '@/lib/api/profile';
import { resetTinode } from '@/lib/tinode/connection';
import { requestConfirm } from '@/stores/confirmDialog';
import { notImplemented } from '@/stores/globalToast';
import { useSessionStore } from '@/stores/session';
const DIRECTORY = [
@@ -33,16 +36,7 @@ const MOBILE_SHORTCUTS = [
{ key: 'calls', to: '/calls', icon: 'phone', label: '通话记录', colorClass: 'text-warn' },
];
/**
* README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). Logout is entirely
* client-side -- confirmed live (docs/IM-后端问题清单.md 问题22) all three plausible logout
* endpoints 404, matching the old app's own real implementation (tinode-web.jsx:1995-2038), which
* never calls a server logout endpoint at all, just clears local token/session and disconnects.
*/
function notImplemented() {
window.alert('该功能暂未开放,如需联系客服。');
}
/** README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). */
export function SettingsLayout() {
const location = useLocation();
const navigate = useNavigate();
@@ -55,9 +49,14 @@ export function SettingsLayout() {
// currently shows. Irrelevant at `md`+ (desktop always shows the one real sidebar).
const [mobileSubView, setMobileSubView] = useState(false);
const logout = () => {
if (!window.confirm('确定退出登录吗?')) return;
const logout = async () => {
if (!(await requestConfirm({ message: '确定退出登录吗?' }))) return;
setLoggingOut(true);
// docs/IM-后端问题清单.md 问题22:这个接口此前三个候选路径都404,本地清理是唯一手段,最近
// 复核确认它其实已经真实可用(同一token调用前后分别200/401,确认真的会被吊销)。必须在清空
// sessionStorage的token之前调用(`authToken()`读的就是这个值),且失败不能阻塞本地清理这条
// 兜底路径——不管服务端这次撤销成不成功,用户在这台设备上都应该被清出登录态。
await revokeServerSession().catch(() => {});
resetTinode();
sessionStorage.removeItem('im-session-token');
setMyUserId(null);
@@ -91,7 +90,7 @@ export function SettingsLayout() {
<header className="flex h-15 flex-none items-center px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">设置</h1>
</header>
<div className="flex items-center gap-3 border-b border-border-list-item px-5 pb-4.5 pt-2">
<div className="flex items-center gap-3 border-b border-border-row px-5 pb-4.5 pt-2">
<Avatar
id={meQuery.data?.user_id || ''}
name={meQuery.data?.profile.nickname}
@@ -114,7 +113,7 @@ export function SettingsLayout() {
active ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
}`}
>
<Icon name={item.icon} className="text-[18px]" />
<Icon name={item.icon} className="text-[20px]" />
<span className="flex-1">{item.label}</span>
</Link>
);
@@ -124,7 +123,7 @@ export function SettingsLayout() {
type="button"
onClick={logout}
disabled={loggingOut}
className="mx-3 my-4 h-10 flex-none rounded-lg border border-[#d5d9df] text-sm text-text-secondary disabled:opacity-50"
className="mx-5 mt-auto mb-5 h-10 flex-none rounded-lg border border-[#d5d9df] text-sm text-text-secondary disabled:opacity-50"
>
退出登录
</button>
+96 -9
View File
@@ -5,6 +5,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { acceptGroupInvitation } from '@/lib/api/groups';
import { useBootstrap } from '@/lib/api/hooks';
import { getSystemNotifications, markSystemNotificationRead, type SystemNotification } from '@/lib/api/notifications';
import { formatEventTimestamp } from '@/lib/messages/time';
@@ -61,6 +62,12 @@ function iconForEvent(eventType: string): { icon: string; className: string } {
*/
function actionRoute(item: SystemNotification): { label: string; to: string } | null {
if (item.event_type === 'friend.request.created') return { label: '查看新朋友', to: '/contacts/requests' };
// Handled separately (see the card's own `action.type === 'group_invitation_accept'` branch below,
// rendering a real "接受邀请" button) -- NOT a plain navigation like every other group.* event.
// You are not yet a member of this group at the point this notification arrives, so navigating
// straight to `/messages/{topic}` (what the branch below would otherwise do, since this event also
// carries a real `grp...` action.topic) would open a conversation you have no access to.
if (item.action?.type === 'group_invitation_accept') return null;
if (item.event_type.startsWith('group.') && typeof item.action?.topic === 'string' && item.action.topic.startsWith('grp')) {
return { label: '查看群聊', to: `/messages/${item.action.topic}` };
}
@@ -102,6 +109,29 @@ export function SystemMessagesView() {
},
onError: (err) => setError(err instanceof Error ? err.message : '标记已读失败'),
});
// README/docs :1464/1739's `group.invitation.created` flow -- the doc marks the accept endpoint
// "待实现" but it's real (see acceptGroupInvitation's own comment). Success takes you straight to
// the group (you're a member now); failure is surfaced inline on the card itself (an expired/
// 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>>({});
const acceptInvitationMutation = useMutation({
mutationFn: (invitationId: string) => acceptGroupInvitation(invitationId),
onSuccess: (data, invitationId) => {
setInvitationError((prev) => {
if (!prev[invitationId]) return prev;
const next = { ...prev };
delete next[invitationId];
return next;
});
navigate(`/messages/${data.topic}`);
},
onError: (err, invitationId) => {
setInvitationError((prev) => ({ ...prev, [invitationId]: err instanceof Error ? err.message : '邀请已失效,请联系邀请人重新邀请' }));
},
});
const [markingAll, setMarkingAll] = useState(false);
// `unread` only reflects PAGES ALREADY LOADED into this useInfiniteQuery -- looping just that set
// used to leave any unread notification on a page the user never scrolled to untouched, while the
@@ -117,10 +147,22 @@ export function SystemMessagesView() {
// one page of notifications (a real, confirmed-live-possible infinite hang, not just a stale
// comment claiming otherwise). Re-reading the cache's own `next_cursor` each iteration instead
// of trusting a captured object's field is what actually terminates.
// `fetchNextPage()` swallows its own request failures (TanStack Query v5's QueryObserver
// catches internally rather than rejecting) -- a page that fails to load leaves `next_cursor`
// exactly where it was, so a plain `while (cursor)` would retry the SAME stuck page forever on
// any transient failure (expired token, network blip), leaving `markingAll` stuck true and
// this button permanently showing "标记中…" with nothing telling the user why. Capped so a
// failure surfaces as an error instead of hanging.
let cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
while (cursor) {
let guard = 0;
while (cursor && guard++ < 200) {
await query.fetchNextPage();
cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
const next = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
if (next === cursor) {
setError('翻页失败,请重试');
return;
}
cursor = next;
}
const allItems = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.flatMap((p) => p.items) ?? [];
// Each item's own failure already surfaces via readMutation's onError above (mutateAsync fires
@@ -155,7 +197,11 @@ export function SystemMessagesView() {
all-pages total NavRail's own badge already trusts (and now invalidated by readMutation's
onSuccess below, so it won't go stale either) -- preferred over the loaded-only count
whenever it's available. */}
{(bootstrap.data?.entry_badges?.system_unread_count ?? unread.length) ? (
{/* `??` only guards against a MISSING bootstrap count, not a STALE one -- bootstrap is
`staleTime: Infinity` and only ever invalidated in the "count goes down" direction
(readMutation's onSuccess below), so a cached `0` from before any new notification
arrived would otherwise out-vote a real non-empty `unread` this render actually has. */}
{Math.max(bootstrap.data?.entry_badges?.system_unread_count ?? 0, unread.length) ? (
<button type="button" onClick={markAllRead} disabled={markingAll} className="text-sm text-accent disabled:opacity-60">
{markingAll ? '标记中…' : '全部标为已读'}
</button>
@@ -178,8 +224,30 @@ export function SystemMessagesView() {
const { icon, className } = iconForEvent(item.event_type);
const action = actionRoute(item);
const isUnread = !item.read_at;
// The whole card is the click target, not just the "查看…" link below -- most
// notifications (好友申请已通过/拒绝, most 群聊动态) have no `actionRoute` at all, so
// gating mark-read on that inner link left them with literally nothing clickable
// anywhere on the card, permanently stuck showing "新" no matter how long they sat there
// (a real, reported bug: "有些消息没有链接,没地方点击就一直是新"). A card with an action
// still marks read AND navigates; one without just marks read, since there's nowhere to go.
const activate = () => {
if (isUnread) readMutation.mutate(item.id);
if (action) navigate(action.to);
};
return (
<div key={item.id} className={`w-full flex-none rounded-xl bg-white p-[18px] md:w-[560px] ${isUnread ? '' : 'opacity-85'}`}>
<div
key={item.id}
role="button"
tabIndex={0}
onClick={activate}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
activate();
}
}}
className={`w-full flex-none cursor-pointer rounded-xl bg-white p-[18px] text-left md:w-[560px] ${isUnread ? '' : 'opacity-85'}`}
>
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.created_at))}</div>
<div className="mt-2 flex items-start gap-3">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg ${className}`}>
@@ -191,17 +259,36 @@ export function SystemMessagesView() {
{isUnread ? <b className="flex-none rounded bg-unread px-1 text-[10px] leading-[14px] text-white">新</b> : null}
</div>
<p className="m-0 text-sm text-[#4a4f57]">{notificationText(item)}</p>
{action ? (
{/* Plain text, not a nested `<button>` -- it's inside the card's own click target
above, and a button-in-button (role="button" div counts the same way) is the
same illegal-nesting/double-fire shape already fixed once in this codebase for
favorites' voice player. */}
{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"
onClick={() => {
disabled={acceptInvitationMutation.isPending}
onClick={(e) => {
e.stopPropagation();
if (isUnread) readMutation.mutate(item.id);
navigate(action.to);
acceptInvitationMutation.mutate(item.action!.invitation_id as string);
}}
className="self-start text-sm text-accent"
className="rounded-lg bg-btn px-3 py-1.5 text-xs font-medium text-white disabled:opacity-60"
>
{action.label} ›
{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}
</div>
) : null}
</div>
</div>
+32
View File
@@ -1,5 +1,37 @@
@import "tailwindcss";
/*
* Material Icons' OWN Google Fonts stylesheet (index.html's `<link>`) ships its `.material-icons`
* class completely unlayered -- plain CSS, no `@layer`. Tailwind v4 (the `@import` above) puts every
* one of its own utilities inside `@layer utilities`. Per the CSS Cascade Layers spec, ANY unlayered
* declaration beats EVERY layered one for the same property on the same element, regardless of
* selector specificity or which stylesheet loads later in the document -- confirmed live: an
* `<Icon className="text-[14px]">` computed to `font-size: 24px` regardless of the requested size,
* every time, because Google's unlayered rule was always winning over Tailwind's layered
* `.text-\[14px\]` utility. Icon.tsx uses its own `im-icon` class (not `.material-icons`) specifically
* so Google's competing rule never matches these elements at all -- this is the same visual default
* (24px, line-height 1, etc., copied 1:1 from Google's own stylesheet), just declared inside `@layer
* base` so Tailwind's `@layer utilities` (which Tailwind itself registers after `base`) can actually
* override it, the way every call site sizing an icon via `text-[Npx]` already assumed it could.
*/
@layer base {
.im-icon {
font-family: 'Material Icons';
font-weight: normal;
font-style: normal;
font-size: 24px;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
direction: ltr;
-webkit-font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
}
/*
* Design tokens ported 1:1 from docs/design_handoff_meetingim_web/README.md's "Design Tokens"
* section. Values are exact hex/px from the design handoff, not semantic guesses -- keep them in
+11
View File
@@ -1,5 +1,16 @@
import { imPost, imRequest } from './client';
/**
* docs/IM-后端问题清单.md 问题22:此前 `POST /v1/im/auth/logout` 三个候选路径都404,退出登录/
* 切换企业只能靠本地清理(清token+断连接),服务端并不知道这个token已经失效——同一次复核已确认这个
* 接口现在是真实可用的(同一token连续200,调用这个接口后同一token之后全部401,即真的会被吊销)。
* 调用失败(网络错误/接口本身还有问题)不应该阻塞本地清理这条兜底路径,调用方永远不等待/不因为这次
* 调用失败而跳过后续的本地清理。
*/
export function logout() {
return imPost<void>('/auth/logout');
}
export interface VerificationCodeResponse {
challenge_id: string;
resend_after_seconds: number;
+19 -10
View File
@@ -7,6 +7,7 @@
// is `X-MeetingIM-APIKey`, not the upstream sample's `X-Tinode-APIKey` -- that exact mismatch was
// a real, repeatedly-rediscovered bug in the old codebase).
import { API_KEY } from '@/lib/config';
import { getKickedInfo } from '@/lib/tinode/connection';
import { useSessionStore } from '@/stores/session';
const DEFAULT_API_BASE = '/v1/im';
@@ -29,7 +30,7 @@ export class ImApiError extends Error {
const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | null)> = {
API_KEY_REQUIRED: '服务暂不可用,请刷新页面重试',
METHOD_NOT_ALLOWED: '请求方式有误,请刷新页面重试',
TENANT_UNAVAILABLE: '企业码不存在或已停用',
TENANT_UNAVAILABLE: '企业码不存在或已停用,请核实后重试',
TENANT_MISMATCH: '登录状态异常,请重新进入企业码',
AUTH_FAILED: '账号或密码错误',
TOKEN_EXPIRED: '登录已过期,请重新登录',
@@ -86,6 +87,9 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
QR_CODE_INVALID: '二维码无效,请重新扫描',
FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友',
// `relation.Relationship != RelationshipNone` (im_http.go's serveIMFriendRequestCreate) -- already
// friends, or a request between the two of you (either direction) is already pending.
FRIEND_REQUEST_CONFLICT: '你们已经是好友,或好友申请正在处理中',
USER_NOT_DISCOVERABLE: '未找到该用户',
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
@@ -207,17 +211,22 @@ function forceReauth() {
if (reauthTriggered) return;
sessionStorage.removeItem('im-session-token');
useSessionStore.getState().setMyUserId(null);
// The latch only needs to survive until the hard reload below actually happens -- that reload is
// what resets it (a fresh module load starts with `reauthTriggered = false` again). Setting the
// latch UNCONDITIONALLY (the previous version did, before this check) meant a stray/background
// request 401-ing while already sitting on `/login` (reachable via an in-SPA `&lt;Navigate&gt;`, which
// never reloads) burned the latch with no reload to reset it -- silently disabling this function
// for the rest of that tab's life, including a genuine expiry after the user logs back in on the
// same SPA instance.
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
if (typeof window === 'undefined' || window.location.pathname === '/login') return;
reauthTriggered = true;
// An account-level eviction (connection.ts's onCtrlMessage) and a REST 401 can both be caused by
// the SAME server-side event (e.g. 顶号: the new login invalidates this token, and whichever
// request -- the WS {ctrl:205} push or an in-flight REST call -- gets processed first is down to
// network timing, not something this code controls). Without this, a REST 401 that happens to
// land first bounces straight to /login with zero explanation, silently pre-empting the whole
// point of KickedNoticeRoot: the doc's "弹出中文提示后跳转登录页" requirement. A brief wait lets
// that WS message (usually already in flight over an open connection, typically faster than a
// fresh REST round trip) land first if it's coming; if `kickedInfo` shows up in that window, defer
// to its own button-triggered navigation instead of racing it. Ordinary 401s (no eviction involved,
// by far the common case) just navigate ~200ms later than before -- not perceptible.
window.setTimeout(() => {
if (getKickedInfo()) return;
window.location.href = '/login';
}
}, 200);
}
const idem = () => ({ 'Idempotency-Key': requestId() });
+27
View File
@@ -143,6 +143,33 @@ export function getConversationSettings(topic: string) {
return imGet<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`);
}
/**
* P2P-only. Confirmed live (both directions of a real block): `relationship` is `"blocked_by_me"` on
* the blocker's own side and `"blocked_by_peer"` on the blocked side -- `can_send`/`capabilities.
* can_send_text` are `false` on both. This is the fix for a real, live-reproduced bug (Zentao #28):
* sending into a blocked P2P conversation showed only the generic per-bubble "发送失败,点击重试"
* with no reason on EITHER side -- reading tinode-sdk source confirmed why a specific reason can
* never be recovered after the fact: `Topic#publishMessage`'s own `.catch()` (tinode.dev.js ~line
* 4709) swallows the server's rejection into a bare `_failed` flag on the message and never rethrows,
* so there's no error object left for application code to read once a send has already failed. This
* endpoint is the way around that -- check `can_send` BEFORE attempting to send, not after.
* `getUserProfile()`'s own `capabilities.can_send` looked like it might cover this more cheaply (already
* fetched elsewhere), but confirmed live it 403s entirely from the blocked party's own side instead of
* returning a normal response, so it can't be used for this specific check either.
*/
export interface ConversationContext {
topic: string;
peer_user_id: string;
display_name: string;
can_send: boolean;
relationship: 'blocked_by_me' | 'blocked_by_peer' | string;
capabilities?: { can_send_text?: boolean; can_send_media?: boolean; can_send_card?: boolean; can_call?: boolean; can_add_friend?: boolean; can_handle_request?: boolean };
}
export function getConversationContext(topic: string) {
return imGet<ConversationContext>(`/conversations/${encodeURIComponent(topic)}/context`);
}
export function updateConversationSettings(topic: string, body: Partial<Pick<ConversationSettings, 'pinned' | 'mute_notifications'>> & { version: number }) {
return imPatch<ConversationSettings>(`/conversations/${encodeURIComponent(topic)}/settings`, body);
}
+67 -14
View File
@@ -130,9 +130,17 @@ export function leaveGroup(topic: string) {
return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {});
}
/** doc-flagged 待实现 (not yet implemented server-side) -- kept typed and ready for when it lands. */
export function disbandGroup(topic: string) {
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`);
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.17) -- get one via
* `requestActionToken({scene:'group_dissolve', ...})` (stores/securityChallenge.ts) first. Live,
* fully implemented server-side (a stale comment here previously claimed otherwise) -- confirmed by
* actually calling it. An explicit empty-object body is required: a bodiless DELETE still carries
* this client's own `Content-Type: application/json` header, and the server's JSON decoder treats
* a truly empty body under that header as malformed rather than "no optional fields provided". */
export function disbandGroup(topic: string, actionToken: string) {
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`, {
body: {},
headers: { 'X-Action-Token': actionToken },
});
}
export type GroupRole = 'owner' | 'admin' | 'member';
@@ -231,11 +239,18 @@ export function removeGroupAdmin(topic: string, userId: string, version: number)
);
}
/** Real response is flat (confirmed live: `{muted_at,muted_by,reason,status,user_id,version}`) --
* no nested `user` object, and no `display_name` at all (unlike e.g. group members, this endpoint
* never returns a readable name, only the bare id -- callers needing one must cross-reference the
* member list themselves, same as GroupFilesModal already has to for `uploader_id`). */
export interface GroupMute {
user: { user_id: string; display_name?: string };
user_id: string;
muted_by?: string;
muted_at: string;
expires_at: string | null;
expires_at?: string | null;
reason?: string;
status?: string;
version?: number;
}
export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) {
@@ -252,18 +267,26 @@ export function getGroupMutes(topic: string, cursor = '') {
return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`);
}
/** `getGroupMutes` alone only ever reads the first page -- unlike `getAllGroupMembers`'s own
* `fetchAllPages` wrapper right above, GroupAdminModal's mutes view called the single-page
* function directly, silently truncating both the list and its own header count once a group has
* enough muted members to span more than one page. */
export function getAllGroupMutes(topic: string) {
return fetchAllPages((cursor) => getGroupMutes(topic, cursor));
}
export function unmuteGroupMember(topic: string, userId: string) {
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
}
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- not yet
* wired since no risk-challenge flow is built anywhere in this app yet; calling this will 409 until
* that's added. Kept typed so the group-management UI can be built ahead of that. */
export function transferGroupOwner(topic: string, targetUserId: string, version: number) {
return imPost<{ owner: string; previous_owner: string; version: number }>(`/groups/${encodeURIComponent(topic)}/owner-transfer`, {
target_user_id: targetUserId,
version,
});
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- get one via
* `requestActionToken({scene:'group_owner_transfer', ...})` (stores/securityChallenge.ts) first. */
export function transferGroupOwner(topic: string, targetUserId: string, version: number, actionToken: string) {
return imPost<{ owner: string; previous_owner: string; version: number }>(
`/groups/${encodeURIComponent(topic)}/owner-transfer`,
{ target_user_id: targetUserId, version },
{ headers: { 'X-Action-Token': actionToken } },
);
}
export function getGroupGovernance(topic: string) {
@@ -277,13 +300,43 @@ export function updateGroupGovernance(
return imPatch<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`, patch);
}
/**
* Per-invitee outcome, confirmed by reading im_be's serveIMGroupInvitations directly (not just the
* doc's own single-outcome worked example): `"joined"` when the group's own governance AND the
* target's own privacy setting both don't require approval (added immediately, no further action
* needed); `"already_member"`; `"pending"`/`"pending_approval"` when either side DOES require it (a
* real GroupInvitation record is created instead, delivered to the target as a system notification --
* see SystemMessagesView's `group.invitation.created` handling -- and needs their own accept, this
* app has no UI surface for the inviter to ever learn the outcome beyond "sent"); `"failed"` with a
* `reason`.
*/
export interface GroupInvitationResult {
user_id: string;
status: 'joined' | 'already_member' | 'pending' | 'pending_approval' | 'failed';
invitation_id?: string;
reason?: string;
}
export function inviteGroupMembers(topic: string, userIds: string[], message?: string) {
return imPost<{ items: Array<{ user_id: string; status: string; invitation_id?: string }> }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
return imPost<{ items: GroupInvitationResult[] }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
user_ids: userIds,
message,
});
}
/**
* The target's own action on a `group.invitation.created` system notification (README/docs
* :1464/1739 -- doc marks this "待实现" but confirmed by reading im_be's serveIMGroupInvitationResource
* directly, it's real and live, same doc-drift as the topic-draft endpoint). Joins the group
* immediately on success (no further approval step at this point -- any approval requirement was
* already resolved by the invite-creation side choosing "pending"/"pending_approval" as the status
* instead of creating a real invitation, or is inherent to a `pending_approval` invitation itself,
* which this same endpoint resolves).
*/
export function acceptGroupInvitation(invitationId: string) {
return imPost<{ invitation_id: string; status: string; topic: string }>(`/group-invitations/${encodeURIComponent(invitationId)}/accept`);
}
/** Confirmed LIVE (2026-09-09) this does NOT match either the protocol doc's own worked example
* (`docs/IM-前后端对接文档.md:1741`, which shows a nested `applicant:{user_id,display_name}`
* object) or this file's own previous type -- the real response is a FLAT `user_id` string with no
+28 -1
View File
@@ -3,7 +3,7 @@
// calls this then hard-deletes the message from the local topic cache itself.
import type { DraftyContent } from 'tinode-sdk';
import { imGet, imPost } from './client';
import { imGet, imPost, imPut } from './client';
export interface RecallResult {
topic: string;
@@ -107,6 +107,33 @@ export function getTopicMedia(topic: string, type: MediaFetchType, cursor = '')
return imGet<{ topic: string; items: MediaItem[]; next_cursor?: string }>(`/topics/${encodeURIComponent(topic)}/media?${qs.toString()}`);
}
export interface TopicDraft {
topic: string;
content: string;
entities: unknown[];
version: number;
updated_at?: string;
}
/**
* README §3.2's unsent-message-draft persistence (Zentao #19: input clears on conversation switch/
* page refresh). docs/IM-前后端对接文档.md:1003/1247 marks `PUT /v1/im/topics/{topic}/draft` as
* "待实现" (not yet implemented) and describes local-only storage as the baseline -- but confirmed by
* reading im_be's own source (server/im_http.go's serveIMTopicDraft, wired to store.Conversations.
* GetDraft/PutDraft), this endpoint is REAL and already live, the doc is just stale. A draft that's
* never been saved comes back as a 200 (not 404) with `content:""`/`version:0`, so GET never needs
* special-casing a not-found error -- confirmed in the same source.
*/
export function getTopicDraft(topic: string) {
return imGet<TopicDraft>(`/topics/${encodeURIComponent(topic)}/draft`);
}
/** `entities` intentionally always `[]` for now -- this app's draft state is plain text only (no
* in-progress @mention/attachment entities tracked yet), and the field is optional server-side. */
export function putTopicDraft(topic: string, content: string, version: number) {
return imPut<TopicDraft>(`/topics/${encodeURIComponent(topic)}/draft`, { content, entities: [], version });
}
/**
* A single message's "收藏" context-menu action is really just a one-item batch (docs/
* IM-前后端对接文档.md:1935): `POST /v1/im/favorites/batch {"messages":[{"topic","seq"}]}` ->
+24
View File
@@ -15,3 +15,27 @@ export interface UrlSafetyResult {
export function checkUrlSafety(url: string, source: string) {
return imPost<UrlSafetyResult>('/security/urls/check', { url, source });
}
/** Sensitive-action re-verification (docs/IM-前后端对接文档.md:762-766, §6.17's group-dissolve line).
* Confirmed by reading im_be's own `validIMSecurityChallengeScene` (server/im_http.go) -- the doc
* text only spells out the two youth-mode scenes by name, but the server also accepts
* `group_dissolve`/`group_owner_transfer`, which is what actually lets this flow work for either. */
export type SecurityChallengeScene = 'group_dissolve' | 'group_owner_transfer';
export interface SecurityChallenge {
challenge_id: string;
phone_mask: string;
expires_in_seconds: number;
resend_after_seconds: number;
}
export function requestSecurityChallenge(scene: SecurityChallengeScene) {
return imPost<SecurityChallenge>('/security/challenges', { scene, channel: 'sms' });
}
export function verifySecurityChallenge(challengeId: string, code: string) {
return imPost<{ action_token: string; action: string; expires_at: string }>(
`/security/challenges/${encodeURIComponent(challengeId)}/verify`,
{ code },
);
}
+21 -2
View File
@@ -36,13 +36,22 @@ const UPLOAD_CTRL_TEXT_CN: Record<string, string> = {
'not found': '内容不存在或已被删除',
};
export async function uploadFile(file: File, topic?: string): Promise<UploadedFile> {
/**
* `purpose` only matters when `topic` is omitted (a chat/group-file upload always sends
* `chat_file` regardless) -- currently just the avatar upload path, which used to send no
* `purpose` at all. That omission dated back to this same endpoint's `purpose=user_avatar` 400ing
* (docs/IM-后端问题清单.md 问题28) -- confirmed since fixed (a real PNG upload with that purpose
* now returns 200), so there's no reason left to omit it.
*/
export async function uploadFile(file: File, topic?: string, purpose?: string): Promise<UploadedFile> {
const form = new FormData();
form.append('file', file);
form.set('id', getTinode().getNextUniqueId());
if (topic) {
form.append('purpose', 'chat_file');
form.append('topic', topic);
} else if (purpose) {
form.append('purpose', purpose);
}
const res = await fetch(`${fileBase()}/u/`, {
method: 'POST',
@@ -72,6 +81,16 @@ export function fileAuthHeaders(): Record<string, string> {
return { 'X-MeetingIM-APIKey': API_KEY, Authorization: `Token ${authToken()}` };
}
/**
* Some backend-issued absolute URLs (e.g. Aliyun OSS presigned URLs) come back as `http://` even
* though every page in this app is served over `https://` -- the browser blocks those as mixed
* content before any of our code sees a response. There's no legitimate case where this app needs
* to fetch a plain-http resource, so upgrading the scheme here is safe and has no downside.
*/
export function upgradeToHttps(url: string): string {
return url.startsWith('http://') ? `https://${url.slice('http://'.length)}` : url;
}
/**
* Absolute download URL for a file reference returned by uploadFile() or a received attachment.
* The upload response's `url` (e.g. "/v0/file/s/file123") is already rooted at the tenant's file
@@ -79,7 +98,7 @@ export function fileAuthHeaders(): Record<string, string> {
* fileBase() down to just its origin before appending, rather than concatenating the two paths.
*/
export function fileDownloadUrl(refUrl: string): string {
if (refUrl.startsWith('http')) return refUrl;
if (refUrl.startsWith('http')) return upgradeToHttps(refUrl);
const origin = fileBase().replace(/\/v0\/file.*$/, '');
return `${origin}${refUrl}`;
}
+8 -1
View File
@@ -61,7 +61,14 @@ export const IMAGE_THUMBNAIL_DIM = 36;
export const VIDEO_THUMBNAIL_WIDTH = 48;
export const MAX_ONLINE_IN_TOPIC = 4;
export const MAX_TITLE_LENGTH = 60;
/** `im_be/server/im_http.go`'s `serveIMGroupCreate`: `len([]rune(name)) < 1 || > 32` rejects outright
* with a bare MALFORMED_REQUEST (no `details.field`, unlike the description-length check right next
* to it) -- was `60` here, a value that let CreateGroupModal's input accept and submit names the
* backend always rejects, surfacing only the generic "内容不符合平台规范" (indistinguishable from an
* actual content-moderation hit, since the backend response itself doesn't say which). Capping input
* at the real limit prevents the over-length case from ever reaching the backend at all (live-
* confirmed real bug, Zentao #26). */
export const MAX_TITLE_LENGTH = 32;
/** docs/IM-前后端对接文档.md:1445 -- "description 可省略或传空字符串,最长 200 字符". */
export const MAX_TOPIC_DESCRIPTION_LENGTH = 200;
export const MAX_PEER_TITLE_LENGTH = 24;
+8 -2
View File
@@ -1,7 +1,7 @@
import type { StoredMessage } from 'tinode-sdk';
import type { MessagePolicy } from '@/lib/api/bootstrap';
import { isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted } from '@/lib/messages/dispatch';
/** 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). */
@@ -33,7 +33,13 @@ 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 [];
const actions: MessageAction[] = ['quote', 'copy', 'favorite', 'forward'];
// 名片消息和通话结果消息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'];
// 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.
+20 -4
View File
@@ -18,12 +18,28 @@ export function isRecalled(msg: StoredMessage): boolean {
return !isServerDeleted(msg) && msg.content == null;
}
interface SystemNoticeContent {
interface FlatTextContent {
text: string;
}
function isSystemNoticeContent(content: unknown): content is SystemNoticeContent {
return !!content && typeof content === 'object' && typeof (content as SystemNoticeContent).text === 'string';
function isFlatTextContent(content: unknown): content is FlatTextContent {
return !!content && typeof content === 'object' && typeof (content as FlatTextContent).text === 'string';
}
/**
* `{text: "..."}` -- a flat, non-Drafty content shape (no `txt`/`fmt`/`ent`) this backend uses for
* more than one kind of message, not just the centered system-event lines `systemNoticeText` below
* renders: a real, live-confirmed example is the auto-posted "群公告\n<announcement text>" message
* a group's announcement publish creates, which has an ordinary sender/avatar (NOT a centered system
* line) and `head['x-im-type']:"text"` (not `system_notice`) -- but the SAME flat `{text}` content
* shape regardless. `renderDrafty`/`formatMessagePreview` both need this recognized as plain text
* before falling through to real Drafty processing (`Drafty.format`/`Drafty.preview` on an object
* with none of the fields they expect renders empty/`undefined`, confirmed live: a group's own
* announcement-notice bubble showed no text at all, and the chat list's preview literally read "我:
* undefined").
*/
export function flatTextContentOf(content: unknown): string | null {
return isFlatTextContent(content) ? content.text : null;
}
/**
@@ -35,7 +51,7 @@ function isSystemNoticeContent(content: unknown): content is SystemNoticeContent
export function systemNoticeText(msg: StoredMessage): string | null {
const headType = msg.head?.['x-im-type'];
if (headType !== 'system_notice' && headType !== 'system_action') return null;
return isSystemNoticeContent(msg.content) ? msg.content.text : null;
return flatTextContentOf(msg.content);
}
export interface ContactCardContent {
+3
View File
@@ -4,6 +4,7 @@ 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';
/**
* Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown
@@ -124,6 +125,8 @@ function makeFormatNode(highlightQuery: string | undefined) {
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode {
if (content == null) return null;
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
const flatText = flatTextContentOf(content);
if (flatText != null) return highlightQuery ? <>{highlightMatches(flatText, highlightQuery, 'plain')}</> : flatText;
if (Drafty.isPlainText(content)) {
const text = Drafty.toPlainText(content);
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
+8 -1
View File
@@ -1,6 +1,6 @@
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
import { callResultDisplay, contactCardContentOf, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
import { callResultDisplay, contactCardContentOf, flatTextContentOf, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
/**
* One-line chat-list preview text (README §2.1): plain text renders as-is, everything else falls
@@ -26,6 +26,13 @@ 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 `{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
// line), which fell through everything above straight into the Drafty-entity branches below and
// produced the literal string "undefined" (confirmed live: chat list showed "我:undefined").
const flatText = flatTextContentOf(content);
if (flatText != null) return prefix + flatText;
if (typeof content === 'string' || Drafty.isPlainText(content)) {
return prefix + Drafty.toPlainText(content);
}
+153
View File
@@ -0,0 +1,153 @@
// Desktop/browser Notification API integration: fires a real OS-level notification for a genuinely
// new incoming message, with the same "when NOT to" judgment calls every other IM web client makes.
// Only works while this tab is open (foreground or backgrounded) -- there is no Service Worker/Push
// subscription here, so this cannot notify once the browser itself is fully closed. That would need
// a backend push-subscription endpoint + VAPID keys, a materially bigger feature than this.
import type { StoredMessage } from 'tinode-sdk';
import { formatMessagePreview } from '@/lib/messages/preview';
import {
getActiveConversationTopic,
getChatListSnapshot,
getMessagesSnapshot,
subscribeAnyTopicChange,
subscribeNewGroupDiscovered,
} from '@/lib/tinode/topics';
import { useNotificationNavStore } from '@/stores/notificationNav';
import { useSessionStore } from '@/stores/session';
// "Am I already looking at this exact conversation" (topic open AND tab visible) is tracked in
// topics.ts, not here -- topics.ts's own me.onPres correction (see its comment) needs the same
// state, and topics.ts can't import it back from this module without a cycle. ConversationView calls
// topics.ts's setActiveConversationTopic directly; this module just reads it via the getter.
const DEBOUNCE_MS = 400;
/** How recent a topic's first-ever-observed message must be to count as "genuinely live" rather than
* catch-up history -- see checkTopic's own comment. Generous relative to DEBOUNCE_MS on purpose: a
* slow network round trip for a brand-new friend's actual first message shouldn't get misjudged as
* history just because it took a couple of seconds to arrive. */
const LIVE_MESSAGE_MAX_AGE_MS = 10_000;
const lastSeqByTopic = new Map<string, number>();
let pendingScan: ReturnType<typeof setTimeout> | null = null;
function latestMessageOf(topicName: string): StoredMessage | undefined {
const msgs = getMessagesSnapshot(topicName);
return msgs[msgs.length - 1];
}
/**
* One global debounce covers every topic at once (re-scanning the whole chat list is cheap and
* already the pattern getChatListSnapshot/invalidateChatListSnapshot itself uses) rather than a
* per-topic timer -- coalescing matters because a historical page load (opening a conversation for
* the first time, or its chat-list preview prefetch) fires this same underlying "something changed"
* signal once per message in the batch, arriving synchronously back-to-back. Checking on every one of
* those would seed/compare a topic's baseline against a moving mid-batch target and could misfire on
* messages that are actually old; waiting for the burst to settle means the first evaluation always
* sees the batch's true final state.
*/
function scheduleScan() {
if (pendingScan) clearTimeout(pendingScan);
pendingScan = setTimeout(() => {
pendingScan = null;
for (const contact of getChatListSnapshot()) {
if (contact.topic) checkTopic(contact.topic);
}
}, DEBOUNCE_MS);
}
function checkTopic(topicName: string) {
const latest = latestMessageOf(topicName);
if (!latest || typeof latest.seq !== 'number') return;
const previousSeq = lastSeqByTopic.get(topicName);
lastSeqByTopic.set(topicName, latest.seq);
if (previousSeq !== undefined && latest.seq <= previousSeq) return; // no actual new message
// A topic observed here for the very first time (fresh login, or a brand-new conversation just
// learned about) can't be judged as "new" by seq alone -- a months-old conversation surfacing for
// the first time this session and a brand-new friend's actual first-ever message look identical by
// that measure (both are "seq 1, never seen before"), but only one of them should notify. Message
// age is what actually distinguishes them: something that happened within roughly this debounce
// window is genuinely live; anything older is catch-up history, silently seeded as the baseline
// instead. A topic already being tracked (previousSeq defined) always notifies on a real seq
// increase regardless of age -- that case doesn't have this ambiguity to begin with.
if (previousSeq === undefined) {
const ts = latest.ts instanceof Date ? latest.ts.getTime() : NaN;
const age = Number.isFinite(ts) ? Date.now() - ts : Infinity;
if (age > LIVE_MESSAGE_MAX_AGE_MS) return;
}
if (shouldNotify(topicName, latest)) fireNotification(topicName, latest);
}
function shouldNotify(topicName: string, msg: StoredMessage): boolean {
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return false;
const myUserId = useSessionStore.getState().myUserId;
// Your own message, echoed back (e.g. sent from another tab/device) -- never alert yourself.
if (msg.from && myUserId && msg.from === myUserId) return false;
// Already looking at this exact conversation, in the tab that's actually on screen right now --
// the active-topic check alone isn't enough, since a backgrounded tab still keeps ConversationView
// mounted.
if (topicName === getActiveConversationTopic() && document.visibilityState === 'visible') return false;
const contact = getChatListSnapshot().find((c) => c.topic === topicName);
if (contact?.private?.mute_notifications) return false;
return true;
}
/** Shared by both real call sites below -- a new message, or being newly added to a group. `tag:
* topicName` makes a second notification from the same conversation REPLACE the first in the OS
* notification tray instead of stacking, matching every mainstream IM web client. */
function showNotification(topicName: string, title: string, body: string) {
let notif: Notification;
try {
notif = new Notification(title, { body, tag: topicName, icon: '/favicon.svg' });
} catch {
// Some browsers/contexts throw constructing Notification even with permission granted (e.g. an
// OS-level notification setting disabled after the fact) -- never let this crash the message
// pipeline over a non-essential feature.
return;
}
notif.onclick = () => {
window.focus();
useNotificationNavStore.getState().requestNavigate(topicName);
notif.close();
};
}
function fireNotification(topicName: string, msg: StoredMessage) {
const contact = getChatListSnapshot().find((c) => c.topic === topicName);
const title = contact?.public?.fn || topicName;
const myUserId = useSessionStore.getState().myUserId;
const body = formatMessagePreview(msg, myUserId) || '发来一条新消息';
showNotification(topicName, title, body);
}
/** "被拉入群时title的消息没有提示" -- topics.ts's reconcileMeSubs() is the only thing that can even
* detect this (nothing else observes a group appearing that this client never knew about), so it
* fires this via subscribeNewGroupDiscovered rather than this module trying to infer "newly added"
* from message/seq activity, which a group with zero messages so far wouldn't have at all. */
function onNewGroupDiscovered(topicName: string) {
if (typeof Notification === 'undefined' || Notification.permission !== 'granted') return;
const contact = getChatListSnapshot().find((c) => c.topic === topicName);
if (contact?.private?.mute_notifications) return;
const title = contact?.public?.fn || topicName;
showNotification(topicName, title, '你被拉入了这个群聊');
}
let unsubscribe: (() => void) | null = null;
/** Call once per authenticated session (RequireAuth.tsx, alongside startMeReconciliation) and invoke
* the returned function on logout/unmount. Safe to start/stop repeatedly. */
export function initMessageNotifications(): () => void {
if (unsubscribe) return unsubscribe;
const unsubScan = subscribeAnyTopicChange(scheduleScan);
const unsubNewGroup = subscribeNewGroupDiscovered(onNewGroupDiscovered);
unsubscribe = () => {
unsubScan();
unsubNewGroup();
};
return () => {
unsubscribe?.();
unsubscribe = null;
if (pendingScan) clearTimeout(pendingScan);
pendingScan = null;
lastSeqByTopic.clear();
};
}
+101
View File
@@ -0,0 +1,101 @@
// Browser-tab-level "you have unread messages" signal: a scrolling title (only while the tab is
// backgrounded -- flashing/scrolling while the user is actively looking at it would just be
// annoying, and every real IM web client stops the moment you look back) plus a numeric badge drawn
// onto the favicon (visible regardless of focus, same as Gmail/Slack's own tab badge).
const BASE_TITLE = document.title;
const TICK_MS = 300;
const TITLE_WINDOW = 18;
let scrollTimer: ReturnType<typeof setInterval> | null = null;
let scrollText = '';
let scrollPos = 0;
function stopScrolling() {
if (scrollTimer) {
clearInterval(scrollTimer);
scrollTimer = null;
}
document.title = BASE_TITLE;
}
/** Left-scrolling marquee over `📨 (N条新消息) <original title>` -- shifts one character per tick
* and wraps, rather than just alternating two strings, so it reads as genuine "滚动" rather than a
* blink. Re-callable while already running to pick up a changed count without restarting the timer
* (only the text/position reset; a second interval is never created). */
function startScrolling(count: number) {
scrollText = `📨 (${count}条新消息) ${BASE_TITLE}   `;
scrollPos = 0;
if (scrollTimer) return;
scrollTimer = setInterval(() => {
const doubled = scrollText + scrollText;
document.title = doubled.slice(scrollPos, scrollPos + TITLE_WINDOW);
scrollPos = (scrollPos + 1) % scrollText.length;
}, TICK_MS);
}
// Stop scrolling and restore the plain title the instant the user looks back at the tab, rather
// than waiting for the next unread-count change to notice -- otherwise a title that scrolled to a
// mid-cycle, half-scrambled substring could sit there indefinitely after the user has already
// returned, if no new message happens to arrive to trigger another updateTabIndicator() call.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') stopScrolling();
});
let faviconImg: HTMLImageElement | null = null;
const FAVICON_HREF = '/favicon.svg';
/** Draws a small red circle + white count in the favicon's top-right corner onto a canvas, then
* points the existing `<link rel="icon">` at the resulting PNG data URL -- mutating the same link
* element's href/type rather than adding a second competing `<link rel="icon">`, since browsers'
* tie-breaking between multiple icon links is inconsistent. Reverts to the plain SVG favicon at 0. */
function updateFaviconBadge(count: number) {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) return;
if (count <= 0) {
link.href = FAVICON_HREF;
link.type = 'image/svg+xml';
return;
}
if (!faviconImg) {
faviconImg = new Image();
faviconImg.src = FAVICON_HREF;
}
const draw = () => {
const size = 64;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
if (!ctx || !faviconImg) return;
ctx.drawImage(faviconImg, 0, 0, size, size);
const radius = size * 0.32;
const cx = size - radius;
const cy = radius;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fillStyle = '#f34b46'; // --color-unread (src/index.css) -- same red the in-app badges use
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.font = `bold ${Math.round(radius * 1.15)}px -apple-system, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(count > 99 ? '99+' : String(count), cx, cy + 1);
link.href = canvas.toDataURL('image/png');
link.type = 'image/png';
};
if (faviconImg.complete) draw();
else faviconImg.onload = draw;
}
/** Call whenever the app's canonical total-unread number changes (AppShell.tsx already computes it
* for NavRail/BottomTabBar) -- keeps the favicon badge and, while backgrounded, the scrolling title
* in sync with that same number rather than tracking a second, potentially-divergent count. */
export function updateTabIndicator(unreadCount: number) {
updateFaviconBadge(unreadCount);
if (unreadCount > 0 && document.visibilityState !== 'visible') {
startScrolling(unreadCount);
} else {
stopScrolling();
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import { Drafty, type Topic } from 'tinode-sdk';
import { uploadFile } from '@/lib/api/upload';
import { uploadFile, upgradeToHttps } from '@/lib/api/upload';
import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config';
import { newClientMid } from '@/lib/tinode/send';
@@ -43,7 +43,7 @@ export async function sendImage(topic: Topic, file: File): Promise<unknown> {
? Drafty.insertImage(undefined, 0, { mime: file.type, bits: await fileToBase64(file), width, height, filename: file.name, size: file.size })
: Drafty.insertImage(undefined, 0, {
mime: file.type,
refurl: (await uploadFile(file, topic.name)).url,
refurl: upgradeToHttps((await uploadFile(file, topic.name)).url),
width,
height,
filename: file.name,
@@ -58,7 +58,7 @@ export async function sendFile(topic: Topic, file: File): 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: (await uploadFile(file, topic.name)).url, 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());
@@ -79,7 +79,7 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P
? Drafty.insertAudio(undefined, 0, { mime: file.type, bits: await fileToBase64(file), duration: durationMs, size: file.size, filename: file.name })
: Drafty.insertAudio(undefined, 0, {
mime: file.type,
refurl: (await uploadFile(file, topic.name)).url,
refurl: upgradeToHttps((await uploadFile(file, topic.name)).url),
duration: durationMs,
size: file.size,
filename: file.name,
+24 -3
View File
@@ -66,14 +66,35 @@ export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' |
return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>;
}
/**
* `Topic#videoCall` sends a `{note}` packet, which (unlike `{pub}`) carries no `id` field -- read
* the SDK source: with no id to route a response through, a disconnected socket makes the
* underlying `sendText()` throw SYNCHRONOUSLY rather than reject a promise. None of this module's
* callers in `stores/calls.ts` await or `.catch()` these calls (they're fire-and-forget signals),
* so an uncaught synchronous throw here doesn't just fail silently -- it aborts whatever code was
* running right after the call site. Concretely: `hangUp()`'s 60s ring-timeout callback calls this,
* and if the connection happens to be down at that exact moment (e.g. `resetTinode()` ran because
* the account was evicted mid-call), the exception skips every line after it in that function,
* including the local Agora `leaveCall()` cleanup and the UI's own end-of-call state transition --
* the call window would stay stuck open with a live camera/mic track. These three are the only
* `note`-based signals in the app; wrapping here (once) covers every call site in one place.
*/
function sendNote(topic: Topic, event: 'ringing' | 'accept' | 'hang-up', seq: number, payload: Record<string, unknown>) {
try {
return topic.videoCall(event, seq, payload);
} catch {
return undefined;
}
}
export function sendCallRinging(topic: Topic, seq: number, callId: string) {
return topic.videoCall('ringing', seq, { callId });
return sendNote(topic, 'ringing', seq, { callId });
}
export function sendCallAccept(topic: Topic, seq: number, callId: string) {
return topic.videoCall('accept', seq, { callId });
return sendNote(topic, 'accept', seq, { callId });
}
export function sendCallHangup(topic: Topic, seq: number, callId: string, reason?: string) {
return topic.videoCall('hang-up', seq, reason ? { callId, reason } : { callId });
return sendNote(topic, 'hang-up', seq, reason ? { callId, reason } : { callId });
}
+42 -1
View File
@@ -7,7 +7,7 @@
// silently stomp on each other. This module claims all the SDK's callback slots exactly once and
// fans them out to any number of subscribers via its own Set, which is what hooks.ts's
// useSyncExternalStore-based hooks actually subscribe to.
import { Tinode, type LoginCtrl } from 'tinode-sdk';
import { Tinode, type LoginCtrl, type TinodeCtrl } from 'tinode-sdk';
import { API_KEY, APP_NAME, DEFAULT_HOST, LOGGING_ENABLED } from '@/lib/config';
import { isTenantMismatch } from '@/lib/tinode/errors';
@@ -109,6 +109,32 @@ if (typeof window !== 'undefined') {
window.addEventListener('offline', () => setReconnectState({ networkOnline: false }));
}
/**
* 强制下线通知与顶号互踢-后端接口文档.md §2: an account-level eviction is a `{ctrl}` with
* `code:205, text:"evicted"` and NO `topic` (a topic-scoped one -- e.g. plain removal from a group --
* carries a topic name instead and is left to whatever already handles that; not this module's
* concern). `reason` drives which notice text KickedNoticeRoot shows.
*/
let kickedInfo: { reason: string | undefined } | null = null;
const kickedListeners = new Set<() => void>();
export function getKickedInfo(): { reason: string | undefined } | null {
return kickedInfo;
}
export function subscribeKicked(callback: () => void): () => void {
kickedListeners.add(callback);
return () => kickedListeners.delete(callback);
}
/** KickedNoticeRoot calls this once the user has acknowledged the notice, right before navigating
* to /login -- clears the flag so a later fresh login doesn't leave a stale notice around to
* reappear (e.g. StrictMode remount, or the user landing back on some cached page via history). */
export function clearKickedInfo() {
kickedInfo = null;
kickedListeners.forEach((cb) => cb());
}
/**
* 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,
@@ -205,6 +231,21 @@ function createTinode(): Tinode {
if (next <= 0) clearCountdown();
}, 1000);
};
t.onCtrlMessage = (ctrl: TinodeCtrl) => {
if (ctrl.code !== 205 || ctrl.text !== 'evicted' || ctrl.topic) return;
// Tear down the session THE MOMENT eviction is detected, not when the user dismisses the
// notice -- the doc requires reconnect to stop immediately, and resetTinode()'s disconnect()
// sets the SDK's own #boffClosed flag before this handler returns, which is what actually
// prevents the backoff timer from ever scheduling another attempt (confirmed by reading the
// real bundle's Connection#init_ws -- disconnect() is the only thing that does this; there is
// no separate "stop autoreconnect" method to call instead). Only the user-facing navigation to
// /login is deferred, so they get a chance to actually read why.
sessionStorage.removeItem('im-session-token');
useSessionStore.getState().setMyUserId(null);
resetTinode();
kickedInfo = { reason: typeof ctrl.params?.reason === 'string' ? ctrl.params.reason : undefined };
kickedListeners.forEach((cb) => cb());
};
return t;
}
+50
View File
@@ -39,6 +39,23 @@ export function isTenantMismatch(err: unknown): boolean {
return raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase() === 'tenant mismatch';
}
/**
* `openConversation()`'s `{sub}` genuinely rejects with a clean `CommError` (code 404, message
* "topic not found (404)") for a topic that no longer exists -- confirmed live: a group dissolved
* by its owner, then deep-linked/refreshed into directly by a former member (not the "I'm actively
* watching it get dissolved right now" case subscribeTopicGone already covers), reproducibly 404s
* exactly like this. Before this check existed, ConversationView's generic catch just showed
* "加载会话失败,请重试" and left the screen sitting on a half-initialized topic stub (raw topic id
* as the name, "1970/1/1" for `touched`, "0" members -- `getTinode().getTopic()` lazily creates that
* stub the moment anything asks for the name, whether or not the subscribe behind it ever succeeds),
* a real reported bug ("当打开的群聊被删除后, 刷新还在群聊中 但是显示的不对"). 404 alone is treated
* as sufficiently unambiguous for "this topic doesn't exist" -- there's no other real reason a
* `{sub}` would come back with this exact code.
*/
export function isTopicGone(err: unknown): boolean {
return (err as { code?: number })?.code === 404;
}
export function loginErrorMessage(err: unknown): string {
const known = translateCtrlError(err);
if (known) return known;
@@ -87,3 +104,36 @@ export function validateCredentialsMessage(params: ValidateCredentialsParams | u
const reason = params?.reason;
return (reason && INVITE_CODE_REASON_TEXT_CN[reason]) || '注册信息校验未通过,请检查后重试';
}
/** 强制下线通知与顶号互踢-后端接口文档.md §2.4's own worked copy, verbatim -- not our own wording,
* so this stays a straight lookup rather than something to rephrase later without checking the doc
* again. `admin_user_evict`/`admin_device_kick` intentionally share one entry (doc's own table
* merges them into a single row). */
const KICKED_REASON_TEXT_CN: Record<string, { title: string; message: string }> = {
replaced_by_new_login: {
title: '账号已在其他设备登录',
message: '您的账号已在另一台设备上登录,当前设备已退出。如非本人操作,请及时修改密码。',
},
admin_user_evict: {
title: '账号已被管理员下线',
message: '您的账号已被管理员强制下线,如有疑问请联系管理员。',
},
admin_device_kick: {
title: '账号已被管理员下线',
message: '您的账号已被管理员强制下线,如有疑问请联系管理员。',
},
user_deleted: {
title: '账号已注销',
message: '您的账号已注销,当前会话已退出。',
},
user_state_changed: {
title: '账号已被限制登录',
message: '您的账号已被管理员限制登录,如有疑问请联系管理员。',
},
};
const KICKED_REASON_FALLBACK = { title: '账号已被强制下线', message: '当前会话已被终止,请重新登录。' };
export function kickedNoticeText(reason: string | undefined): { title: string; message: string } {
return (reason && KICKED_REASON_TEXT_CN[reason]) || KICKED_REASON_FALLBACK;
}
+16 -1
View File
@@ -2,7 +2,16 @@ import { useCallback, useSyncExternalStore } from 'react';
import type { Contact, StoredMessage } from 'tinode-sdk';
import { getConnectionStatus, getReconnectState, getTinode, subscribeConnectionStatus, subscribeReconnectState, type ReconnectState } from './connection';
import {
getConnectionStatus,
getKickedInfo,
getReconnectState,
getTinode,
subscribeConnectionStatus,
subscribeKicked,
subscribeReconnectState,
type ReconnectState,
} from './connection';
import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicChanges } from './topics';
/** The current WS connection status, reactive via useSyncExternalStore. */
@@ -15,6 +24,12 @@ export function useReconnectState(): ReconnectState {
return useSyncExternalStore(subscribeReconnectState, getReconnectState);
}
/** Non-null once the server has forcibly evicted this session (顶号/管理端踢出/封禁/删号/重置密码 --
* see 强制下线通知与顶号互踢-后端接口文档.md §2); KickedNoticeRoot is the sole consumer. */
export function useKickedInfo() {
return useSyncExternalStore(subscribeKicked, getKickedInfo);
}
/** The singleton Tinode client instance (stable reference, not itself reactive). */
export function useTinode() {
return getTinode();
+70
View File
@@ -33,6 +33,18 @@ declare module 'tinode-sdk' {
params?: Record<string, unknown>;
}
/** One entry from `Topic#subscribers()` -- fields confirmed live against a real P2P topic (only
* `user` is actually consumed anywhere in this app so far; the rest are declared for completeness). */
export interface Subscription {
user: string;
online?: boolean;
updated?: Date;
read?: number;
recv?: number;
acs?: { want?: string; given?: string; mode?: string };
public?: TopicDescPublic | null;
}
export interface CredentialParam {
meth: string;
val?: string;
@@ -191,6 +203,20 @@ declare module 'tinode-sdk' {
isSelfType(): boolean;
getAccessMode(): AccessMode | undefined;
/**
* Iterates this topic's cached subscriber list, one `Subscription` per call -- for a P2P topic
* this is always exactly the two participants (self included), confirmed live. This is the only
* reliable way to recover the OTHER party's real `usr...` id once a P2P topic has been subscribed
* at least once: the SDK renames a freshly-addressed `usr...` topic to a real, opaque `p2p...`
* name the moment `subscribe()` succeeds (see topics.ts's `openConversation`), and -- confirmed by
* reading tinode.dev.js -- `Topic.topicType()`'s own prefix table has no `p2p` entry at all (only
* `usr` maps to the P2P type), so `isP2PType()`/`p2pPeerDesc()` silently stop working the instant
* a topic has this real name. `topic.name` itself is NOT a usable substitute for the peer's id
* past that point -- anything needing "the other user" (profile lookup, friends-list matching,
* block/remark) must go through this method instead.
*/
subscribers(callback: (sub: Subscription) => void): void;
/**
* Iterates locally-cached messages in `[sinceId, beforeId]` (both omitted = everything cached).
* `callback(msg, prevMsg, nextMsg, bufferIndex)` fires once per message, already resolved to the
@@ -246,9 +272,20 @@ declare module 'tinode-sdk' {
onAllMessagesReceived?: (count: number) => void;
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; 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
* usr-alias -> real-topic-name rename case (Topic#subscribe, same code block that updates
* `this.name`). Distinct from `onSubsUpdated` below (which the SDK ALSO fires in that same
* block, with just the name/count) -- wired to the identical notify callback here since both
* ultimately mean the same thing for this app's purposes: "the chat list's contact set changed,
* recompute it." */
onMetaSub?: (topic: Topic) => void;
onSubsUpdated?: (subs: string[], count?: number) => void;
onPres?: (pres: unknown) => void;
onAuxUpdated?: (aux: unknown) => void;
/** Fired by the SDK's own `Topic#_gone()` once this topic is confirmed deleted (group dissolved,
* or this account removed from it) -- clears `public`/`private`/messages just before firing. */
onDeleteTopic?: () => void;
}
export class MeTopic extends Topic {
@@ -294,6 +331,18 @@ declare module 'tinode-sdk' {
getMeTopic(): MeTopic;
getTopic(topicName: string): Topic;
/** Iterates EVERY locally-cached topic (any type, including `me`/`fnd`), unlike `MeTopic.contacts()`
* which additionally filters by `isCommType()` -- see topics.ts's `getChatListSnapshot` for why
* that filter can't be trusted for this backend's P2P topics. */
mapTopics(callback: (topic: Topic, key: string) => void): void;
/** Public (not underscore-prefixed) on the real SDK, confirmed reading tinode.dev.js -- removes a
* topic from the local cache entirely, same cleanup the SDK's own `{pres what:"gone"}` handling
* uses internally. Needed for a topic that was never successfully subscribed at all (a 404 on
* first {sub}, e.g. deep-linking into an already-dissolved group) -- `getTopic()` having been
* called even once already left a bare, empty stub in the cache by then (see topics.ts's
* openConversation/getChatListSnapshot for why that stub alone is enough to show up in the chat
* list looking broken), and nothing else ever clears a topic that was never truly attached. */
cacheRemTopic(topicName: string): void;
account(userId: string, scheme: string, secret: string, login: boolean, desc?: unknown): Promise<unknown>;
createAccountBasic(
@@ -348,6 +397,27 @@ declare module 'tinode-sdk' {
* has stopped entirely (e.g. an explicit disconnect).
*/
onAutoreconnectIteration?: (timeoutMs: number, promise?: Promise<unknown>) => void;
/**
* Fires for every incoming `{ctrl}` packet, account-level ones included (verified
* tinode.dev.js:6564-6566: `if (this.onCtrlMessage) this.onCtrlMessage(pkt.ctrl)`, unconditional,
* runs before the SDK's own internal `code===205` handling). This is the ONLY hook the actual
* installed SDK (0.25.3) offers for a server-forced eviction (`code:205, text:"evicted"`) --
* there is no `onKick` callback in this version despite 强制下线通知与顶号互踢-后端接口文档.md's
* §2.4 claiming the SDK auto-stops reconnect and calls one; read the real bundle to confirm
* before relying on that doc's description of client-side behavior again.
*/
onCtrlMessage?: (ctrl: TinodeCtrl) => void;
}
export interface TinodeCtrl {
code: number;
text: string;
/** Empty/absent for an account-level `{ctrl}` (e.g. forced eviction); a topic-scoped event
* (e.g. a plain "removed from this group") carries the topic name here instead. */
topic?: string;
params?: Record<string, unknown>;
ts?: string;
id?: string;
}
export const Drafty: {
+357 -6
View File
@@ -22,6 +22,70 @@ const wiredTopics = new WeakSet<Topic>();
const previewPrefetched = new Set<string>();
const messageSnapshots = new Map<string, StoredMessage[]>();
let activeConversationTopic: string | null = null;
/** Set by ConversationView on mount/unmount -- "is the user genuinely looking at this exact
* conversation right now" (topic open AND tab visible), consumed both by lib/notifications/push.ts
* (suppress a desktop notification for something already on screen) and by the me.onPres correction
* below (don't fight the SDK's auto-read-mark for the ONE conversation actually being watched). Lives
* here rather than in push.ts because topics.ts itself now also needs to read it, and push.ts already
* depends on topics.ts -- the reverse would be a cycle. */
export function setActiveConversationTopic(topicId: string | null) {
activeConversationTopic = topicId;
}
export function getActiveConversationTopic(): string | null {
return activeConversationTopic;
}
/**
* The other party's real `usr...` id for a P2P conversation -- NOT the same as `topic.name` once a
* conversation has any real history. Real, live-confirmed bug this fixes: a P2P topic addressed by
* the peer's own `usr...` id (the only address that exists before the very first subscribe -- e.g.
* straight from a friend's profile) gets renamed by the SDK to a real, opaque `p2p...` name the
* instant `subscribe()` succeeds (see openConversation's own doc comment on this same rename). Every
* P2P conversation with actual history is addressed by this `p2p...` form from then on -- but reading
* tinode.dev.js confirms `Topic.topicType()`'s prefix table has no `p2p` entry at all (only `usr` maps
* to the P2P type), so `topic.isP2PType()`/`topic.p2pPeerDesc()` silently return false/undefined for
* every one of these topics, and code that assumed "the P2P topic name IS the peer's user id" (true
* only for the brief pre-first-subscribe window) breaks the moment that assumption stops holding --
* confirmed live: `GET /users/{topicName}/profile` 404s, `friends.find(f => f.user_id === topicName)`
* never matches, block/remark/avatar all silently target a user id that doesn't exist.
*
* Returns `undefined` (never `topic.name` as a guess) when the subscriber list isn't populated yet --
* live-confirmed this matters: on the render right after navigating straight to a `p2p...`-named
* conversation, `subscribers()` can still be empty for the one or two renders before the subscribe
* response fills it in, and returning `topic.name` as a placeholder there just fires the exact same
* doomed `GET /users/{p2p...}/profile` request this function exists to prevent, a render later. Every
* caller already guards its query with `enabled: !!peerUserId`, so `undefined` correctly makes it wait
* instead of firing once with a value it knows is wrong.
*/
export function getP2PPeerUserId(topic: Topic, myUserId: string | null | undefined): string | undefined {
if (topic.name.startsWith('usr')) return topic.name;
let peer: string | undefined;
topic.subscribers((sub) => {
if (!peer && sub.user !== myUserId) peer = sub.user;
});
return peer;
}
const confirmedReadSeq = new Map<string, number>();
/**
* This app's OWN independent record of "the user has genuinely seen up to `seq` in this topic" --
* tracked separately from the SDK's own `read`/`unread` fields because those get silently advanced by
* a mechanism this app doesn't want for a conversation that's merely open in a backgrounded tab (see
* the me.onPres override in subscribeMeTopic below for the full story). Callers: openConversation
* seeds this the moment a conversation is freshly opened (opening one always means "I've seen
* everything up to here", regardless of tab visibility, since you can only open one by clicking on it
* while looking at the page); ConversationView additionally bumps it whenever it calls noteRead() while
* actually visible.
*/
export function markConfirmedRead(topicName: string, seq: number) {
const prev = confirmedReadSeq.get(topicName) || 0;
if (seq > prev) confirmedReadSeq.set(topicName, seq);
}
function notifyTopic(topicName: string) {
messageSnapshots.delete(topicName);
topicListeners.get(topicName)?.forEach((cb) => cb());
@@ -64,6 +128,90 @@ export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicI
return () => infoListeners.delete(callback);
}
const goneListeners = new Set<(topicName: string) => void>();
const newGroupListeners = new Set<(topicName: string) => void>();
/** Fires when reconcileMeSubs() (below) discovers a group topic this client has never seen before --
* the "you were just added to a group" case (Zentao/user-reported: "被拉入群时title的消息没有提示").
* Consumed by lib/notifications/push.ts to fire a desktop notification -- can't live there itself
* since push.ts already depends on topics.ts and the reverse would be a cycle, same reasoning as
* setActiveConversationTopic living here instead of in push.ts. */
export function subscribeNewGroupDiscovered(callback: (topicName: string) => void): () => void {
newGroupListeners.add(callback);
return () => newGroupListeners.delete(callback);
}
/** Fires once a topic is confirmed gone (group dissolved by anyone, or removed from this account) --
* the SDK's own `Topic#_gone()` clears the topic's public/private/messages and calls
* `onDeleteTopic()` (confirmed reading tinode.dev.js). Without ConversationView reacting to this,
* a member who stays on a group's conversation screen while someone else dissolves it just keeps
* looking at a now-defunct view -- `onMetaDesc`/`onPres`/etc. above only invalidate cached snapshots
* for re-render, they don't tell the currently-open screen it needs to navigate away entirely. */
export function subscribeTopicGone(callback: (topicName: string) => void): () => void {
goneListeners.add(callback);
return () => goneListeners.delete(callback);
}
/**
* Topics confirmed gone that `getChatListSnapshot` must never show again THIS session, no matter what
* re-creates a cache entry for the name afterward -- live-confirmed real race: `openConversation`'s
* own cleanup (`closeConversation`, fired by ConversationView's unmount effect as the redirect below
* unmounts it) calls `getTinode().getTopic(topicName)` again for its `.leave(false)` -- unconditionally,
* with no way to know this specific name should stay forgotten -- which by itself re-creates a fresh,
* empty stub the instant `cacheRemTopic` below has removed the old one, silently undoing the cleanup
* and leaving the exact same broken sidebar row behind. Excluding by name in the chat-list filter
* itself sidesteps that ordering race entirely rather than trying to make cleanup run strictly after
* every other thing that might touch this topic's cache entry.
*/
const goneTopicNames = new Set<string>();
/**
* Shared by every "this topic is confirmed gone" path below -- native `onDeleteTopic` (a real `{pres
* what:"gone"}`, which never actually happens for THIS backend's own group dissolve -- see the
* `group.dissolved` handling in wireTopicCallbacks' `onInfo` for why) and that custom path itself both
* call this, so cache cleanup can't accidentally happen on only one of them. Removing the topic from
* the SDK's cache matters even here (not just for the 404-before-ever-subscribing case forgetTopic
* covers) -- confirmed live: a passive member watching a group when someone else dissolves it now
* correctly gets bounced to /messages the instant it happens, but the group still lingered in their
* chat list afterward (raw topic id, "1970/1/1") until this was added, same broken-stub symptom, just
* reached via the live path instead of the refresh-into-already-gone one.
*
* Skips everything (including `goneListeners`) once `goneTopicNames` already has this name -- real,
* user-reported bug: leaving a group yourself (GroupDetailColumn's 退出群聊/解散群聊, which calls
* `forgetTopic` synchronously the instant its own REST call succeeds, see below) still leaves the
* server free to ALSO deliver a native/`group.dissolved` gone-signal for that same topic a moment
* later, since from the server's point of view your own subscription really did just end too. Left
* unguarded, that late-arriving signal would re-run `goneListeners` a second time for a topic already
* handled -- showing ConversationView's "该群聊已解散" toast (wrong wording for a leave you chose
* yourself, not a dissolve) on top of whatever that action's own success handler already did.
*/
function markTopicGoneAndNotify(topicName: string) {
if (goneTopicNames.has(topicName)) return;
goneTopicNames.add(topicName);
getTinode().cacheRemTopic(topicName);
notifyTopic(topicName);
goneListeners.forEach((cb) => cb(topicName));
}
/**
* For any topic THIS client already knows is gone without needing to be told by a server push --
* `openConversation()` rejecting with a 404 (see ConversationView's `isTopicGone` handling) because it
* was deep-linked/refreshed into after already being dissolved, or GroupDetailColumn's own 退出群聊/
* 解散群聊 success handlers calling this immediately rather than waiting on the async gone-signal that
* usually follows. Doesn't go through markTopicGoneAndNotify's `goneListeners` trigger -- every caller
* already has its own richer, action-specific way to notify+navigate (a 404 catch block with the real
* `err`; a mutation's own `onSuccess`), so firing goneListeners too would just re-run that a second
* time, and for the leave/disband callers specifically, with the WRONG generic "该群聊已解散" wording.
* Adding to `goneTopicNames` here first is exactly what makes markTopicGoneAndNotify's own guard above
* effective against whatever late server push for this same topic follows.
*/
export function forgetTopic(topicName: string): void {
goneTopicNames.add(topicName);
getTinode().cacheRemTopic(topicName);
notifyTopic(topicName);
}
function wireTopicCallbacks(topic: Topic) {
if (wiredTopics.has(topic)) return;
const notify = () => notifyTopic(topic.name);
@@ -71,12 +219,45 @@ function wireTopicCallbacks(topic: Topic) {
topic.onInfo = (info) => {
notify();
if (info) infoListeners.forEach((cb) => cb(topic.name, info));
// Real, live-confirmed gap (user-reported: "被解散当时没有" [redirect] -- only a later manual
// refresh ever caught it): this backend's own group-dissolve notification (server/
// im_group_events.go's publishIMGroupRealtimeEvent) travels as a custom `{info what:"group"
// event:"group.dissolved"}` packet, NOT the native Tinode `{pres what:"gone"}` the SDK's own
// `_gone()`/`onDeleteTopic` chain below is built around. Confirmed by reading tinode.dev.js's
// `_routeInfo`: `what:"group"` matches none of its explicit switch cases (recv/read/kp/kpa/kpv/
// call), so nothing native reacts to it -- `onInfo(info)` still fires right after regardless, but
// until now nothing here recognized this specific shape either. A member who was ACTIVELY,
// PASSIVELY watching a group (never the one who dissolved it themselves -- that flow already
// navigates away on its own mutation success, which is why testing only as the dissolver always
// looked correct) got no live signal at all: only a later refresh, hitting openConversation's 404
// path (see ConversationView's isTopicGone), ever discovered it was gone. Reuses the exact same
// goneListeners subscribeTopicGone already exports, so ConversationView needs no changes at all.
// Live-confirmed follow-up bug: this path alone (unlike onDeleteTopic below) never actually
// cleared the SDK's own topic cache, so the redirect fired correctly but the dissolved group kept
// lingering in the sidebar as a broken stub ("g grpMc5uN_H6ASc 2人 1970/1/1") until markTopicGoneAndNotify
// was made to cover this path too.
const groupEvent = info as { what?: string; event?: string } | undefined;
if (groupEvent?.what === 'group' && groupEvent.event === 'group.dissolved') {
markTopicGoneAndNotify(topic.name);
}
};
topic.onMetaDesc = notify;
// Only ever actually invoked on the `me` topic (the SDK reads `me.onMetaSub`/`me.onSubsUpdated`
// specifically, regardless of which topic's subscribe triggered it -- confirmed tinode.dev.js:
// 4654-4659) -- wiring it here on every topic is a no-op for anything else, same as onSubsUpdated
// right below, which already gets this same treatment. Without this, a brand-new P2P conversation
// reached by the other user's `usr...` id (no established topic yet) never tells the chat list a
// new contact exists the instant the server assigns its real `p2p...` name on first subscribe --
// the contact stays invisible in the sidebar, and ConversationView's own `contact` lookup (peer
// name, online status) keeps missing until some UNRELATED full resync happens to pick it up.
topic.onMetaSub = notify;
topic.onSubsUpdated = notify;
topic.onPres = notify;
topic.onAuxUpdated = notify;
topic.onAllMessagesReceived = notify;
topic.onDeleteTopic = () => {
markTopicGoneAndNotify(topic.name);
};
wiredTopics.add(topic);
}
@@ -109,6 +290,37 @@ export function subscribeMeTopic(): Promise<unknown> {
// doesn't touch it. Without this, the chat list's unread badge/ordering goes stale after reading
// a conversation until some unrelated topic event happens to invalidate its cached snapshot.
me.onContactUpdate = () => notifyTopic(me.name);
// Real, live-confirmed bug (2026-09-16, reported directly): reading tinode-sdk's source, TopicMe's
// own `_routePres` handles a `{pres what:"msg"}` push -- the mechanism that updates a topic's unread
// count even when you're not actively viewing it -- by calling `cont._updateReceived(pres.seq,
// pres.act)`, which unconditionally sets `read = seq` (marks it AS READ) whenever `act` is falsy or
// refers to yourself. A conversation that's simply left OPEN (ConversationView mounted, topic
// actively subscribed) -- even sitting in a fully backgrounded tab nobody is looking at -- still gets
// this same presence push, and gets silently auto-marked read regardless of tab visibility: confirmed
// live, a message sent to a conversation left open in a backgrounded tab never bumped the unread
// count at all, defeating the tab-title/favicon/notification indicators this same session just built
// (ConversationView's own visibility-gated noteRead() call -- see its comment -- only prevents ITS
// OWN explicit read-marking; it has no way to intercept this separate, lower-level SDK mechanism).
// markConfirmedRead (see its own comment) is this app's independent record of when the user genuinely
// saw a topic. Right after the SDK's own (for this app's purposes, wrong) auto-mark runs, correct it
// back down to that record for any topic that isn't the one actually being looked at right now.
me.onPres = (pres: unknown) => {
notifyTopic(me.name);
const p = pres as { what?: string; src?: string } | undefined;
if (p?.what !== 'msg' || typeof p.src !== 'string') return;
const topicName = p.src;
const isBeingViewed = topicName === activeConversationTopic && document.visibilityState === 'visible';
if (isBeingViewed) return;
// Cast for the same reason getChatListSnapshot does -- read/unread/seq are real fields the SDK
// sets directly on the Topic instance at runtime, just not declared on this hand-maintained .d.ts.
const topic = getTinode().getTopic(topicName) as unknown as Contact;
const correctRead = confirmedReadSeq.get(topicName) || 0;
if ((topic.read || 0) > correctRead) {
topic.read = correctRead;
topic.unread = Math.max(0, (topic.seq || 0) - correctRead);
notifyTopic(topicName);
}
};
inFlightMeSubscribe = me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build()).finally(() => {
inFlightMeSubscribe = null;
});
@@ -192,6 +404,16 @@ export function openConversation(topicName: string): Promise<Topic> {
}
const query = topic.startMetaQuery().withLaterDesc().withLaterSub().withAux().withData(undefined, undefined, MESSAGES_PAGE);
await topic.subscribe(query.build());
// Opening a conversation via a normal SPA click always means "I've seen everything up to here" --
// you can only click one while looking at the page. That assumption breaks for a direct reload or
// deep link straight to a conversation's URL in a tab that's sitting BACKGROUNDED the whole time
// (confirmed live: reloading tab-1 mid-test while it stayed the non-frontmost tab landed here with
// the tab genuinely hidden) -- gated on visibility so that case correctly leaves any
// already-unread history alone for the me.onPres correction above to keep reporting as unread,
// instead of this seeding call silently absolving it the instant the route mounts.
if (document.visibilityState === 'visible') {
markConfirmedRead(topicName, (topic as unknown as Contact).seq || 0);
}
return topic;
})();
inFlightSubscribe.set(topicName, promise);
@@ -244,22 +466,50 @@ export function ensureTopicAttached(topicName: string): Promise<Topic> {
let chatListSnapshot: Contact[] | null = null;
/**
* Real, addressable chat-list topic types this backend actually issues -- deliberately NOT using
* `Topic#isCommType()`/`isP2PType()` (confirmed live by reading tinode-sdk's own `Topic.topicType()`
* table: it maps prefix `'usr'` to P2P, with NO entry for `'p2p'` at all). That table is right for a
* peer ALIAS (`usr<peerId>`, used before a P2P topic has ever been subscribed), but this backend's
* REAL, server-assigned P2P topic name is `p2p<hash>` (confirmed via raw `{meta topic:"me"}` capture:
* every subscription the server reports uses this form, never the alias) -- so `isCommType()` returns
* false for every P2P topic once its real name is known, silently filtering ALL of them out of
* `MeTopic.contacts()`. This has nothing to do with account age or how a conversation was opened --
* verified by direct fresh-login inspection that even a heavily-used account with many real, working
* P2P friendships had ZERO P2P entries in `contacts()`; every P2P row the chat list has ever shown
* this session was actually the special-cased `slf` self-chat entry.
*/
function isChatListTopicName(name: string): boolean {
return name.startsWith('p2p') || name.startsWith('grp') || name === 'slf';
}
/**
* Cached the same way getMessagesSnapshot is, but invalidated by ANY topic's change (a new message
* on a contact that isn't currently open still needs to move that row and refresh its preview).
* Triggers each contact's one-shot preview prefetch as a side effect of being read -- cheap and
* idempotent (prefetchPreview no-ops after the first call per topic), so it's fine to do this on
* every rebuild rather than needing a separate "wire up previews" pass.
*
* Uses `Tinode#mapTopics` (every locally-cached topic, unfiltered) + `isChatListTopicName` instead of
* `MeTopic#contacts()` -- see that function's own comment for why `contacts()`'s built-in filter can't
* be trusted here. `mapTopics` still only sees topics the SDK has locally cached, which `me`'s own
* subscribe (`subscribeMeTopic`'s `withLaterSub()`) and any direct `openConversation()` both populate.
*/
export function getChatListSnapshot(): Contact[] {
if (chatListSnapshot) return chatListSnapshot;
const byTopic = new Map<string, Contact>();
getTinode()
.getMeTopic()
.contacts((contact) => {
if (!contact.topic) return;
prefetchPreview(contact.topic);
byTopic.set(contact.topic, contact);
getTinode().mapTopics((topic) => {
if (!topic.name || !isChatListTopicName(topic.name) || goneTopicNames.has(topic.name)) return;
prefetchPreview(topic.name);
// `.topic` (what every consumer of this list actually reads) normally arrives via
// `_processMetaSubs` merging the server's own `sub.topic` field onto this same cached instance --
// but a brand-new P2P topic reached through `openConversation()` is cached here the moment its
// OWN subscribe resolves, potentially before `me`'s subscription list has ever reported it. Set
// explicitly rather than assuming that merge already happened; kept as the same live instance
// (not a clone) so prototype methods like `latestMessage()` survive.
const contact = topic as unknown as Contact;
contact.topic = topic.name;
byTopic.set(topic.name, contact);
});
const rows = [...byTopic.values()];
chatListSnapshot = rows;
@@ -301,3 +551,104 @@ onReauthenticated(() => {
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {});
}
});
const ME_RECONCILE_INTERVAL_MS = 30_000;
let meReconcileTimer: ReturnType<typeof setInterval> | null = null;
/**
* Best-effort mitigation for a real backend gap that no amount of frontend-only code can fully close:
* confirmed by reading im_be's server/im_group_events.go, EVERY group lifecycle event (created, member
* added/removed, dissolved, owner transferred, ...) goes through publishIMGroupRealtimeEvent, which
* only reaches sessions ALREADY subscribed to that specific group topic. Unlike P2P -- which has a
* dedicated force-delivery path straight to a peer's session regardless of subscription state
* (deliverIMConversationSystemMessageToP2PSessions) -- groups have no equivalent, so a member who has
* never opened a given group gets zero live signal when added to it: no chat-list row, no unread badge,
* nothing, indefinitely, until something re-syncs. Confirmed live (2026-09-16): a brand-new group with a
* genuinely passive, fully-connected, already-on-/messages member produced no change at all, even
* several seconds after a real message was sent into it.
*
* There is nothing to subscribe to or react to for a topic this client doesn't know exists yet, so the
* only thing a client can do is periodically ask "what's new" -- reusing the exact same incremental
* `withLaterSub()` semantics subscribeMeTopic()/onReauthenticated() above already rely on, just issued
* via `getMeta()` instead of `subscribe()`. `Topic.subscribe()` short-circuits once `_attached` is
* already true (the normal, undisturbed state this is guarding, unlike the reconnect case above), but
* `getMeta()` sends the `{get}` regardless of attach state -- the same distinction ConversationView's
* own P2P desc/presence fix relies on.
*/
function reconcileMeSubs() {
const me = getTinode().getMeTopic();
if (!me.isSubscribed()) return;
// Snapshot every currently-known topic's read-state fields before the fetch -- see the .then()
// below for why the response can't be trusted at face value for topics already tracked (only for
// genuinely new ones, which is this mitigation's entire reason to exist).
const before = new Map<string, { seq: number; read: number; recv: number; unread: number }>();
for (const c of getChatListSnapshot()) {
if (c.topic) before.set(c.topic, { seq: c.seq || 0, read: c.read || 0, recv: c.recv || 0, unread: c.unread || 0 });
}
me.getMeta(me.startMetaQuery().withLaterSub().build())
.then(() => {
// Real, live-confirmed bug (2026-09-16, reported directly): tinode-sdk's own merge utility
// (mergeObj in utils.js) has a defensive "keep the later value" rule for Date fields but NONE
// for plain numbers -- seq/read/recv/unread just get blindly overwritten by whatever this
// fetch's response says, confirmed by reading the source. If the server's view of a sub's read
// cursor is even momentarily behind a `{note read}` the client already sent (e.g. this timer or
// a focus/visibility trigger firing shortly after the user opened and read a conversation), this
// silently un-reads it: the badge reappears with no new message behind it, and re-focusing the
// window doesn't clear it either since nothing about it actually changed to re-trigger a fresh
// read-mark -- confirmed reproducing exactly like this live, not a hypothetical race. Since plain
// numbers can't defend themselves the way Dates do, restore anything that regressed for topics
// already known BEFORE this fetch started, rather than trust the fetch there -- this mitigation
// only needs to win for topics that were unknown before it (the actual gap it exists to close),
// not overwrite ones a user has already read in the meantime.
for (const c of getChatListSnapshot()) {
if (!c.topic) continue;
const prev = before.get(c.topic);
if (!prev) {
// Brand-new topic to this session -- exactly what this mitigation is for. A fresh member's
// sub record can legitimately come back "already caught up" (read === seq) from the
// server's own default, which would otherwise leave being newly added to a group producing
// NO unread signal at all whenever it happens to have zero messages so far -- real,
// reported UX gap ("被拉入群时title的消息没有提示"): the tab-title/favicon-badge (see lib/
// notifications/tabIndicator.ts) are driven by this same unread count, so nothing tells the
// user anything happened. Force it to read as genuinely unread from this client's own
// perspective regardless of what the server defaulted it to.
c.read = 0;
c.unread = c.seq || 0;
notifyTopic(c.topic);
if (c.topic.startsWith('grp')) newGroupListeners.forEach((cb) => cb(c.topic!));
continue;
}
const seq = c.seq || 0;
const unread = c.unread || 0;
if (seq <= prev.seq && unread > prev.unread) {
c.read = prev.read;
c.recv = prev.recv;
c.unread = prev.unread;
notifyTopic(c.topic);
}
}
})
.catch(() => {});
}
/**
* Starts the periodic timer plus tab-focus/visibility triggers (the common "switched away and came
* back" case gets an immediate catch-up instead of waiting for the next tick). Call once per
* authenticated session (RequireAuth.tsx) and invoke the returned function on logout/unmount -- safe
* to start/stop repeatedly (React 19 StrictMode's mount->cleanup->remount included), unlike
* subscribeMeTopic() itself, which is guarded separately against being re-fired.
*/
export function startMeReconciliation(): () => void {
meReconcileTimer = setInterval(reconcileMeSubs, ME_RECONCILE_INTERVAL_MS);
const onVisible = () => {
if (document.visibilityState === 'visible') reconcileMeSubs();
};
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', reconcileMeSubs);
return () => {
if (meReconcileTimer) clearInterval(meReconcileTimer);
meReconcileTimer = null;
document.removeEventListener('visibilitychange', onVisible);
window.removeEventListener('focus', reconcileMeSubs);
};
}
+10
View File
@@ -3,6 +3,16 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
// This app has no service worker of its own, but browsers that visited this origin before the
// redesign/desktop-multicolumn rewrite may still have the old one registered (see
// public/service-worker.js) -- force an immediate update check rather than waiting for the
// browser's own lazy schedule, so it gets replaced by the kill switch right away.
if ('serviceWorker' in navigator) {
navigator.serviceWorker.getRegistrations().then(regs => {
regs.forEach(reg => reg.update())
})
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
+21 -7
View File
@@ -10,7 +10,7 @@ import {
} from '@/lib/calls/agora-client';
import { callContentOf, callStateOf, sendCallAccept, sendCallHangup, sendCallInvite, sendCallRinging, type CallWebrtcState } from '@/lib/tinode/calls';
import { getTinode } from '@/lib/tinode/connection';
import { getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicInfo } from '@/lib/tinode/topics';
import { getChatListSnapshot, getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicInfo } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
export type CallPhase = 'idle' | 'outgoing' | 'incoming' | 'connected' | 'ended';
@@ -152,7 +152,12 @@ export const useCallStore = create<CallState>((set, get) => ({
hangUp: () => {
const { topic, seq, callId, connectedAt } = get();
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId);
// `rejectCall()` right above already covers the callee-declines-before-accepting case, so
// `hangUp()` seeing `!connectedAt` only ever means the CALLER is giving up on their own
// still-ringing outgoing call (manual cancel, or the 60s ring-timeout below firing this same
// function) -- IM-前后端对接文档.md's own wire example for exactly that case is
// `{note event:"hang-up", payload:{reason:"cancelled"}}`, which this never actually sent.
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId, connectedAt ? undefined : 'cancelled');
leaveCall();
get()._endWithReason(connectedAt ? 'finished' : 'missed');
},
@@ -238,12 +243,21 @@ export function initCallSignaling() {
subscribeAnyTopicChange(() => {
const myUserId = useSessionStore.getState().myUserId;
if (!myUserId) return;
// NOT `getTinode().getMeTopic().contacts()` -- live-confirmed real bug this fixes (user-reported:
// a whole incoming-call test milestone blocked, WS packet capture confirmed the invite genuinely
// arrives, but no incoming-call UI ever appeared): `contacts()` reads the `me` topic's OWN
// internally-tracked subscription list, populated only once `me`'s `{meta sub}` sync reports this
// specific contact -- but a P2P topic can be fully subscribed and sitting in the SDK's topic cache
// (able to receive and store a real incoming call invite) well before that sync catches up. This
// exact gap is already documented on `getChatListSnapshot`'s own comment (topics.ts) as the reason
// that function uses `mapTopics` instead -- this call-signaling listener just never got the same
// fix when it was written. Confirmed live: a real two-account call, `me.contacts()` on the callee's
// own live session enumerated ONE contact (a group) and silently omitted the caller entirely, while
// `getChatListSnapshot()`'s own list correctly showed both.
const peerNames = new Map<string, string>();
getTinode()
.getMeTopic()
.contacts((c) => {
if (c.topic) peerNames.set(c.topic, c.public?.fn || c.topic);
});
for (const contact of getChatListSnapshot()) {
if (contact.topic) peerNames.set(contact.topic, contact.public?.fn || contact.topic);
}
for (const topicName of peerNames.keys()) {
const messages = getMessagesSnapshot(topicName);
+48
View File
@@ -0,0 +1,48 @@
import { create } from 'zustand';
/**
* Replaces `window.confirm()` app-wide (README's own design language never intended a native
* browser dialog to show up inside this UI -- it was just the fastest way to gate a destructive
* action while the real dialog didn't exist yet). A global store + a single `<ConfirmDialogRoot>`
* mounted once at the app root (see App.tsx), not a local `useState` per call site: this needs to be
* callable from any event handler, including ones with no natural "own a piece of JSX for this"
* home (e.g. GroupFilesModal's per-row delete button), the same reasoning `stores/calls.ts`/
* `stores/messageJump.ts` already established for other "any component, any time" actions.
*
* `requestConfirm()` returns a Promise the same shape `window.confirm()`'s return value has (resolves
* true/false, never rejects) so every existing `if (!window.confirm(...)) return;` call site becomes
* `if (!(await requestConfirm({ message: ... }))) return;` with the enclosing function marked async --
* a mechanical, low-risk edit, not a rewrite of the surrounding logic.
*/
export interface ConfirmOptions {
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
/** Red confirm button for irreversible/destructive actions (delete, disband, deregister) --
* omitted (default blue) for merely consequential ones (log out, switch tenant). */
danger?: boolean;
}
interface PendingConfirm extends ConfirmOptions {
resolve: (value: boolean) => void;
}
interface ConfirmDialogState {
pending: PendingConfirm | null;
respond: (value: boolean) => void;
}
export const useConfirmDialogStore = create<ConfirmDialogState>((set, get) => ({
pending: null,
respond: (value) => {
get().pending?.resolve(value);
set({ pending: null });
},
}));
export function requestConfirm(options: ConfirmOptions): Promise<boolean> {
return new Promise((resolve) => {
useConfirmDialogStore.setState({ pending: { ...options, resolve } });
});
}
+37
View File
@@ -0,0 +1,37 @@
import { create } from 'zustand';
/**
* Replaces `window.alert()` app-wide, same rationale as `stores/confirmDialog.ts`'s replacement of
* `window.confirm()`: a global store + a single `<GlobalToastRoot>` mounted once at the app root
* renders the app's own existing `Toast` component (already used everywhere for a mutation's error
* state) instead of the browser's native dialog. `notify()` is deliberately fire-and-forget (no
* Promise, no return value) -- an alert-style message never blocked on a user response the way
* confirm did, so there's nothing to await.
*/
interface GlobalToastState {
message: string;
variant: 'success' | 'error';
token: number;
}
export const useGlobalToastStore = create<GlobalToastState>(() => ({
message: '',
variant: 'error',
token: 0,
}));
export function notify(message: string, variant: 'success' | 'error' = 'error') {
// `token` (not just `message`) is what Toast's own auto-dismiss effect keys off of via the caller
// -- see GlobalToastRoot -- so firing the SAME message twice in a row (e.g. two clicks on a
// still-disabled "该功能暂未开放" button before the first toast dismisses) restarts the timer
// instead of the second call being a no-op change that React skips re-rendering for.
useGlobalToastStore.setState((s) => ({ message, variant, token: s.token + 1 }));
}
/** Every existing `window.alert('该功能暂未开放,如需联系客服。')` call site (settings, composer
* toolbar, add-friend, group/contact detail panels) used the identical hand-typed Chinese string --
* one shared helper instead of that string living independently in five files that could each drift
* from the others if the wording ever needs to change. */
export function notImplemented() {
notify('该功能暂未开放,如需联系客服。');
}
+20
View File
@@ -0,0 +1,20 @@
import { create } from 'zustand';
/**
* Transient cross-component instruction: `lib/notifications/push.ts` lives outside React (it reacts
* to raw SDK topic events, same as topics.ts itself) and has no way to call `useNavigate()` directly
* when a desktop Notification is clicked. Matches this codebase's established pattern for exactly
* this shape of problem (stores/calls.ts, stores/messageJump.ts) rather than threading a navigate
* callback down into a non-React module.
*/
interface NotificationNavState {
pendingTopic: string | null;
requestNavigate: (topic: string) => void;
clear: () => void;
}
export const useNotificationNavStore = create<NotificationNavState>((set) => ({
pendingTopic: null,
requestNavigate: (topic) => set({ pendingTopic: topic }),
clear: () => set({ pendingTopic: null }),
}));
+39
View File
@@ -0,0 +1,39 @@
import { create } from 'zustand';
import type { SecurityChallengeScene } from '@/lib/api/security';
/**
* Prompts for the protocol's sensitive-action SMS re-verification (docs/IM-前后端对接文档.md:762-766)
* and resolves with the resulting `action_token`, or `null` if the user cancels -- same Promise
* shape/global-store-plus-single-root-component pattern as `confirmDialog.ts`'s `requestConfirm`, for
* the same reason: this needs to be callable from a mutation's `mutationFn` (GroupDetailColumn's
* dissolve flow, currently the only caller) with no natural "own a piece of JSX for this" home.
*/
export interface SecurityChallengeOptions {
scene: SecurityChallengeScene;
/** What's about to happen if the user completes verification -- e.g. "解散群聊「群名」". */
actionDescription: string;
}
interface PendingChallenge extends SecurityChallengeOptions {
resolve: (token: string | null) => void;
}
interface SecurityChallengeState {
pending: PendingChallenge | null;
respond: (token: string | null) => void;
}
export const useSecurityChallengeStore = create<SecurityChallengeState>((set, get) => ({
pending: null,
respond: (token) => {
get().pending?.resolve(token);
set({ pending: null });
},
}));
export function requestActionToken(options: SecurityChallengeOptions): Promise<string | null> {
return new Promise((resolve) => {
useSecurityChallengeStore.setState({ pending: { ...options, resolve } });
});
}
+1 -1
View File
@@ -18,7 +18,7 @@ export default defineConfig({
__APP_VERSION__: JSON.stringify(pkg.version),
},
server: {
port: 8094,
port: process.env.PORT ? Number(process.env.PORT) : 8094,
// Same-origin dev proxy for the REST layer only -- avoids CORS entirely (the browser only ever
// sees http://localhost:8094) so a resolved tenant's *relative* endpoints ("/v1/im", "/v0/file")
// work unmodified in local dev, same idea as the old app's hand-rolled dev-proxy-server.js, just