Commit Graph
100 Commits
Author SHA1 Message Date
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
Your NameandClaude Sonnet 5 e3217a5de9 fix(web): 转发选择器好友/群聊分开全选
原来是列表整体一个全选按钮,选中后好友和群聊会一起被勾上。改成
好友、群聊两个分组各自独立的全选/取消全选,互不影响,方便只想
批量选中其中一类的场景(比如只发给所有群,不带上私聊好友)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:15:29 +08:00
Your NameandClaude Sonnet 5 46b3288880 feat(web): 下线自主注册与协议勾选,修复转发消息报错,转发选择器分组+全选
按产品决定移除面向国内客户不适用的自主注册入口:删除 /register 路由
及 RegisterPage、登录页"马上注册"链接、登录/注册共用的协议勾选组件
Agreement,以及仅注册流程使用的 registerAccount/queueAccExtra 等
死代码;登录不再依赖勾选协议才能提交。

修复转发消息报错(真实高频bug,不限于个别场景):`forwardMessage`/
`sendText` 发送前只是 `tinode.getTopic()` 取一个本地话题对象,从未
真正订阅——SDK 要求话题处于 attached 状态才允许发布,而只有当前正
打开的会话才满足这一点,一旦切换过/离开过某个会话(哪怕之前订阅过
预览),它就会变回未订阅状态。也就是说转发给"当前会话之外"几乎任何
一个联系人都会失败。新增 `ensureTopicAttached()`,在转发前按需临时
订阅目标话题,单条转发和多选批量转发两处都已接入,实机验证对未打开
过的好友/群转发均成功。

转发选择器(ForwardPickerModal)按好友/群聊分组显示(原来混在一个
列表里),并加了"全选/取消全选",避免多个接收方时逐条点选。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:04:59 +08:00
Your NameandClaude Sonnet 5 1826d2906f feat(web): 会话头像/名称可点击展开详情栏,移除"关于 MeetingIM"设置页
聊天窗口头部的头像和昵称此前只是静态展示,只有右侧"i"图标能展开
详情栏;现在点击头像或名称同样会触发 onToggleDetail,单聊群聊均
生效。

按产品决定移除设置里的"关于 MeetingIM"页面(桌面目录、移动端"我的"
二级设置列表两处入口一并去掉),该页面唯一内容是版本号和一个后端
本就不支持 platform=web 的检查更新按钮,删除后 AboutPage.tsx 和
仅被它使用的 lib/api/app.ts 一并清理为死代码。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:06:46 +08:00
Your NameandClaude Sonnet 5 9271a0a9c1 fix(web): 隐藏文件助手入口,修复自聊天详情栏错误操作
后端目前没有支持文件助手所需的配额/分类等能力,只是把它当作
普通话题处理;与其做一个功能不全的界面,先从导航栏、会话列表
置顶区、通讯录固定入口、设置页移动端快捷入口中移除该入口。slf
话题现在按普通会话行为处理(排序、未读计数均不再特殊处理)。

同时修复 DetailColumn 对自聊天(slf)显示语音/视频通话、备注、
拉黑、举报、删除好友等对自己无意义操作的问题,改为仅显示适用
的发消息/查找聊天记录/置顶/免打扰/清空聊天记录等项。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 23:35:58 +08:00
Your NameandClaude Sonnet 5 3065e232be fix(web): favicon didn't match the app's real logo mark
public/favicon.svg was a leftover scaffold placeholder -- a generic purple
gradient blob, unrelated to this app's actual brand mark (a plain #1677ff
rounded square with a white "M", as literally rendered on the login/
tenant-entry screens: `bg-accent` + bold white "M"). Replaced it with an
SVG matching that exact design so the browser tab icon is recognizably the
same mark as the rest of the app.

Also removed public/icons.svg while in here -- a sprite sheet of generic
social-media icons (bluesky/discord/github/x) from the same leftover
scaffolding, confirmed unreferenced anywhere in the app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:18:30 +08:00
Your NameandClaude Sonnet 5 d1c81d432e fix(web): 文件助手 duplicated as its own chat-list row once slf has real history
文件助手 is a permanent pinned entry that redirects into a real conversation on
the slf topic (see PINNED_ENTRIES's own doc comment -- there's no dedicated
file-browser API to back a real one). Once that topic has any message
history it also comes back from useChatList() as an ordinary contact, so it
rendered a second time as its own chronologically-sorted row further down --
confirmed live: navigating to /messages/slf via either row satisfied both
rows' own "am I active" check at once, highlighting both simultaneously.

Filters the slf topic out of the ordinary contacts list; 文件助手 stays the
one and only way it's represented there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 22:01:50 +08:00
Your NameandClaude Sonnet 5 db0f8e4543 fix(deploy): build/serve the new web/ Vite app instead of the old webpack build
The deploy pipeline (.gitea/workflows/deploy.yml -> Dockerfile) never got
updated when the frontend was rewritten under web/ -- it was still doing
`npm ci`/`npm run build` at the repo root and copying the old app's umd/,
css/, index.html, etc. into the nginx image. Every deploy therefore kept
shipping the old app regardless of what was actually on dev, no matter how
much of the rewrite had landed.

Dockerfile now builds from web/ (Vite) and copies its self-contained
dist/ output (index.html + content-hashed assets/, no separate umd/css/
img/audio/manifest/service-worker/firebase files -- none of that exists
in the new app). VITE_TINODE_API_KEY is what src/lib/config.ts actually
reads (Vite only exposes VITE_-prefixed env vars to client code), so the
existing TINODE_API_KEY build-arg is now renamed into that on its way
into the build environment instead of passed through under its old name.

nginx.conf updated to match: index.html stays no-cache (its URL never
changes, so a stale cached copy would keep pointing at deleted asset
filenames after a deploy), assets/ gets a long immutable cache (Vite
content-hashes every filename there, so a new build never reuses an old
URL). Dropped the old umd/ and img/ location blocks, neither exists in
the new build.

Verified locally: `docker build` succeeds, the container serves the new
app's real index.html/assets with the intended cache headers, and an
unknown client-side route (/messages/slf) correctly falls back to
index.html.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:56:21 +08:00
Your NameandClaude Sonnet 5 5c3f3b7653 feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 20:16:33 +08:00
Your NameandClaude Sonnet 5 72b7b79caf fix(web): code review round 3 -- UI bugs and remaining design fidelity
- FileAttachment never surfaced a failed authenticated download -- the
  card looked identically clickable whether it worked or not, and
  clicking while broken silently no-op'd. Added an error state/retry
  (useAuthedBlobUrl gained a retry() escape hatch for this).
  ImageAttachment/AudioAttachment already handled this; only the file
  card didn't.
- --shadow-btn was one token serving two different specs: round 2's fix
  (0 6px 16px, from the 50px auth CTAs) actually regressed the 36px
  messaging send button (ConversationView/VoiceRecorder), which needs
  0 4px 12px per 02-messages.dc.html. Split into --shadow-btn /
  --shadow-btn-sm.
- loadOlder's scroll-restore guard compared topicId by value, which
  false-positives on an A->B->A round trip within one request's
  lifetime (same string, but the DOM was torn down and rebuilt by the
  intervening open/close). Switched to a generation counter, same
  pattern as topics.ts's leave-race fix.
- errorToast wasn't included in ConversationView/DetailColumn's
  topicId-reset effects -- a failed action's toast could keep showing
  over a conversation/contact switched to afterward. Also switched
  those effects (and MessagesLayout's) from useEffect to
  useLayoutEffect -- since none of these components remount on
  topicId change, a post-paint effect could let one frame render with
  the previous topicId's stale UI still showing.
- VoiceRecorder never listened for MediaRecorder's own onerror -- a
  mid-recording failure left the "录音中" timer ticking forever with no
  indication the recording had already died.
- VerificationCodeButton's phone-change countdown reset (round 2) only
  cleared the button's own state; RegisterPage/ResetPasswordPage's
  challengeId was still tied to whatever phone the code was actually
  issued for, with nothing cross-checking it against a since-edited
  phone number.
- Agreement's checkbox used bare accent-color on a native input, which
  only tints the checked fill and leaves size/radius/border to the
  browser/OS; replaced with the spec's exact 16x16 border-radius:4px
  box + check glyph.
- Added a catch-all route (App.tsx), TenantFeedbackPage image-picker
  validation feedback + corrected remove-button style, and a handful of
  smaller token/spacing corrections against screens/*.dc.html (NavRail
  font-weight, LoginPage/TenantEntryPage logo size+shadow+color,
  ChatListColumn row padding/icon size, --shadow-modal alpha).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 05:27:41 +08:00
Your NameandClaude Sonnet 5 8ec74dffcc fix(web): code review round 3 -- surviving races and a shape bug
Round 2's dedup fixes for connect()/openConversation left two gaps that
only showed up under closer scrutiny (and, for the first one, actual
live reproduction):

- closeConversation() awaited the shared in-flight subscribe promise
  before calling leave(), but didn't check whether a NEWER
  openConversation() had since claimed the same topic while it was
  waiting -- under React 19 StrictMode's mount->cleanup->remount, the
  stale cleanup's leave() would detach the subscription the surviving
  mount had just established, silently killing live updates for
  whatever conversation you'd just opened. Fixed with a per-topic
  generation counter: closeConversation only actually leaves if no
  newer open has since claimed the topic.
- prefetchPreview (chat-list preview subscribe) never joined the same
  in-flight tracking as openConversation, so opening a conversation
  while its own preview subscribe was still resolving could still send
  two overlapping {sub} packets at the same topic.
- resetTinode() didn't clear connectPromise/restorePromise or
  disconnect a connection that was still mid-handshake (only guarded on
  isConnected()) -- switching tenants while the old connection was slow
  to respond could leave a new login silently waiting on the abandoned
  old connection's outcome.
- subscribeMeTopic() got the same in-flight dedup as the others, for
  consistency (no concrete concurrent caller today, cheap insurance
  against a future one).

Also:
- getBlocks()'s response type was a flat Friend shape; the real
  contract (confirmed against both the protocol doc's JSON example and
  the old app's own consumer) nests the user under `.user`, so "already
  blocked" state in DetailColumn was silently always false.
- useBootstrap() had no tenant in its query key and staleTime:Infinity,
  so switching tenants without a full reload kept serving the PREVIOUS
  tenant's message_policy/features/permissions/security forever.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 05:27:16 +08:00
Your NameandClaude Sonnet 5 c9f4615c40 fix(web): code review round 2 -- messaging UI bugs and design fidelity
- DetailColumn: remark-edit draft and the history-search modal's open
  state weren't reset on contact switch (no key={topicId}, same long-
  lived instance) -- could silently save a typed remark against the
  WRONG contact. History-search state lifted to MessagesLayout so
  ConversationView's header search icon (previously wired to nothing)
  can open the same modal
- ConversationView: loadOlder's in-flight "restore scroll position"
  callback and its loading flag weren't guarded against a conversation
  switch happening mid-request, and the scroll-to-bottom effect keyed
  only on messages.length so two conversations that happened to load
  the same page size never got scrolled down on open
- ChatListColumn: pinned 系统消息/文件助手 rows used solid-color-square
  avatars like regular contacts; the actual design-code source
  (ChatList.dc.html) and reference screenshots use a light tinted
  background with a colored icon instead
- MessageBubble: own ("我") messages hash-colored their avatar like any
  contact instead of the design's fixed accent blue
- Every send/recall/favorite/forward action swallowed its own failure
  silently (empty .catch or, for forward, no catch at all) -- moved
  Toast from features/auth into app/ (shared) and wired real error
  feedback through it
- MessageContextMenu: added the recall countdown label, Escape-to-close,
  and viewport-edge clamping (a right-click near the window edge could
  render partially off-screen)
- EmojiPanel now defaults to 最近 per the reference (was defaulting to
  表情)
- HistorySearchModal: pagination (searchTopicMessages/getTopicMedia both
  supported cursor/next_cursor already, nothing used it -- silently
  truncated at one page), Escape-to-close, and a brief "没有找到" flash
  on the first keystroke before the debounce caught up
- A handful of design-token corrections (--shadow-btn, NavRail's border/
  spacing, LoginPage's logo radius/brand-panel padding, a placeholder
  icon color) verified against screens/*.dc.html's exact pixel values

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 04:48:53 +08:00
Your NameandClaude Sonnet 5 d3a0eb0dbc fix(web): code review round 2 -- critical session/connection races
- RequireAuth: a page reload lost the session even with a valid token
  sitting in sessionStorage -- myUserId is never persisted (by design)
  but nothing ever re-derived it from the reusable login token. Added
  connection.ts's restoreSession(), which also fixes the race the first
  attempt at this ran into live: React 19 StrictMode double-invokes the
  restore effect in dev, and Tinode.loginToken() isn't idempotent (second
  call on an already-authenticated connection gets 409'd), so both the
  connect() and the login itself needed module-level in-flight dedup,
  not just a per-effect cancelled flag
- topics.ts: same dedup pattern for openConversation/closeConversation --
  StrictMode's mount->cleanup->remount always overlapped an open with
  itself, and a leave() landing before its own subscribe() resolved was
  silently no-op'ing instead of detaching
- LOGGING_ENABLED was unconditionally on, including prod builds --
  Tinode's verbose logging prints the raw login secret and full message
  content to the console
- imPost/imPatch/imPut/imDelete could silently drop the Idempotency-Key
  header for a caller that passes its own headers (object-spread order
  bug, dormant today since no call site does this yet)
- DetailColumn's getFriends()/getBlocks() only ever read page 1 despite
  fetchAllPages existing specifically to prevent this bug class
- availableActions() now defers to the server's message_policy.
  supported_actions when present instead of only a client-side time
  window, and exposes recallRemainingSeconds for the context menu's
  countdown label

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 04:48:28 +08:00
Your NameandClaude Sonnet 5 1bd8d7f804 fix(web): history search used response shapes the real server doesn't send
Confirmed live against the real backend: docs/IM-前后端对接文档.md's flat
`/media` items ({thumbnail_file_id,file_id,width,height}) and flat search
hits ({file:{...}}, {url,title,...}) don't match what the server actually
returns. Both endpoints really return the raw original message (`{txt,fmt,
ent}`, search hits wrapped one level deeper in `snapshot`) -- the same shape
lib/messages/drafty.tsx's renderDrafty already knows how to render, reused
here instead of hand-picking fields from a shape that doesn't exist. This is
also why image/video thumbnails were rendering as generic icons: the field
they were reading was never present in a real response.

Extracted useAuthedBlobUrl (authenticated file-fetch-to-blob-URL) out of
AttachmentPreview into lib/hooks.ts so the history modal's own thumbnail
grid can reuse it instead of duplicating the fetch/header logic.

Also adds the URL safety-check gate (docs/IM-前后端对接文档.md:1822,1898)
before opening a link found via the 链接 tab, which this view didn't have
at all before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:58:22 +08:00
Your NameandClaude Sonnet 5 93d4077d1e fix(web): code review round 1 remaining UI fixes
- EmojiPanel: add missing 手势/物品 categories, 12-column grid per README §3.3
- DetailColumn: add 投诉举报 row, real ID/region/签名 via new getUserProfile
  API, chevron_right icons now use --color-icon-arrow token (were tertiary
  text color), regroup rows so dividers only sit between sections not every
  row (matches design-code reference exactly)
- MessageBubble/ConversationView: right-clicked message bubble now gets the
  spec's outline while its context menu is open
- ChatListColumn/AppShell: pinned 系统消息/文件助手 rows using confirmed
  bootstrap entries/entry_badges topic names ("sys"/"slf"); route to the
  existing SectionPlaceholder pattern since their own dedicated layouts are
  Phase 8, not built yet

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:58:06 +08:00
Your NameandClaude Sonnet 5 a7da4dc079 fix(web): code review round 1 -- messaging UI critical/high findings
- CRITICAL: draft text and voice-recording state were never isolated per
  conversation. ConversationView is one long-lived instance reused across
  every topic (`:topicId` route param change doesn't remount it), so a
  single `draft` string leaked one conversation's unsent text into whatever
  conversation was opened next -- and, far more seriously, starting a
  voice recording in conversation A and then switching to conversation B
  before hitting send would publish that recording into B: VoiceRecorder
  keeps its own MediaRecorder running across the switch, but the onSend
  callback passed to it is a fresh closure over whatever topic is CURRENT
  when it finally fires. Fixed by keying drafts per topicId and force-
  resetting `recording` (unmounting VoiceRecorder, which stops its stream)
  whenever topicId changes.
- CRITICAL: the detail/settings panel was completely unreachable below
  960px -- it only ever rendered inside `lg:block` (>=1280px, docked) or
  `md:block lg:hidden` (960-1279px, drawer) wrappers, so tapping the header
  "i" button below 960px flipped internal state and fired its data queries
  with zero visible effect. Added the README §1.4-mandated <960px full-page
  overlay tier, and a small new useMediaQuery hook so the header's "i"
  button highlight reflects whether the panel is ACTUALLY visible right
  now instead of being hardcoded to a breakpoint (it was previously always
  blue at 960-1279px regardless of the drawer's real open state, and always
  grey at >=1280px despite the panel being permanently docked open there).
- CRITICAL (visually confirmed against 1a-desktop-chat.png): messages you
  send never showed an avatar at all (just an empty spacer) -- every
  reference screenshot shows your own "我" avatar beside your bubbles the
  same as the other party's. MessageRow now takes myUserId and renders it.
  While in there, also moved the failed-send error icon beside the bubble
  (README: "气泡左侧") instead of below it with the retry text.
- CRITICAL: ChatListColumn's search box had no value/onChange at all --
  typing into it did nothing. Now filters by contact name; added the
  search_off/"未找到相关会话" and forum/"暂无会话" empty states README §2.1
  calls for, neither of which existed before.
- MEDIUM (real Tailwind cascade bug): the selected chat-list row's
  highlight was being overridden by the unconditional `hover:bg-[...]`
  class on the same element -- Tailwind emits hover-variant rules after
  base rules regardless of source order, so hovering an already-selected
  row visually un-selected it until the mouse moved away. Scoped the hover
  class to the non-selected branch only.
- NavRail: inactive icons used the wrong color token (icon-default #555c65
  instead of the spec's #656b74, this app's text-secondary); the unread
  badge prop was never actually supplied by AppShell, so the badge's
  otherwise-pixel-correct rendering never appeared in the running app.
  Fixed both, and gave BottomTabBar's 消息 tab the same real badge (computed
  once in AppShell from useChatList(), passed to both).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:23:54 +08:00
Your NameandClaude Sonnet 5 569f266390 fix(web): code review round 1 -- auth pages design fidelity + missing screens
- CRITICAL correctness bug: resetTinode() was defined but never called
  anywhere. Switching tenants (TenantEntryPage) only updated the session
  store; the Tinode singleton it drives is created once and permanently
  cached (getTinode() lazily creates it, reusing it forever afterward), so
  every subsequent login kept talking to the FIRST tenant's host regardless
  of which tenant was actually selected -- multi-tenant switching was
  silently broken. Also moved LoginPage's getTinode() call to after its
  "no tenant yet" redirect guard, so it can't get created against a
  not-yet-resolved tenant in the first place.
- Error strips across all four auth pages were missing the
  error_outline icon README §10.1 specifies.
- Fixed a token mix-up affecting every error-state input border across
  Register/PasswordInput: README's exact spec is border #d94b4b (this app's
  --color-danger-text) with #cf3f3f text below (--color-danger-border) --
  the code had them backwards, borders were using danger-border.
- TenantEntryPage: added the missing 企业注册/遇到问题 links (now real,
  working pages -- see below), fixed the business icon's color token
  (was accent #1677ff, spec calls for focus-border #4091ff).
- LoginPage/ResetPasswordPage: the "切换企业"/"返回登录" back link was nested
  inside the vertically-centered form container, so it got pulled to the
  page's middle instead of staying pinned to the top like every reference
  screenshot shows it. Also gave LoginPage's brand panel the spec's 56/44
  split (was an even 50/50), 40px headline (was Tailwind's 36px text-4xl),
  and the missing footer links row; added a mobile-only large-logo facade
  (matches 1e-mobile-login.png) since the desktop split-panel brand block
  is hidden below 900px and mobile had no tenant-identifying visual at all.
- RegisterPage: removed an extra 56px-logo+tenant-name block that appears
  in neither the desktop nor mobile reference screenshots; added the
  mobile-only 48px back-chevron+title header bar the design calls for
  (RegisterPage previously had zero back-navigation on any breakpoint).
- ResetPasswordPage: the password error text said "需要包含字母和数字" while
  the actual check only enforces a minimum length (>=8, no character-class
  requirement, matching Register's own rule) -- the message was promising
  a validation this page doesn't perform.
- VerificationCodeButton: the resend countdown's setInterval was never
  cleared on unmount -- a real leak if the user navigates away mid-countdown.
- New: EnterpriseRegisterPage (企业注册, README §10.5) and
  TenantFeedbackPage (问题反馈, README §10.6 / `01/1j`), both previously
  linked-to-but-nonexistent. lib/api/tenant.ts gained the REST calls they
  need (submitTenantApplication, submitTenantEntryFeedback,
  uploadPublicFile against the pre-login /v1/im/public/uploads surface,
  distinct from the chat-file upload endpoint).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:23:30 +08:00
Your NameandClaude Sonnet 5 986432addd fix(web): code review round 1 -- critical data-layer bugs
Three parallel adversarial code-review passes surfaced real defects across
the app. This commit covers the data/logic layer:

- CRITICAL: file upload/download used the wrong auth header entirely.
  Built from docs/IM-前后端对接文档.md as `X-MeetingIM-Auth: token <token>`,
  but the old app's own upload code carries an explicit comment that the
  real header is `Authorization: Token <token>` (capitalized scheme,
  standard header name), "verified live against the real server, not just
  read from source" -- and separately warns the SDK's own built-in
  LargeFileHelper gets this wrong too (sends X-Tinode-APIKey/X-Tinode-Auth,
  which this backend doesn't recognize). This means every external
  (non-inline) image/file/voice attachment -- anything too large to send as
  base64 -- was silently broken; only the earlier live tests happened to
  use small inline attachments, which never exercise this code path.
  Confirmed fixed live: a real >256KB image now uploads (200, real file_id)
  and downloads (blob URL, correct 800x600 dimensions) correctly.
- CRITICAL: TopicMe.onContactUpdate was never wired. Marking a conversation
  read (topic.noteRead()) updates the contact's unread count through ONLY
  this single-slot callback (confirmed against the SDK's own
  Topic.note()/TopicMe._refreshContact -- it does not also fire onData/
  onInfo/onPres, unlike every other _refreshContact call site, which do).
  Without it the chat list's unread badge/ordering went stale after reading
  a conversation until an unrelated topic event happened to invalidate the
  shared snapshot cache.
- HIGH: chat-list message preview didn't check isServerDeleted(msg) (the
  `hi` soft-delete flag), only `content == null` -- a hi-flagged message
  with non-null content leaked its real text/attachment label into the
  preview instead of showing "[消息已删除]".
- MEDIUM: upload failures showed raw untranslated SDK ctrl.text (e.g. "too
  large" instead of "文件大小超出限制") -- ported the old app's
  UPLOAD_CTRL_TEXT_CN table, which it built specifically because this
  native-protocol endpoint's errors never pass through the REST layer's
  own reason-code translation.
- Ambient SDK type corrections (tinode-sdk.d.ts): AccessMode.mode/given/want
  are numeric bitmasks at runtime, not the "JRWPS"-string type they were
  declared as (dormant today, nothing reads them yet, but wrong ahead of
  Phase 3's group-role logic); Drafty.sanitizeEntities's doc comment
  claimed a deep copy it doesn't do (verified it mutates entity .data in
  place); onAutoreconnectIteration's first param was misnamed "seconds"
  when the SDK always passes milliseconds.
- sendVoice() now passes `filename` to Drafty.insertAudio (was the only one
  of sendImage/sendFile/sendVoice omitting it).
- friends.ts's accept/reject-friend-request `version` param used a falsy
  check (`version ? ... : ...`) that would silently drop a legitimate `0`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 03:23:01 +08:00
Your NameandClaude Sonnet 5 d5440b962b feat(web): bulk forward/favorite in multi-select mode
Rounds out the multi-select bottom bar (README §3.6) with 转发/收藏 alongside
the existing 删除 -- reuses the single-message ForwardPickerModal/
favoriteMessages/forwardMessage machinery from the context-menu actions,
just applied to the whole selected set. One "转发" button (not separate
逐条/合并 buttons) matching the old app's real dialog-internal mode toggle,
which this app's simplified single-target picker doesn't model -- already
noted as a scope cut in ForwardPickerModal's own docstring.

Verified live: entering multi-select via a message's context menu shows
转发/收藏/删除, and bulk-favorite POSTs a real /v1/im/favorites/batch request.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:51:29 +08:00
Your NameandClaude Sonnet 5 03b8098b3c feat(web): voice messages (record, send, playback)
Click-to-record (README §3.3's explicit 取消/发送 buttons), not the old
app's press-and-hold-with-swipe-to-cancel gesture -- the new design replaces
that touch gesture with plain buttons, a reasonable desktop-first
adaptation; the underlying MediaRecorder mechanics (audio/webm or
audio/mp4 depending on browser support, 24kbps, 2s minimum/10min maximum
duration) are ported as-is from the old app's audio-recorder.jsx.

- VoiceRecorder.tsx: recording UI + MediaRecorder lifecycle. Silently
  discards (no send) recordings under MIN_DURATION, matching the old app.
  Deliberately skips the `webm-duration-fix` package the old app pulled in
  for Chrome's well-known missing-webm-duration-metadata bug -- this app's
  own displayed duration comes from its own recording timer, not the file's
  embedded metadata, so only a browser's native <audio> scrubber would be
  affected, not anything this app actually shows.
- attachments.ts: sendVoice() -- inline base64 or upload-then-reference,
  same size-based branch as images/files, Drafty.insertAudio's confirmed
  {mime,val/ref,duration,size} shape.
- AttachmentPreview.tsx: AudioAttachment play/pause button + static
  waveform (CSS repeating-gradient, not real waveform analysis) + duration.

Verified the failure path live (this sandbox blocks getUserMedia): the
recorder's error state shows a clear message and recovers cleanly back to
the normal composer with no crash. The actual record -> encode -> send ->
playback round trip could not be exercised in this environment and needs
a manual check with real microphone access.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:49:17 +08:00
Your NameandClaude Sonnet 5 aa3e464b35 feat(web): emoji picker
Plain system-emoji grid (README §3.3's mockup itself has no custom emoji
asset list, just gray placeholder tiles captioned "use system emoji or a
branded pack" -- matches the old app's own reasoning, no WeChat/QQ-style
sticker art available). Recent-emoji tab persists via localStorage, capped
at 30, same as the old app's send-message.jsx.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:45:55 +08:00
Your NameandClaude Sonnet 5 59d1033a92 feat(web): image and file message attachments (send + render)
- lib/api/upload.ts: POST /v0/file/u/ (docs/IM-前后端对接文档.md:460-461) --
  a separate surface from /v1/im/*, at the tenant's file base URL, auth'd
  with X-MeetingIM-Auth: token <token> rather than Authorization: Bearer
  (matches upstream Tinode's own LargeFileHelper convention).
- lib/tinode/attachments.ts: sendImage/sendFile. Files <= the inband limit
  go in directly as base64 (Drafty.insertImage's `bits` field / attachFile's
  `data` field) with no upload round trip; larger ones upload first and
  reference the returned URL. Simplified from the old app's urlPromise
  dance (optimistic local preview while the upload runs in the background)
  by just awaiting the upload before creating the message -- less
  optimistic, meaningfully simpler.
- AttachmentPreview.tsx: image/file bubble rendering. Externally-referenced
  (non-inline) attachments need an authenticated fetch into a blob URL,
  not a plain <img src>/<a href> -- GET /v0/file/s/{file_id} requires the
  same custom auth header uploads do, which a bare element can't attach.
- drafty.tsx: wired the 'IM'/'EX' Drafty style-tag cases to these.

Verified end to end: sent a real inline image (confirmed the exact IM
entity shape in the outgoing WS packet, and that the <img> decoded and
loaded from its own base64 data) and a real inline file (rendered with the
correct icon/size/download affordance).

Known gap: voice messages (MediaRecorder + AU entities) not built yet --
meaningfully more complex (hold-to-record gesture, audio encoding,
duration/waveform) and deliberately left for a following pass rather than
rushed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:44:12 +08:00
Your NameandClaude Sonnet 5 6fcd779530 feat(web): pagination, quote/reply, favorite, and single-message forward
- ConversationView: infinite-scroll "加载更多" -- scroll-to-40px triggers
  topic.msgHasMoreMessages()+getMessagesPage() to fill exactly the missing
  seq ranges (not naive since/before paging, since server-side deletions can
  punch holes in an otherwise-contiguous range), with scroll position
  preserved across the prepend.
- lib/tinode/send.ts: sendReply() ports the old app's exact quote
  construction (Drafty.replyContent truncates, Drafty.quote wraps with the
  sender's name, Drafty.append inlines the whole quote block ahead of the
  new text -- the receiving side never needs to look up the original
  message). forwardMessage() sends a single message directly to one picked
  target topic (ForwardPickerModal) -- simplified from the old app's
  stage-then-compose flow, trading that nicety for a much smaller dialog.
- lib/messages/drafty.tsx: added the 'QQ' Drafty style-tag case ported
  against the SDK's actual Drafty.quote() output shape (no `key`/`data` of
  its own -- verified by reading tinode.dev.js, not guessed).
- lib/api/messages.ts: favoriteMessages() -- a single message is just a
  one-item batch against POST /v1/im/favorites/batch (confirmed in
  docs/IM-前后端对接文档.md:1935).
- Context menu now shows 引用/复制/收藏/转发/多选/[撤回]/删除 in spec order
  (README §3.5); 翻译 still deliberately absent (no real backend for it,
  confirmed by research).

Verified end to end: sent a real quote reply and confirmed the exact wire
packet (head.reply, QQ fmt span, MN entity) and that it renders correctly
via the new QQ formatter case; favorite POSTed and returned created_count:1;
forward POSTed the expected {head:{forwarded:"topic:seq"}} packet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:39:23 +08:00
Your NameandClaude Sonnet 5 bccbdff9b2 feat(web): offline/reconnect/realname top banners (README §3.7)
Four states, one shown at a time (offline > reconnecting > just-reconnected
> realname-reminder priority): no-network (navigator.onLine + online/offline
listeners), reconnecting-with-countdown, just-reconnected, and a skippable
realname-verification reminder (bootstrap.security.realname).

- connection.ts: claims Tinode's onAutoreconnectIteration(timeoutMs, promise?)
  callback (single-slot, same pattern as onConnect/onDisconnect) and runs its
  own 1s-tick countdown off the first call's timeout -- the SDK gives the
  backoff length once per iteration, it doesn't push a running countdown.
  Verified the callback's exact three-shape contract (timeoutMs alone starts
  a countdown; 0+promise means a retry is now in flight; -1 means retrying
  stopped) by reading the SDK's own Connection class, not guessed.
  retryReconnectNow() backs "立即重试": clears the countdown and calls
  tinode.reconnect(true).
- "已重新连接,正在同步 N 条新消息" intentionally shown without a fake N --
  confirmed via research that the old app never implemented this state at
  all, and there's no reliable signal here for how many messages arrived
  while disconnected; inventing a count would be dishonest data.

Verified live: toggling browser online/offline events correctly shows and
clears the no-network banner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:31:32 +08:00
Your NameandClaude Sonnet 5 80bf0aa41f feat(web): 查找聊天记录 history search + media grid in the detail panel
Closes a real visual gap flagged directly: the detail panel jumped straight
from the quick-action buttons to 备注/置顶聊天/etc, skipping the "查找聊天
记录" row + 4-icon media grid (图片/视频/文件/链接) that's prominently
visible in the reference design (README §3.4) -- deliberately deferred in
an earlier pass, but a dead-end-free real implementation was worth building
now rather than leaving a visible hole.

- lib/api/messages.ts: searchTopicMessages/getTopicMedia, endpoint shapes
  from docs/IM-前后端对接文档.md:1251-1254 (verified live: both return real
  200s against the actual backend, not just matching the docs on paper).
- HistorySearchModal: centered modal (README §1.5's 弹层 pattern) with the
  five tabs, debounced search (lib/hooks.ts's useDebouncedValue, ported from
  the old app's use-cached-data.js), empty/loading states.
- DetailColumn: wires the row + grid to open the modal on the right tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:27:12 +08:00
Your NameandClaude Sonnet 5 0b48346d02 fix(web): avatar background color was invisible everywhere (transparent)
avatarBgClass() built its class name via template-literal interpolation
(`bg-avatar-${i + 1}`). Tailwind's build finds utility classes by statically
scanning source text for complete class-name strings -- a dynamically
assembled name is invisible to that scan, so `.bg-avatar-1` through
`.bg-avatar-11` were never generated in the compiled CSS at all. Every
avatar in the app (chat list, conversation header, message bubbles, detail
panel) rendered with a fully transparent background instead of its color --
the element's className looked correct in the DOM, which is why this
shipped across several commits before being caught by checking computed
style rather than the class name itself.

Fixed by writing the 11 class names out as a literal array Tailwind's
scanner can actually see.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:23:20 +08:00
Your NameandClaude Sonnet 5 2272b42c6f feat(web): message context menu, recall, and basic multi-select
Right-click a message for 复制/撤回/删除/多选 (README §3.5). 引用/收藏/翻译/
转发 deliberately left out of this pass -- quote and forward need their own
composer-state flows, and there's no confirmed single-message favorite
endpoint yet; a menu item that doesn't work is worse than a smaller correct
menu.

- lib/messages/actions.ts: gates recall to own messages, server-confirmed
  seq (not the SDK's local pending-seq placeholder), and bootstrap's
  message_policy.recall_window_seconds -- scoped to P2P for now, no
  group-admin recall yet.
- lib/api/bootstrap.ts + api/hooks.ts: first GET /bootstrap wiring
  (message_policy, forward limits, etc.), cached indefinitely per session.
- lib/api/messages.ts: recall goes through REST, not native WS -- and
  requires a body `{version, reason}` this backend tracks per-message
  independently of Tinode's own seq (confirmed in
  docs/IM-前后端对接文档.md:1248 and by hitting a live 400
  MALFORMED_REQUEST/version first, since no version field is actually
  delivered anywhere in the WS {data} packet this app reads messages from).
  Defaults to version:1 -- the correct value for any never-before-edited
  message, which covers the common case; a real edit/recall race surfaces
  as a version-conflict error rather than being silently papered over.
- Basic multi-select: enter via the context menu's "多选" (no long-press
  gesture yet), header becomes "已选择 N 条消息"+取消, bottom bar currently
  offers only 删除 (bulk 转发/收藏 wait on those features existing).

Verified end to end: recall correctly withheld past the 120s window and
correctly available within it, the real recall REST call round-trips and
the message disappears locally; multi-select mode enters/exits and
pre-selects the long-pressed/right-clicked message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 02:01:53 +08:00
Your NameandClaude Sonnet 5 7f5756671d feat(web): real P2P chat-settings detail panel
Replaces the detail-column placeholder with the actual "聊天设置" panel
(README §3.4): profile header, quick-action buttons, remark, pin/mute
toggles, blacklist, clear-history, delete-friend -- backed by real REST
endpoints (lib/api/friends.ts, ported from the old app's real
contact-management-view.jsx settings panel, not its unrelated mock
direct-message-view.jsx).

First real use of TanStack Query in this app (useQuery for
friends/conversation-settings/blocks, useMutation for the writes) --
previously only wired up in App.tsx with nothing consuming it yet.

Verified end to end: pin toggle PATCHes /conversations/{topic}/settings
and persists; remark PATCHes /friends/{userId} and reflects immediately
via query invalidation; blacklist PUT/DELETEs /blocks/{userId}.

Known minor gap: the drawer variant (960-1279px tier) stays mounted
(CSS-hidden) alongside the docked panel at >=1280px rather than
unmounting, so both instances' queries run -- harmless since TanStack
Query dedupes/shares the cache by key, but worth a follow-up cleanup.
Also still missing: the <960px "detail settings push as a full page"
variant the mobile spec calls for.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 01:54:54 +08:00
Your NameandClaude Sonnet 5 f549248c08 feat(web): real chat list + P2P conversation view against live Tinode data
Phase 2 core loop: chat list and single-chat conversation now read/write real
Tinode topic data end to end, replacing the earlier hardcoded placeholders.

- lib/tinode/topics.ts: per-topic pub-sub layered on connection.ts's pattern
  (Topic.onData/onInfo/etc are single-slot callbacks, claimed once and fanned
  out). Implements the old app's subscribe lifecycle: `me` subscribed once
  per session (metadata only), each contact gets a one-shot preview
  subscription (withLaterData(1), never left), opening a conversation
  leaves-then-resubscribes with a full history page (withData, not
  withLaterData -- a preview prefetch already nudging _maxSeq would make
  withLaterData return empty).
- lib/tinode/hooks.ts: useChatList()/useTopicMessages() via
  useSyncExternalStore, snapshots cached and only invalidated on real
  topic-change notifications (not recomputed every render).
- lib/tinode/send.ts: sendText/retryMessage via createMessage+publishDraft
  (optimistic local echo), not the simpler publish() -- matches the old
  app's approach. retryMessage flushes the failed local entry and rebuilds
  before republishing, per the old app's context-menu retry handler.
- lib/messages/{time,avatar,attachments,status,preview,dispatch,drafty}:
  formatting utilities. Avatar color hash ports the old app's Java-style
  string hash but with modulus 11 (this design's palette) not the old app's
  16. @mention renders flat accent blue per the new design, not the old
  app's per-user hash color -- a deliberate simplification. File-type icons
  (PDF/Word/Excel/PPT) are new-design-only; the old app buckets all
  documents identically. Date/time formatting is written fresh against the
  design spec (today+time combined, weekday tier) since the old app never
  combines a relative-date word with a time in one string.
- ChatListColumn, MessageBubble, ConversationView: real components replacing
  the placeholder chat list and "占位" conversation view.
- tinode-sdk.d.ts: expanded with the real Topic/MetaGetBuilder/Drafty
  surface needed above, verified against the installed SDK source
  (createMessage/publishDraft/messages/msgStatus/noteRead/flushMessage/
  Drafty.format's exact treeBottomUp contract, MESSAGE_STATUS_* statics).

Verified end to end against the real backend: used the still-functional old
app (a second account, ixp355087) to create a real friendship and exchange
messages with the new app's test account, confirming the new chat list and
conversation view correctly render pre-existing real data, sending a new
message shows the right optimistic-then-delivered status transition, and
the old app receives it live in return.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 01:49:14 +08:00
Your NameandClaude Sonnet 5 aa1c46ff22 feat(web): full auth screens rework to match design + old-app business rules
Rebuilt tenant/login/register/reset-password to match the design handoff's
01-account screens precisely (previously only loosely approximated), and
ported the old app's real business rules instead of guessing:

- LoginPage: desktop split branding panel (>=900px), terms-agreement
  checkbox gating submit, forgot-password link, password visibility toggle,
  full ctrl-error translation table (lib/tinode/errors.ts, ported from the
  old app's CTRL_ERROR_TEXT_CN/translateCtrlError -- registration/login go
  over native WS, not REST, so client.ts's REASON_MESSAGES never applied).
- RegisterPage: rebuilt as the real unified username-OR-phone form (not
  phone-only) -- username optional unless phone is blank, invite code
  required+validated only on the username-only path, matching the server's
  actual resolveRegistrationAccount rules. connection.ts's
  registerAccountByPhone generalized into registerAccount() supporting both
  paths, including the Tinode.credential(null) crash the old app had to
  work around on the username-only path.
- New ResetPasswordPage wired to /auth/password-reset/* (a different
  endpoint family than registration's /auth/verification-codes/*).
- Shared components: PasswordInput, VerificationCodeButton, Agreement,
  Toast.
- Fixed tinode-sdk.d.ts: the real method is getCurrentUserID(), not the
  invented getAuthenticatedUserID() -- caught live, login crashed past a
  successful auth.
- Fixed a responsive bug present on all four auth pages: fixed w-[380px]
  forms overflowed past the viewport edge below ~430px wide.

Verified end-to-end against the real backend (api.118im.com, tenant
"default"): fresh phone registration, password reset, re-login with the
new password, and the username-only path's invite-code rejection all
round-tripped correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 01:11:21 +08:00
Your NameandClaude Sonnet 5 63c1b84b41 feat(web): phone registration + fix login/register response handling
- Add RegisterPage (phone + SMS code) and lib/api/auth.ts verification-code
  endpoints, wired into App.tsx routing.
- Fix registerAccountByPhone(): the injected acc.phone extra field must be
  bare 11-digit, not E.164 -- server checks digit length before normalizing.
- Fix LoginCtrl shape: SDK's login/loginBasic/createAccountBasic promises
  resolve with the ctrl fields flat, not wrapped in {ctrl: {...}}.
- Fix tinode-sdk.d.ts: the real method is getCurrentUserID(), there is no
  getAuthenticatedUserID().

All three bugs found and fixed via live testing against the real backend
(api.118im.com, tenant "default"), confirming registration and login both
work end-to-end.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 00:51:06 +08:00
Your NameandClaude Sonnet 5 8cdd84152d 企业码解析+登录跑通真实后端:Tinode连接适配层、类型化API客户端、会话store
lib/tinode/connection.ts:单例Tinode连接+自己的发布订阅层——SDK的onConnect/onDisconnect/onLogin
是单槽位属性(赋值会互相覆盖),不是真正的多监听器事件源,所以这里统一接管这几个槽位,自己
扇出给任意数量的React订阅方(lib/tinode/hooks.ts的useSyncExternalStore)。installTenantHiPatch
照搬旧应用tinode-web.jsx的实现搬过来——服务端{hi}包的tenant字段SDK本身没有API,只能在原始
sendText层面注入,这是后端协议的真实约束,不是旧代码库特有的写法。

lib/api/client.ts:REST客户端按旧应用src/lib/im-api.js的request函数+完整REASON_MESSAGES错误码
翻译表搬过来(含请求头X-MeetingIM-APIKey等几个月联调换来的正确写法),这次是重新用TypeScript
写、不是复制文件,但端点行为对齐。

实机联调过程中发现并修正了一处凭记忆猜错的地方:tenants/resolve真实响应结构是
{endpoints:{api,file,websocket}, tenant:{code,name}},不是我最初假设的顶层{code,name}——
已用真实请求验证修正。用企业码default解析成功(显示"Default Tenant")+ 一次真实错误密码
登录尝试(服务端真实401,错误提示正确显示"账号或密码错误")两步都对着真实后端验证通过,
不是只测了正确路径。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 00:38:50 +08:00
Your NameandClaude Sonnet 5 8bcd9f3a5b 新分支:Vite+React19+TS+Tailwind v4 重新搭建,先跑通四级响应式布局壳
不再沿用旧 im_webapp 的技术栈(webpack + class组件 + 15000行全局CSS + 手搓History路由),
按业务重新选型:Vite/React Router/Zustand预留/TanStack Query/保留tinode-sdk真实依赖
(协议客户端本身没有重写的业务理由)。新代码放在 web/ 子目录,旧代码原样保留在仓库根目录,
两者共存到重写完成。

这一步先验证布局壳本身:NavRail(消息/通讯录/收藏/文件助手/通话记录/设置)+ 会话列表 +
主区域 + 详情栏,按设计稿的四档断点(<640/640-959/960-1279/≥1280)用真实浏览器宽度逐档验证
过——两列及以下"列表/主区域二选一+返回键"、三列"详情栏抽屉化"、四列"详情栏常驻"均已用
真实点击/返回验证正确,不是只看了截图。会话列表和聊天内容目前是占位数据,业务数据接入和
Tinode连接是下一步。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 00:24:11 +08:00
Your NameandClaude Sonnet 5 4e8f45d7d8 路由从 hash 换成 History API(pushState/replaceState/popstate),不再依赖 location.hash
HashNavigation 改名 Navigation(src/lib/navigation.js),接口不变,内部改用真实路径:
parseUrlHash/composeUrlHash 改名 parseUrl/composeUrl;navigateTo/navigateReplace 改调
pushState/replaceState 后手动派发 popstate(pushState/replaceState 不会像赋值 location.hash
那样自动触发事件,这里复用 navigateReplace 早就在用的同一个手动 dispatch 手法);goBack()
不用改,history.back() 本来就是真实浏览器历史机制。新增 Navigation.currentUrl() 替代调用方
原本直接读的 window.location.hash(这个类从来没包装过这个读取)。

一处需要专门处理的边界情况:当 path[0] 是空字符串("不选中侧栏视图、只开一个会话"这个仓库里
的合法状态)时,拼出的真实路径开头会变成双斜杠,被浏览器当协议相对地址解析、试图跳到一个叫
会话id的host,pushState 直接抛 SecurityError。composeUrl/parseUrl 内部用占位 token "_" 处理
这种情况,对外的调用方(19个文件)完全无感。

tinode-web.jsx 的 handleHashRoute 改名 handleRouteChange,监听从 hashchange 换成 popstate;
service-worker.js 的推送通知点击跳转手动改写成同样的路径格式(这个文件在打包之外单独执行,
没法 import Navigation)。index.html/index-dev.html 加了 <base href="/">——所有静态资源引用
都是相对路径,hash 路由下页面路径永远是 /,换真实路径后深层地址刷新会按当前目录解析出错误
资源地址导致白屏,这一行是标准修法。nginx.conf 的 try_files 兜底规则本来就适用两种路由,只
更新了注释。

实机验证:地址栏不再有 #;点击会话/设置等各类视图正常跳转;浏览器前进后退和应用内"返回"
按钮都正常;收藏页内部的类型 tab 切换(navigateReplace)确认不产生新的历史记录;在会话页/
个人中心页刷新、直接用完整深层路径打开新标签页都能正确恢复到位,资源正常加载不白屏。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 21:00:39 +08:00
Your NameandClaude Sonnet 5 97a6b88d59 修复"查找聊天记录"列表条目上下错位、长内容不换行溢出的问题
.library-message-results > button 沿用全局 button{height:2.2rem} 的重置,与自己的
min-height:5.5rem 叠加后变成一个"clamp出来的定长"而非auto——内容一旦比这个定长高(比如摘要
换行),这一条本身不会长高,而是让自己末尾的 <small> 时间戳溢出到下一条的发送人名字位置,
视觉上像是变成了下一条的开头,看着上下错位。同一个全局重置还带了 white-space:nowrap,摘要
<p> 继承后完全不换行,比栏宽的内容(尤其是链接)直接顶穿右边界。

改用 height:auto 让每一条按内容真实需要的高度伸展;<p> 补 white-space:normal +
overflow-wrap:anywhere(跟 .bubble div.message-content 处理长链接的方式一致)。移动端窄屏下
实机复现后验证:摘要正确换行、每条自己的时间戳不再越界到下一条。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 20:09:06 +08:00
Your NameandClaude Sonnet 5 7981083cad 修复发出消息里的超链接因为和气泡背景同色而完全不可见的问题
上一版把发出气泡背景改成蓝色(--clr-primary-accent)时漏了一处:全局 a{color} 用的正是同一个
token,链接文字和气泡背景撞成一模一样的颜色,肉眼完全看不见(但DOM里其实是好的,实测<a href>
标签和完整URL文本都在)。用真实账号发一条带链接的消息复现后确认。改为链接色跟随气泡文字的白色+
下划线,颜色不能再区分链接和普通文字时用下划线区分。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:58:49 +08:00
Your NameandClaude Sonnet 5 48baeceaa8 修复长按消息气泡弹出自定义菜单时,浏览器/系统原生右键菜单跟着一起弹出的问题
chat-message.jsx 新增 suppressNativeContextMenu,在气泡上监听 contextmenu 事件并 preventDefault,
避免长按(或桌面端右键/触屏模拟长按)触发的原生上下文菜单和应用自己的长按菜单重叠显示。
css/base.css 给 .bubble 补了 -webkit-touch-callout:none,同时覆盖 iOS Safari 那条独立于
contextmenu 事件之外的长按呼出菜单(复制/查询/分享...),不影响 user-select,桌面端正常的
点击拖拽选字复制不受影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:50:06 +08:00
Your NameandClaude Sonnet 5 4ce666042e 修复系统消息/我的收藏页"加载更多"按钮文字下沉被裁切的问题
.system-notification-load-more/.library-load-more 只覆盖了全局button的字号和内边距,没覆盖
line-height,实测继承的全局button{line-height:2.2rem}比这两个按钮自己的行高需求高出一大截,
把文字挤到框内偏下的位置,底部还被裁切掉约2px(用getBoundingClientRect量出文字整体比按钮
边框低9.9px)。改用flex居中+height:auto,不再依赖固定line-height撑出的位置。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:41:59 +08:00
Your NameandClaude Sonnet 5 f0091fb146 修复消息气泡配色未跟设计稿核对过的问题:发出气泡改蓝底白字,接收气泡改浅灰,日期分隔去掉气泡背景
对照 docs/images/个人对话框(我的状态/对方的状态).png:发出=蓝色实心+白字,接收=浅灰+深字,
与其余相关截图口径一致;旧配色(接收浅绿#c4e0a3、发出用页面同款off-white)不匹配任何参考图,
是尚未跟设计稿核对过的原始默认值。顺带修了一个自查时发现的新问题:已读回执图标复用的.blue
工具类和新的蓝色发出气泡背景是同一个色值,之前会变成看不见的勾选图标,改为不透明白色。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:41:18 +08:00
Your NameandClaude Sonnet 5 a8b81a51e3 修复本轮UI走查发现的6处样式/交互问题
1. 企业注册-统一社会信用代码:扫码图标误用 icon+onIconClick(渲染在输入框左侧、蓝色),
   跟设计稿(图标在右侧、深灰色)不符。原因是 Field 组件里 icon 出现在 <input> 之前;
   全仓库唯一一处 onIconClick 用法,改用 PasswordField 同款的 action(渲染在右侧),
   复用已有的 .auth-inline-icon 灰色样式,不新增CSS。

2. 意见反馈:图片上传按钮跟设计稿差异较大(当前是通栏虚线框+蓝色图标文字,设计稿是
   独立白色方块+灰色图标文字);字数"0/300"计数器卡在textarea自带边框和外层白卡的接缝
   处,看起来像叠在两层盒子中间。改法:拆分原本跟"举报证据上传"共用的CSS规则,给
   .feedback-upload>button 单独设置白底/无边框/灰色/7.5rem方块;去掉textarea自身的
   border,让计数器所在的外层卡片成为唯一可见的白色容器。"图片"标题跟数量合并回一行
   (原来用space-between分居两端),maxFiles真实上限(5,早前已确认是后端真实值)不变。

3. 青少年模式(开启/关闭/验证三个页面互跳):三处跳转全部误用push(HashNavigation.
   navigateTo),导致点返回会先回到流程内已经处理完的页面,跟本工程之前排查过的
   同类"tab切换污染历史记录"是同一个模式。新增 navigateReplace()(跟 navigate() 同结构,
   内部换成 HashNavigation.navigateReplace,抄的是 message-center-view.jsx/
   content-library-view.jsx已验证过的写法),三处调用改用它。

4. 修改密码页面:复用了 profile-editor-page 的通用label/input样式,但没有像
   BirthDateEditor那样带上专属wrapper class,导致三个label的<span>不加粗不换行、
   紧贴在一起零间距,input还留着给别的编辑器计数器/清空按钮预留的3rem右侧空白。
   新增 change-password-fields 样式(参照birth-date-editor的label/span/input规则)。

5. 消息长按弹出菜单:全历史检索确认这个功能从来不是真正的长按手势,唯一实现是点击
   气泡右上角一个不透明度只有0.3的小箭头按钮——参考图"消息页面—长按弹出.png"命名的
   功能需求和实现从未对齐。给 .bubble 补上真实的touchstart/touchmove/touchend长按
   识别(500ms计时器,移动超过10px视为滚动取消),复用同一套菜单构建逻辑(从
   handleContextClick里抽出openContextMenu(x,y)公用)。验证时发现并修复了一个重构
   引入的真bug:openContextMenu里遗留了两行没改完的e.pageX/e.pageY(应为参数x/y),
   会导致点击小按钮和长按两条路径双双抛ReferenceError,已修正并重新验证两条路径都正常。

6. 群公告横幅(群聊导航栏下方常驻提示条):当前是通栏无圆角、贴边无留白、底部硬分隔线、
   文字用的是全局默认灰色(#666)文本token,设计稿是浮在灰底上的白色圆角卡片、四周留白、
   无分隔线、文字接近黑色。改法:加margin+border-radius+box-shadow做出浮起的卡片感,
   去掉border-bottom,颜色改用--clr-bubble-text(消息正文同款深色token,而不是改全局
   的--clr-text-primary,避免影响其它用到这个默认文本色的地方)。

验证:本地dev-proxy逐项验证——企业注册图标位置颜色(截图核对)、意见反馈布局(截图核对,
0/5计数器无缝落在白卡右下角)、青少年模式三处调用grep确认无遗漏+navigateReplace底层
history.replaceState机制单独验证不增加history.length、修改密码页面(截图核对label加粗
间距生效)、长按弹出(真实dispatch TouchEvent触发菜单成功,且验证点击小按钮路径未被
重构破坏)、群公告横幅(getComputedStyle核对margin/borderRadius/boxShadow/color/
borderBottom五个属性全部生效)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:24:24 +08:00
Your NameandClaude Sonnet 5 6ab2867f27 重新构建dev产物,反映本轮死代码清理+收藏批量删除弹层+通讯录CSS修复三处改动
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 01:59:33 +08:00
Your NameandClaude Sonnet 5 0d32569633 修复通讯录"新朋友"两处视觉问题:未读红点数字被挤出圆圈、拒绝/通过验证按钮在移动端换行
现象1:新朋友入口的红色未读数徽标(.people-entry-list em)只显示一个纯红点,数字看不见。
现象2:待处理好友申请行的"拒绝"/"通过验证"两个按钮上下堆叠换行,而不是同一行并排。

根因1:全局 button{line-height:2.2rem} 被 <em> 徽标继承,数字所在的匿名行盒远高于
1.2rem 的圆圈本身,place-items:center 只能让整个超高行盒居中,数字被推出圆心、
甚至溢出到按钮白色背景上(白字白底=看起来像空心圆点)。这跟本工程早前那轮全局
line-height 排查是同一类问题(01325ad0),.messages-conversation-meta b 当时已经
用 line-height:1 修过,.people-entry-list em 这条当时漏改了。

根因2:css/base.css 里 @media(max-width:640px) 断点下有一条
.people-request-list article > span, .group-request-list article > span
{flex-direction:column} 的覆盖规则,把默认的横向flex行强制改成纵向堆叠。核对两处
按钮组的实际内容(拒绝/通过验证、拒绝/确定,均为2个短文本按钮)后确认这条覆盖没有
任何布局必要性,纯属误加;因为 im_webapp 主要是移动端场景,这个断点几乎总命中,
所以两个页面的按钮实际上一直都在换行。

修法:给 .people-entry-list em 补上 line-height:1;删除上述@media块里这条多余的
flex-direction:column(连带align-items:flex-start)覆盖规则,让两个列表都退回默认
的横向排列+垂直居中。

验证:本地起dev-proxy,用真实登录账号在通讯录页面注入与真实DOM结构/class名完全
一致的测试节点核对渲染效果——红点数字正确居中显示,两个按钮同一行并排显示,
与预期一致。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 01:30:48 +08:00
Your NameandClaude Sonnet 5 31f2b4afea 修复"我的收藏"批量删除仍用原生 window.confirm(),是本工程已确立模式里同一类问题的第三处
现象:跟已经修过的两处同一类问题(46a98782"退出登录"、4773ee5f"解散群组")一样,
content-library-view.jsx 的收藏批量删除也还在用裸 window.confirm(),4773ee5f 的提交
说明里已经点名记录过这一处、留给第七章处理。

根因:window.confirm() 与本工程其它确认弹层的视觉不一致,且部分内嵌 WebView/kiosk
环境会直接禁用同步对话框(confirm() 恒返回 false,删除被静默拦下、用户毫无感知)。

修法:改成两段式 state(点"删除"先进"确认中"态渲染弹层,弹层里的"确定"才真正调用
batchDeleteFavorites),新增 .library-sheet-backdrop/.library-confirm-sheet 样式,
数值抄自本文件已有的 .personal-*/.group-* 同款弹层(46a98782/4773ee5f),保持视觉
语言一致。

验证:真实浏览器注册全新测试账号,在一个真实群聊里发送并收藏两条消息,进入"我的
收藏"多选模式,选中两项后:先测"点删除→点取消"(弹层消失、两项收藏原样保留、未
发出任何 batch-delete 请求);再测"点删除→点确定"(真实 POST .../favorites/
batch-delete 200 成功、两项 ok:true、列表正确清空为"暂无此类收藏"、toast 提示
"已删除所选收藏")。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 17:55:48 +08:00
Your Name c2e07a89b8 清理死代码:sidepanel-view.jsx里被chapterOneViews短路掉的旧版登录/注册/找回密码分支
不是功能修复,纯清理。sidepanel-view.jsx的render()方法开头(chapterOneViews数组
含'login'/'register'/'reset'等)已经对这些view值提前return真实对接后端的
AuthOnboardingView。但同一个render()方法下半部分的三元表达式链里,还留着一份基于
view==='login'/'register'/'reset'的旧分支,分别渲染LoginView/CreateAccountView/
PasswordResetView——由于上面的提前return,这三个view值永远不可能落到这里,是无法
触达的死代码。

确认方式:全仓库搜索确认SidepanelView只有tinode-web.jsx一处渲染入口,其view prop
(state={this.state.sidePanelSelected})只能来自handleHashRoute对SIDE_PANEL_VIEWS
白名单的校验结果——'login'/'register'/'reset'确实在这个白名单里(否则连
sidePanelSelected都设置不了),但chapterOneViews这个子集在render()内部的检查顺序
必然先于旧分支执行,所以旧分支不可能被触达。

删除范围:三个旧分支的JSX(对应import的LoginView/CreateAccountView懒加载/
PasswordResetView懒加载)+ 三个组件源文件本身(login-view.jsx/create-account-view.jsx/
password-reset-view.jsx,删除引用后确认全仓库不含任何其它referrer)+ 两个文件对应的
已生成umd构建产物(webpack不会自动清理不再产出的旧chunk文件,这两个是被删除源文件
遗留下来的孤儿产物)。'cred'分支(ValidationView,不在chapterOneViews里,仍在真实使用)
未改动。defineMessages里对应的login/register/reset翻译key本次未删——它们现在也
无法被messages[view]这条动态查找命中,但涉及i18n提取产物的联动改动超出这次清理范围,
如实记录,未处理。

验证:npm run build:dev干净通过(三个旧chunk不再产出);真实浏览器完整走了一遍
登录(已有账号)/注册(全新手机号)/找回密码(重置刚注册账号的密码,重置后用新密码
重新登录成功)三条流程,均无回归。
2026-09-06 17:31:20 +08:00
Your Name ac8f0cf315 补上好友申请前后关系状态聊天页的入口,并把验证留言渲染成真实消息气泡
第3章要求pending_inbound/pending_outbound/none/blocked几种关系状态都能在真实聊天
窗口里看到(参考图 个人对话框(对方的状态)/(我的状态).png、开启沟通页面.png)。
messages-view.jsx里对应的横幅逻辑("以上是打招呼的消息"/"好友申请正在等待处理"等)
本身早就是对的,但排查发现两个问题:

1. 全项目没有任何入口能导航进这个视图——"新朋友"列表(FriendRequests)每一行没有
   onClick;好友个人主页(Profile)非好友分支只显示一段cannotAddReason纯文字,没有
   任何可点击进聊天的按钮。发起方和接收方在成为好友之前都完全看不到这个视图。
2. 验证留言原文(发好友申请时填的那段话,存在im_friend_requests表的message字段,
   不是真实Tinode消息,没有走topic.publish())没有被当作消息渲染出来,参考图里
   明确是作为聊天页第一条消息气泡展示的。

修复:
- contact-management-view.jsx: FriendRequests每一行加onClick,用(onCreateTopic||
  onTopicSelected)跳进对应聊天(同一套Profile.openChat已经验证过的冷启动安全写法,
  接受/拒绝按钮补stopPropagation不受影响);Profile非好友分支(blocked_by_peer/
  blocked_by_me/pending_inbound/pending_outbound)补一个"查看聊天"按钮,跟
  cannotAddReason文字并排展示(不复用people-profile-actions的两列grid,那是给
  发消息+音视频通话两个平级按钮设计的)。ContactManagementView/sidepanel-view.jsx
  早前就已经把onCreateTopic透传到位,这次只是多接一层。
- messages-view.jsx: loadRelationshipContext新增loadPendingRequestMessage,
  relationship是pending_inbound/pending_outbound时,用relationshipContext自带的
  request_id去GET /friend-requests换真实留言文本(没有留言用参考图默认文案"请求
  添加你为好友"兜底),渲染成一条合成的消息气泡插在消息列表最前面(不进
  messageNodes、不调用发消息接口)——pending_inbound左对齐、pending_outbound右
  对齐,气泡样式直接复用chat-message.jsx真实消息已经在用的.bubble/.left/.right
  这套CSS(app里"发出的"气泡实际是这套已实现的浅色而非参考图的蓝色,保持跟同一
  会话里其它真实消息一致,不単独改这一条的颜色去凑旧版参考图)。

验证:真实两个新账号(vcm909641发起→hankqa14b接收)走了一遍完整流程——
从"新朋友"列表点击行正确进入聊天页,留言气泡+"以上是打招呼的消息"横幅正确显示;
从Profile页"查看聊天"进入,留言气泡右对齐+"好友申请正在等待处理"横幅正确显示;
点"通过验证"接受申请后正确留在列表页不误触发导航;接受后重新打开同一会话,
合成气泡正确消失,被真实的"你们已成为好友,现在可以开始聊天了。"系统消息取代,
无残留/无重复。
2026-09-06 17:24:36 +08:00
Your Name e4b0868036 重新构建dev产物(本轮7个并行agent全部完成后的统一构建) 2026-09-06 16:44:01 +08:00
Your Name 3790472b4c 修复我的收藏/查找聊天记录加载失败时卡死在"加载中…",没有任何错误提示或重试入口
根因:Favorites 和 History(content-library-view.jsx)两个组件的 load() 里,请求失败的
.catch() 只调用 onToast() 弹一条 2 秒自动消失的提示,从未把 items 从初始值 null 变成别的
状态;页面渲染逻辑是"items === null 就显示'加载中…'",两者叠加导致一次真实失败之后页面永远
停在"加载中…",等用户反应过来去看的时候提示早就消失了,分不清"还在加载"和"永远不会加载完"。
这个坑本文件自己的 DownloadPage、group-management-view.jsx 的 GroupFiles、
contact-management-view.jsx 的 Profile 三处早就各自修过(都有一个独立的 loadError 状态),
唯独 Favorites/History 这两个用得最多的列表页当时漏掉了。

真实触发场景:用两个全新账号互加好友、发送图片/视频/文件/语音/文本等真实消息并收藏后,
发现收藏一条较大的(out-of-band)文件附件之后,GET /favorites?type=all 从此稳定返回
500 INTERNAL_ERROR(已确认是纯后端问题,不在前端能修的范围内,另行记录)——但这个后端
问题一暴露出来,"我的收藏"默认落地的"全部"tab 就会用真实数据 100% 复现这里描述的前端缺口:
打开就卡在"加载中…",没有任何办法知道发生了什么、也无法重试。

修复:Favorites/History 都新增 loadError 状态,非 append(首次/切换 tab、类型、搜索词)请求
失败时置为 true、成功时置回 false,切换 type/topic/view/query 时重置;渲染时 items === null
且 loadError 为真则显示"加载失败,请重试"而不是死等的"加载中…"。"加载更多"(append)请求
失败保持原样只弹 toast,不影响已经显示出来的列表。

验证:复现上述 500 场景(GET /favorites?type=all 持续 500),修复前"我的收藏"全部 tab
永久停在"加载中…";重新构建后同一账号同一坏数据状态下正确显示"加载失败,请重试"。
2026-09-06 16:39:13 +08:00
Your Name 0d2637861d 修复好友个人主页"发消息/音视频通话"对零消息历史的新好友必现"消息已禁用"
根因:Profile.openChat() 一直只调用 onTopicSelected(tinode-web.jsx 的 handleTopicSelected),
这个函数只负责"切换到某个已知 topic",从不发送原生 Tinode {sub} 请求。对一个从未被任何一方
订阅过的全新 P2P 会话(刚通过好友验证、双方都还没发过消息),本地没有缓存的 topic 订阅信息,
messages-view.jsx 的 isWriter 因此永远停在初始值 false,输入框被替换成"消息已禁用"提示且没有
任何入口能发出第一条消息——对每一个"刚加好友、还没聊过天"的真实场景都 100% 必现,不是边缘情况。

tinode-web.jsx 里其实已经有正确处理这个情况的 handleStartTopicRequest(通过 onCreateTopic prop
暴露):判断 topic 是否已缓存,没有缓存就先设置 sub.mode 为完整权限再切换,已缓存则直接退化成
普通的 handleTopicSelected,两种情况都能正确处理。问题是 sidepanel-view.jsx 渲染
ContactManagementView(好友个人主页/群成员资料页所在文件)时,只透传了 onTopicSelected,
从未把 onCreateTopic 一起传下去,导致 Profile 组件即使想调用正确的入口也拿不到。

修复:sidepanel-view.jsx 补上 onCreateTopic 的透传;contact-management-view.jsx 的
ContactManagementView/Profile 都新增接收这个 prop,openChat 优先调用 onCreateTopic
(不可用时兜底回退到原来的 onTopicSelected,避免类型不匹配报错)。

验证:用两个全新注册、互加好友但零消息历史的真实账号(ch7test1/ch7test2)在浏览器里复现——
修复前点"发消息"进入会话后输入框区域固定显示"消息已禁号"且没有任何输入控件,WS 日志确认
自始至终没有对该 p2p topic 发过 {sub};修复后点"发消息"能看到真实的 {sub} 请求(带
acs.mode: JRWPA)、输入框正常出现,并成功发送/收到多条真实文本消息。
2026-09-06 16:38:46 +08:00
Your NameandClaude Sonnet 5 4773ee5f64 修复"解散群组"仍用原生 window.confirm(),是本文件唯一未统一的危险操作确认弹层
现象:本工程已经在"退出当前登录"那次修复(46a98782)里明确认定原生 window.confirm()
和其余确认弹层不一致、且在部分内嵌 WebView/kiosk 环境下会被直接禁用(同步对话框调用
无提示地恒返回 false,操作被静默拦下且用户毫无感知)。全仓库复查后发现"解散群组"是
group-management-view.jsx 里唯一还在用这个模式的危险操作——同样不可逆、后果同样重大,
却没有跟上那次统一。

修复:改成两段式状态(先点"解散群组"进入确认态,再点确认态里的"确定"才真正调用
delTopic),复用本文件 OwnerSelect 早就在用的同款弹层视觉语言
(.group-sheet-backdrop/.group-confirm-sheet),不再依赖浏览器原生对话框。

验证:真实浏览器操作,用真实账号在真实群里走了"点解散→点取消"(弹层消失、群未受
影响、群列表可正常继续访问)和"点解散→点确定"(原生 WS {del hard:true} 成功执行,
200 ok,正确跳转回群列表且群已从列表消失)两条路径。

顺带记录:content-library-view.jsx 的收藏批量删除也还在用裸 window.confirm(),
是同一类问题,但属于第七章范围,本轮不动,留给对应章节的测试处理。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:30:06 +08:00
Your NameandClaude Sonnet 5 30e6d5e300 修复风控弹窗取消后再次发送同一条回复,引用内容与正文被永久追加重复
现象:对一条历史消息点"回复",输入含链接的文本触发风控弹窗后点"取消发送"
(预览条正确保留,这是既有修复的正常行为),不改动文本再次点发送、这次
在弹窗里点"我已知晓,继续发送"——最终发出的消息里,正文被写了两遍(引用
块本身只有一份),例如输入"排查根因4 xxx",实际发出"[引用] 排查根因4
xxx排查根因4 xxx"。第三次发送同一条文本会变成正文三遍,以此类推。

根因:sendMessage()给回复拼接引用内容时写的是
`Drafty.append(Drafty.sanitizeEntities(this.state.reply.content), msg)`。
Drafty.sanitizeEntities和Drafty.append(umd/tinode.dev.js,第三方SDK,
未改动)都会原地修改第一个参数并把同一个引用返回,从不做深拷贝——
sanitizeEntities内部直接改content.ent[i]再return content,append直接
first.txt+=、first.fmt.push()再return first。这里把this.state.reply.content
(React state里"正在回复"预览条依赖的同一个对象)原样传进去,等于每次
点发送都会把当次输入的正文永久焊死在这条回复引用的原始内容上。

平时这个副作用不可见:一次成功发送后handleCancelReply()马上清掉
state.reply,被污染的对象跟着一起扔掉。但URL风控检查会把真正调用
doSend()(进而调用这行代码)的时机推迟到弹窗resolve之后,"取消发送"
按现有修复(a964d51b)刻意不清空state.reply以保留预览条——这就让
被污染的引用内容留了下来,等下一次点发送时,同一个已经被污染的对象
再被追加一次,正文因此重复。

修复:拼接前对this.state.reply.content做一次深拷贝,让两个SDK方法的
原地修改只作用于用完即扔的副本,不管sendMessage对同一条还没清空的
回复调用多少次都不影响原始状态。

验证:真实浏览器操作,对消息点回复,输入带链接文本发送触发风控弹窗,
点"取消发送"(预览条与文本保留正常),不改文本再次发送并这次确认
"继续发送"——发出的消息引用块和正文都只出现一次,不再重复。

第14+轮最小粒度测试 Ch4 发现(回复+风控弹窗联动场景,紧邻已知修复
a964d51b验证时发现)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:24:30 +08:00
Your NameandClaude Sonnet 5 53e28e3bc6 修复"我在本群的昵称"保存时硬编码 version:0,与本文件其余乐观锁写法不一致
现象:GET /groups/{topic}/members/me/profile 实测确认真实带 version 字段(非空),
但 MyNicknameEditor 保存时一直硬编码 updateMyGroupProfile(topic, value, 0),从不使用
这个已经到手的真实版本号。

根因/影响:跟本文件其余"编辑单个字段+乐观锁"写法(GroupNameEditor/
GroupDescriptionEditor 用 getGroup 拿到的真实 version;Governance/OwnerSelect 的
注释更明确写过"version<=0 时服务端会整个跳过乐观锁 WHERE 子句,两次几乎同时的写入会
静默覆盖,没有冲突信号")不一致。实测验证这里同样如此:真实 version 是 1,PUT 时传 0
依然 200 成功,服务端自己把 version 推到了 2——多标签页/多端几乎同时修改自己在本群的
昵称时,后写入的会静默覆盖先写入的。

修复:GET 到的 version 存进 state,保存时原样带上,不再硬编码 0。

验证:真实浏览器操作,改群昵称两次,第二次 PUT 请求前 GET 到的 version 正确是上一次
保存后服务端返回的版本号(1→2→3 依次递增),不再是硬编码的 0。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:17:03 +08:00
Your NameandClaude Sonnet 5 8cb96e9254 修复撤回消息在对方界面显示"无效内容"而非"消息已删除"
现象:真实调用撤回接口成功后(recall_window_seconds=120秒窗口内,长按菜单
"撤回"),撤回发起方自己的消息列表里这条消息直接消失,没有问题;但对方
(消息接收方)的聊天界面上,这条消息位置渲染成一个灰色警告图标+"无效内容"
(invalid content),不是预期的"此消息已被撤回/删除"占位提示。

根因:Tinode原生协议里,一条消息被撤回后,服务端把这条消息的content原地
置为null(seq位置保留,其余内容清空)。chat-message.jsx渲染消息内容时按
Drafty富文本、纯字符串、其余情况三段分支处理,content为null时两段判断
(Drafty.isValid和typeof=='string')都不成立,直接落到兜底的"invalid
content"分支——这段代码从未加过null-content(撤回)的专门判断。这跟
messages-view.jsx里另一套依赖msg.hi标记位的"删除"消息渲染(走独立的
<MetaMessage deletedText=.../>组件)是两码事:撤回消息还是走的普通
ChatMessage组件,从未接入deletedText这个机制。

修复:chat-message.jsx新增一个content==null的分支,复用
messages-view.jsx里messages.message_deleted已经注册好的同一个i18n
id/文案("消息已删除",非新增翻译串),渲染为灰色图标+"消息已删除",
不再落到"无效内容"兜底分支。

验证:真实浏览器操作,两个全新注册账号互为好友,A发一条文本消息后
立即长按撤回(120秒窗口内),B的聊天界面上这条消息正确显示"消息已删除"
(此前是"无效内容")。

第14+轮最小粒度测试 Ch4 发现(撤回消息渲染场景)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:12:38 +08:00
Your NameandClaude Sonnet 5 b200dc2525 修复长按消息菜单"撤回"功能完全失效:LOCAL_SEQID 经 webpack externals 解析为 undefined
现象:验证第2章长按消息弹出菜单的时间窗口门控时发现,无论消息是不是自己刚发的、群主/普通
成员、发送后多少秒,长按菜单里永远不出现"撤回"这一项——服务端 messagePolicy 明确返回
recall_window_seconds=120,但这个选项在任何情况下都不会出现。

根因:messages-view.jsx 原来 `import { ..., LOCAL_SEQID } from 'tinode-sdk'`,但
webpack.config.js 把 'tinode-sdk' 映射到全局变量 `tinode`(externals: {'tinode-sdk': 'tinode'}),
而这个全局对象(umd/tinode.dev.js 的 UMD 导出)只挂了 {AccessMode, Drafty, TheCard, Tinode}
四个键,从未包含 LOCAL_SEQID——尽管 tinode-sdk 的 ES 模块本身确实导出了这个常量
(umd/tinode.dev.js 内部仍以 `const LOCAL_SEQID = 0xFFFFFFF;` 定义并在多处话题方法里使用,只是
没有挂到对外暴露的全局对象上)。externals 映射对"这个键不在对象上"和"这个导出根本不存在"没有
区分,所以按名字导入被静默解析成 `undefined`,没有任何构建报错。撤回菜单项的门控条件
`params.seq > 0 && params.seq < LOCAL_SEQID` 因此变成 `seq < undefined`,在 JS 里恒为
false——不是时间窗口判断错了,是入口条件本身就从未为真过,撤回功能对所有用户、所有消息实质性
完全失效。

用控制台直接调用 MessagesView 实例的 handleShowMessageContextMenu 并打印各分支布尔值定位到
这一步:seq=2 这样明显合法的正整数,seqOk 却是 false,才追到 LOCAL_SEQID 本身是 undefined。

修复:messages-view.jsx 不再从 'tinode-sdk' 导入 LOCAL_SEQID,改为本文件内本地声明
`const LOCAL_SEQID = 0xFFFFFFF`,与 SDK 内部定义保持一致(未改动 webpack externals 映射本身——
其它页面都依赖它保持现状,改映射风险更大、影响面更广)。

验证:登录测试账号,在群聊里发一条新消息立即长按,确认"撤回"正确出现在菜单中;点击后
`POST /topics/{topic}/messages/{seq}/recall` 返回 200,消息从会话中正确消失;对比一条已经
超过 recall_window_seconds(同时也超过 edit_window_seconds)的旧消息,确认"撤回"和"编辑"
仍正确不显示,时间窗口判断本身未被破坏。

注:umd/index.dev.js 本次未随此提交提交——工作区里并行的其它 agent 仍在改
contact-management-view.jsx / sidepanel-view.jsx 且未提交,重新构建的 index.dev.js 会把它们的
未提交改动一起编译进同一产物文件,为避免混入不相关改动,只提交 messages-view.jsx 按路由懒加载
的独立 chunk(umd/src_views_messages-view_jsx.dev.js 及其 .map),不受这个问题影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 16:05:19 +08:00
Your NameandClaude Sonnet 5 5c688007c8 修复会话列表把系统通知/合并转发消息的预览行恒显示成无信息量的"[消息]"
现象:好友通过验证、群成员加入、群邀请卡片("邀请你加入群聊。")这类系统消息
(x-im-type=system_notice/system_action)和合并转发消息,点开会话看到的是完整的一句
人话,但会话列表的预览行不显示这句话,一律显示占位符"[消息]"。

根因:message-center-view.jsx 的 draftyPreview() 只认三种取值来源——纯字符串、
Drafty 附件实体(content.ent[],映射成"[图片]"等标签)、以及 content.txt——最后兜底
到"[消息]"。但 system_notice/system_action 的真实文案字段是 content.text(跟
chat-message.jsx 渲染这类消息时读的字段一致),合并转发消息的文案字段是
content.comment/content.title,三者都不是 content.txt,全部落进兜底分支。

修复:draftyPreview() 补上对 content.text(system_*)和 content.comment/
content.title(forward_bundle)的识别,命中就直接用这句真实文案,仍然兜底到
"[消息]"覆盖真正未知的内容形状。

验证:真实浏览器操作,两个全新注册账号之间邀请入群后,会话列表预览行正确显示
"邀请你加入群聊。",不再是"[消息]"。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:58:45 +08:00
Your NameandClaude Sonnet 5 64021fe428 补上"邀请好友入群"功能,POST /groups/{topic}/invitations 真实可用但前端零调用点
现象:docs/images/内容沟通对话3-开启进群验证与拉人状态.png 里"你邀请时间旅行者、
李惠惠加入群聊"这条系统事件证明产品设计里确实有"从我的好友里直接选人邀请进群"这条
路径;im-api.js 的 inviteGroupMembers()(POST /groups/{topic}/invitations)也早就
封装好,服务端接口本身可用(实测确认:请求成功返回 pending 状态的邀请记录,对方会在
跟我的会话里收到"邀请你加入群聊。立即加入"卡片)。但全仓库检索这个函数零调用点——
群聊设置页成员格的"添加"格子原来点了只弹一句"请通过转发/分享群二维码邀请成员",把
这条真实存在的路径堵成了唯一路径(二维码分享走的是另一条"申请入群待审批"的路,跟
"邀请"语义不同,不能互相替代)。

修复:新增 InviteFriends 组件(复用 MemberPicker 同款字母分组多选布局),数据源换成
好友列表并按当前群成员名单过滤掉已在群里的人,选中后调用 inviteGroupMembers;
GroupSettings 成员格的"添加"改为跳转到这个新页面。

顺带修复一处会导致这个新路由完全打不开的既有陷阵:tinode-web.jsx 的 SIDE_PANEL_VIEWS
是一份独立于 group-management-view.jsx 自己 GROUP_MANAGEMENT_VIEWS 的硬编码合法视图
名单(文件里 104-107 行注释解释过这就是历史上"新增路由忘记同步这份名单导致深链被清空"
的同一类坑)——只在后者加了新路由名、没有同步这份名单,会导致 handleHashRoute 判定
"Unknown sidepanel view" 直接不渲染侧栏,新页面点了跟没点一样,一并补上。

验证:真实浏览器操作,两个全新注册账号(好友关系),从群设置页"添加"格子进入,勾选
好友、点完成,对方在跟我的会话里收到真实的"立即加入"邀请卡片,群设置页成员数不变
(邀请只是待确认,尚未入群,符合预期)。邀请确认接受这一步会 500,是已知后端问题
(新增72,AcceptInvitation 里一处表名/字段名写错,见 im_be/server/db/mysql/
tenant_groups.go:448 用了不存在的 subscriptions/Topic/User 而不是 im_subscriptions/
topic/userid),不是本次改动引入。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:58:24 +08:00
Your NameandClaude Sonnet 5 b6adc032a2 修复发送含链接消息时切换话题,风控弹窗可能把消息送错会话
现象:在会话A输入含链接的消息并点发送,POST /security/urls/check 请求还未返回时切换到
会话B,请求返回后风控弹窗会在会话B里弹出(本该属于会话A),此时点"继续发送",消息被
真实发进了会话B,而不是最初输入它的会话A——跨会话消息误投递。

复现:会话A(kcg051307)输入"https://example.com/racefix?token=fix002"点发送,立即
(在checkURL网络请求返回前)切换到会话B(r12c56b2),风控弹窗在会话B视图下弹出;点
"继续发送"后消息被投递进会话B,会话A完全没有收到,会话B收到了本不该发给它的内容
(本次因B恰好还不是好友触发"发送失败"提示,但发送动作本身已经指向了错误的会话,
如果B是好友会真实送达)。

根因:`getDerivedStateFromProps`在话题切换时会把`riskDialog`重置为null,但这只能防住
"弹窗已经弹出、随后切换话题"这一种时序——它防不住"checkURL还在进行中就切换话题,
此时机的.then()回调在切换之后才执行"这种反过来的时序:这次setState({riskDialog:{...}})
是在切换完成之后才发生的,不会被这次重置覆盖,于是在当前(错误的)话题下重新弹出弹窗,
且弹窗里的`doSend`闭包沿用的是`this.props.sendMessage`,按确认时的当前话题发送,
不是消息最初输入时所在的话题。

修法:sendMessage()进入URL风险检测分支时已经缓存了`topicForDraft`(用于清草稿),
在checkURL的.then()回调最开头加一道判断——如果`this.state.topic`已经不等于
`topicForDraft`,说明用户已经离开发起这次检测的会话,直接返回false(视同消息被放弃,
不弹窗、不发送),不再继续读取风控结果或setState。这与本文件已有的"两条风险消息连续
触发、后一个覆盖前一个"场景处理逻辑(resolve(false)后覆盖)是同一种"宁可丢弃، 不可
错误投递"的一贯取舍,只是这次要防的是切换会话而不是重复触发。

验证:npm run build:dev后浏览器实测——(1) 会话A输入风险链接点发送,立即切换到会话B,
2秒后确认会话B里没有弹出风控弹窗(修复前必现);(2) 切回会话A确认最后一条消息仍是修复
前测试遗留的旧消息,本次新消息没有出现在A也没有出现在B,两个会话都没有收到,符合
"宁可丢弃不可错投"的设计;(3) 对照组(不切换话题、正常触发风控)依旧能看到弹窗并正确
发送/取消,未被这次改动影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:57:22 +08:00
Your Name 644b663ed1 重新构建本轮2处修复的编译产物(通话小窗预览显隐 + 语音通话菜单翻译)
对应前两个commit的源码改动:src/widgets/call-panel.jsx、
src/i18n/zh.json、src/i18n/zh-TW.json。跑 npm run build:i18n 重新生成
src/i18n.min/{zh,zh-TW}.json,再跑 npm run build:dev 让 webpack 重新
编译对应的 umd 产物。

本次只挑了跟这两处修复相关的产物文件加入本commit:call-panel.jsx 自己
的 chunk(含 sourcemap)、zh/zh-TW 两个 i18n chunk。umd/index.dev.js、
umd/index.dev.js.map,以及 umd/src_views_messages-view_jsx.dev.js 等其它
umd 产物这次构建也发生了变化,但那是因为这台机器上同时有其他并行任务在
改 src/views/messages-view.jsx、tinode-web.jsx、contact-management-view.jsx
等跟本轮通话章节测试无关的文件(构建时是从磁盘当前状态整体编译的,会把
它们尚未提交的改动一起编译进去)——这些不是我这轮的改动,没有一并提交,
保留在工作区未纳入版本控制,避免把别的任务未完成的改动夹带进这个commit。
2026-09-06 15:56:44 +08:00
Your Name 56cd91d40e 修复语音通话菜单项中文翻译误译成"称呼",实际含义是发起语音电话
会话更多菜单里发起语音通话的入口(tinode-web.jsx menu_item_audio_call,
唯一消费点,2236 行 formatMessage 渲染其 title)中文翻译一直是"称呼",
和视频通话入口"视频电话"并列显示时,用户会把它当成设置备注昵称之类的
功能,点开才发现其实是拨打语音电话——源头是英文 defaultMessage 只有一个
词"Call",同时可以理解成"称呼/命名"或"打电话",翻译流程选错了词义,
zh.json/zh-TW.json(简繁中文)都错,全仓库搜索确认"称呼"/"稱呼"没有
被其它任何 key 使用,不是复用了别的合法翻译。

改成"语音电话"/"語音電話",与同一菜单里视频通话入口现有的"视频电话"
措辞保持一致(未改动"电话"这个词本身的选择,只修正词义选错的问题,
避免额外引入新的用词不一致)。

验证:修复前 tw.props.intl.formatMessage({id:'menu_item_audio_call'})
返回"称呼";重新跑 npm run build:i18n 生成 src/i18n.min 后同一调用
返回"语音电话"。
2026-09-06 15:56:12 +08:00
Your Name 9eb9df649d 修复视频通话振铃阶段本地小窗预览提前可见且可拖动
本地小窗预览(.call-party.self)此前只由 audioOnly/minimized 控制显隐,
没有校验连接是否真正建立(this.state.remoteStream),导致视频通话振铃
阶段(对方尚未接听、只有本地摄像头画面)小窗就已经以 display:block 可见,
且 dragStart 里对应的分支同样没有这层校验,振铃阶段就能拖动这个本应还
不存在的预览框。

render() 里 .call-party.self 的 disabled 属性补上 !connected 判断,
dragStart 里进入 'self' 拖动分支的条件补上 this.state.remoteStream 校验,
两处保持一致:只有远端媒体真正到达(连接建立)后小窗才出现并可拖动。

验证:用 canvas.captureStream()/AudioContext 构造的合法 MediaStreamTrack
配合 monkey-patch getUserMedia,绕开已知后端通话邀请 400 缺陷直接驱动
CallPanel 组件状态。振铃阶段(waitingForPeer=true, remoteStream 未设置)
下 .call-party.self 计算样式 display:none、无 [disabled] 属性对应的
cursor:grab;调用 handleTrackEvent 模拟远端媒体到达后,display:block、
cursor:grab,且用真实鼠标拖拽(非合成事件)验证了拖动/边界收紧都正常。
2026-09-06 15:55:48 +08:00
Your NameandClaude Sonnet 5 dbf47847fe 修复系统消息列表漏查表的"group.join_request.accepted/rejected"机器码
现象:核对第2章系统消息列表最近修复的"group.announcement.updated"查表遗漏问题时,逐个比对
im_be 的 insertGroupEventTx 调用点,发现入群申请审批这一条并不在当初核对的 9 种静态事件码
之列——群管理员在群管理页批准或拒绝入群申请后,系统消息列表会展示原始机器码,而不是可读文案。

根因:TenantGroupJoinRequestResolve(server/db/mysql/tenant_groups.go:642)拼的事件码是
"group.join_request."+status(status 取 server/store/types/groups.go 的
GroupJoinRequestStatusAccepted/Rejected,即 "accepted"/"rejected"),实际产生的是
"group.join_request.accepted"/"group.join_request.rejected" 两个完整机器码。这两个和已收录的
"group.join_request.created"(提交申请时那条)是三个不同的 key,NOTIFICATION_TEXT 表里只有
后者,前两个查表落空,systemNotificationText() 兜底把原始机器码直接展示给用户。

修复:在 message-center-view.jsx 的 NOTIFICATION_TEXT 表里补上这两个 key。

验证:读 im_be 源码逐个核对 insertGroupEventTx 全部调用点,确认这是唯一还缺失的动态拼接事件码;
重新构建后确认产物里两个 key 及对应文案都已生效。未在真实环境触发一条这样的通知做端到端验证——
需要另一账号先申请加群、本账号再批准/拒绝,属于第6章群管理操作范畴,本轮未展开搭建。

注:umd/index.dev.js 本次同样未随此提交提交——工作区里此刻并行的其它 agent 正在改
i18n/zh(-TW).json、group-management-view.jsx、sidepanel-view.jsx、call-panel.jsx、
contact-management-view.jsx 等多个文件且均未提交,重新构建的 index.dev.js 会把它们的未提交
改动一起编译进同一产物文件,为避免混入不相关改动,本次只提交源码。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:51:10 +08:00
Your NameandClaude Sonnet 5 5ddace97f4 修复好友资料/验证页对"当前关系"的两处判断遗漏,与已有的好友申请状态机修复形成完整闭环
现象一:Profile 组件(好友/陌生人资料页)在 getConversationContext 回来后只合并了
relationship 字段,没有一并合并 capabilities——这个组件判断"能否显示添加好友按钮"
用的是 profile.capabilities.can_add_friend,不是 relationship 本身。已实测复现:
刚发送好友申请、relationship 已经变成 pending_outbound 时,若走的是"直接刷新这个
资料页"而不是 Verification 组件自己的发送成功回调(该回调已由另一处提交单独修复),
canAdd 依然读到发送前搜索结果种子里的 can_add_friend:true,"添加好友"按钮继续可点,
可再次触发一次好友申请(配合后端不去重的缺口——新增44)。

现象二:Verification 组件(好友验证/发送申请页)完全没有用 getConversationContext
校正过 relationship,只依赖 getUserProfile——但该接口对非好友直接 403,403 时的
catch 分支什么都不做。真实复现:本次会话内先成为好友、再解除好友关系后,若该好友的
profile-seed 缓存还留着"好友"时期的旧数据,重新打开验证页会一直显示"你们已经是
好友了",且不提供发送框,除非整页刷新清掉内存缓存,否则无法向这个人重新发起好友申请。

修复:两处都在 getConversationContext 成功回调里,把它返回的字段当作当前关系的权威
来源合并进本地 profile 状态——Profile 一并合并 capabilities(而不只是 relationship),
Verification 补上原本没有的 getConversationContext 调用去合并 relationship。

验证:真实浏览器操作,两个全新注册账号之间反复走查——(1) 发送好友申请后直接刷新
资料页,"添加好友"按钮正确消失、显示"好友申请正在等待验证";(2) 成为好友后互相
删除好友关系,立刻重新打开验证页,正确显示可发送的验证留言表单,不再卡在"你们已经
是好友了"。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:41:36 +08:00
Your NameandClaude Sonnet 5 605cd12a6e 修复第1章各认证页之间切换会带出上一个页面的报错文案
现象:在注册页填错邀请码提交后(正确显示"邀请码错误,请重新输入"),点击"已有账号?登录"
切到登录页,登录表单顶部会原样带出这条"邀请码错误"提示——用户在登录页什么都还没做,却先看到
一句跟登录毫不相关的报错。反过来,登录密码错误后点"马上注册"也会把"账号或密码错误"带到刚打开
的注册页。

根因:errorText/errorLevel 是 tinode-web.jsx 顶层的一份全局共享状态,不跟当前显示哪个
sidepanel 绑定。Login 组件自己的"切换企业"链接会在跳转前先调用一次 onError() 清空,说明
之前已经意识到这个状态会带着走,但企业码/登录/注册/忘记密码等页面之间的其它跳转都没有做
同样的处理——其中 Register 等好几个视图目前甚至没有拿到 onError 这个 prop,需要在各处
分别补,比较分散。

修复:在 AuthOnboardingView(chapterOneViews 统一走的入口组件)里加一个以 view 为依赖的
useEffect,view 变化时统一调用 onError() 清空全局报错状态,一次性覆盖企业码/登录/注册/
忘记密码等视图之间的所有切换路径(含浏览器前进后退),不用逐个跳转调用点分别处理。

验证:注册页提交错误邀请码复现"邀请码错误,请重新输入"后点击"登录",确认登录页不再带出
该提示;反向验证登录页密码错误后点击"马上注册",确认注册页同样不再带出"账号或密码错误"。

注:umd/index.dev.js 本轮未随此提交一起提交——auth-onboarding-view.jsx 不是按路由懒加载的
独立 chunk,此刻工作区里另有并行 agent 正在改 contact-management-view.jsx / call-panel.jsx
且未提交,重新构建的 index.dev.js 会把它们尚未提交的改动一起编译进同一个产物文件,为避免把
不相关改动混进这次提交,本次只提交源码,构建产物留给后续统一重新构建的提交处理。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:40:43 +08:00
Your NameandClaude Sonnet 5 a71aee9487 修复"安全"设置页退出登录无二次确认,单击立即登出
现象:从 account-settings-view.jsx"账户设置"、info-view.jsx,以及第2章系统消息列表"前往
安全设置"跳转按钮都能到达 acc-security-view.jsx 这个"安全"页(并非废弃页面,是与
personal-settings-view.jsx"设置"页并存的独立入口),点击"登出"没有任何二次确认,单击
图标就会立即退出登录,且没有任何撤销机会。

根因:该文件里"登出"按钮的 onClick 直接调用 this.props.onLogout(),而同文件里紧挨着的
"删除帐户"按钮走的是 onShowAlert 弹确认框(handleDeleteAccount)。此前 46a98782 那一轮
已经把 personal-settings-view.jsx"设置"页的登出从原生 window.confirm() 改成了自己的
ConfirmSheet/LogoutConfirm,但漏改了这个仍然可达的旧版"安全"页入口。

修复:新增 handleLogoutRequest,复用本文件已有的 onShowAlert 机制弹确认框,文案
("退出登录"/"确定要退出当前登录吗?")与 personal-settings-view.jsx 的 LogoutConfirm
保持一致,不新造一套措辞。

验证:本地登录后访问 #security,点击"登出"确认弹出对话框(标题/正文/按钮文案核对无误);
点击"取消"保持登录态且仍停留在安全页;点击"好"确认后正确清除 auth-token 并退回登录/租户
判断逻辑接管的空 hash。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:36:55 +08:00
Your NameandClaude Sonnet 5 51e0fc420f 修复发送好友申请后个人资料页仍显示可点击的"添加好友"按钮
现象:搜索陌生人->进入资料页->点"添加好友"->发送验证留言成功后,
无论是验证页800ms后的自动跳转、还是手动点返回,回到这个人的资料页时
仍然显示"添加好友"主按钮(本应显示"好友申请正在等待验证"提示文案),
且这个按钮此时仍可点击,会打开验证页再次发起一次申请(配合后端
好友申请不去重的缺口——新增44——实际造成同一个人收到多条重复申请)。

根因:发送成功后 Verification 组件把 profile-seed 缓存的 relationship
patch 成了 pending_outbound,但没有同步更新 capabilities.can_add_friend
——Profile 组件判断是否展示"添加好友"按钮的 canAdd 变量读的是
profile.capabilities.can_add_friend,不是 relationship 本身,遗留的
can_add_friend:true(原样来自发送前的搜索结果种子缓存)导致按钮继续显示。

修复:发送成功后 patch 缓存时把 capabilities.can_add_friend 一并设为
false,和 relationship 保持同步。

验证:真实浏览器操作,两个全新注册账号之间,搜索->进入资料页->添加好友
->发送验证留言,自动跳转回资料页后正确显示"好友申请正在等待验证",
"添加好友"按钮消失,无法重复发起申请。

第14+轮最小粒度测试 Ch3-4 发现(好友申请气泡状态机场景)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:35:50 +08:00
Your NameandClaude Sonnet 5 552c891cc3 修复清除缓存显示"已释放约0.0MB",与实际清理量不符
现象:设置-清除缓存确认后,无论Cache Storage里实际有多少数据,展示的"已释放约
X MB"几乎总是0.0,即使清理动作本身(caches.delete)确实生效(caches.keys()清理后
变为空数组)。

根因:原实现在caches.delete()前后各调用一次navigator.storage.estimate(),用两次
usage之差算释放量。实测确认Chrome的storage.estimate()不会同步反映Cache Storage的
删除——caches.keys()已经确认清空后,estimate()仍继续报告删除前的usageDetails.caches
数值,实测滞后超过500ms未刷新。人为塞入一个2MB的Cache Storage测试项复现:删除成功
但"已释放"显示0.0MB;塞入3MB项验证修复后正确显示3.0MB。

修法:不再依赖storage.estimate()的前后差值,改成删除前逐个遍历所有Cache
Storage条目、读取每个response的真实blob大小并累加,直接得到本次会话即将删除的
真实字节数,不受浏览器存储统计滞后影响。

验证:npm run build:dev后浏览器实测——(1) 无缓存数据时点击"清除缓存",正确显示
0.0MB且弹层能正常从"清空缓存"态切到"缓存已清理"态、点"完成"可关闭;(2) 用JS人为写入
2MB/3MB测试用Cache Storage条目,分别清理后弹层准确显示2.0MB/3.0MB,且caches.keys()
确认条目已被真实删除。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-06 15:30:40 +08:00
Your Name cd092108c2 重新构建dev产物(清理调试残留日志+本轮3处修复的编译产物) 2026-09-06 15:04:00 +08:00
Your Name 4715ea4497 修复未登录状态下深链/跳转到某个话题导致应用整体白屏
handleHashRoute 判断 path[1](话题名)是否合法只检查了 Tinode.topicType(topicName)
这一个语法层面的条件——没登录时把hash设成一个真实群/好友的"#/<topic>"地址(比如
退出登录后浏览器历史回退、或者一个带话题参数的通知/深链在未登录状态下被打开),
话题名本身语法上合法,于是mobilePanel被设成'topic-view'。但render()里
MessagesView真正渲染的前提除了mobilePanel还要求!chapterOneActive(即
this.tinode.isAuthenticated())——这个前提没登录时是假的。结果移动端下
SidepanelView的显示条件(mobilePanel=='sidepanel')和MessagesView的显示条件同时
为假,两边都不渲染,应用整体白屏(app-container是空的,没有任何报错),
仅靠换hash也无法恢复,只能整页刷新。

修复:判断话题合法性时把"当前是否已认证"也纳入,未登录时统一走已经验证过安全的
"话题名非法"分支(topicName置null,mobilePanel回退到sidepanel)。

已实测复现:退出登录后把location.hash设成一个真实群的话题地址,修复前白屏,
修复后正确回退。
2026-09-06 15:03:48 +08:00
Your Name ed9a92896c 修复系统消息列表原样显示机器码"group.announcement.updated"
NOTIFICATION_TEXT 查表漏收了这一种群事件码。real账号(asn494180)的真实系统消息
列表里实测出现过未翻译的原始事件码——server/db/mysql/tenant_groups.go 的
insertGroupEventTx 一共会写9种 group.* 事件(announcement.updated/created、
join_request.created、member.joined/left/updated、mute.updated、
owner.transferred、role.updated、settings.updated),这张表原来漏了公告更新
这一种,是9种里唯一没查表命中、直接把机器码露给用户看的。
2026-09-06 15:03:34 +08:00
Your Name a81a7b162d 修复群系统事件消息重新变回普通气泡+"未找到"作者名(并发编辑意外丢失CSS)
e6e51f62 提交的".meta .bubble.system"样式规则,被同一时间并发运行的另一个agent
提交(8087fd7e)在给css/base.css暂存自己的"通话小窗grab光标"改动时意外整个删掉了
(这个环境多个并发session同时改同一个文件、共享git暂存区,8087fd7e自己的commit信息
也提到了这个现象,但它删掉这条规则时没有同时补上——本轮测试期间反复出现的同一类问题)。

净效果:meta-message.jsx渲染系统事件消息用的".bubble.system"类此前已经没有对应CSS,
群里的成员加入/角色变更/公告发布这类系统提示消息会退回默认气泡样式,重新出现
"未找到"这种内部占位作者名——e6e51f62本来要修的问题又变相复现了。

重新补上这条规则,内容与e6e51f62最初提交的完全一致。
2026-09-06 15:03:25 +08:00
Your Name fa2288c497 修复群文件上传不传file_type,图片/视频/语音群文件全部显示成通用文件图标
根因:POST /groups/{topic}/files 的 file_type 是客户端登记时自己声明的字段(im-api.js的
addGroupFile早就留了这个参数),不是服务端拿mime_type反推的——实测:上传一张mime_type
正确记成"image/png"的图片,只要请求体不带file_type,服务端就把它落成字面量"file"(不是
留空)。GroupFiles的onFile上传流程一直没传这个字段,导致FILE_TYPE_META准备好的
image/video/audio/document四种彩色图标从未被真正用上,所有群文件不分类型清一色显示灰色
通用文件图标。

修复:新增fileTypeFromMime辅助函数(与content-library-view.jsx的同名函数用同一套mime前缀
优先、扩展名兜底文档类型的判断口径),上传时算出分类一起传给addGroupFile。

验证:真实浏览器上传一张PNG,接口返回file_type从"file"变成"image",列表图标从灰色通用
文件图标变成绿色图片图标;对照同一会话里upload-fix之前的旧文件,图标仍是灰色(存量数据
不受影响,符合预期,不需要也没有接口能批量回填旧记录)。
2026-09-06 13:47:19 +08:00