Commit Graph
28 Commits
Author SHA1 Message Date
richjohnrambo dd29eaca42 fix(web): sync group member nicknames 2026-10-17 10:12:20 +08:00
richjohnrambo 440e29975a chore: remove unused web code and dependencies 2026-10-14 14:10:41 +08:00
Your Name 7b8216ea70 fix(web): prevent negative unread counts 2026-09-30 15:58:34 +08:00
Your NameandClaude Sonnet 5 0ba5c97d78 codereview修复:资料弹窗好友关系判断、转发内容克隆、账号切换缓存残留等
三路独立codereview(群成员UI+资料弹窗 / 未读红点+转发修复 / 搜索+复制修复)
发现并修复:

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 18:02:25 +08:00
Austin 41728017cb Merge branch 'dev' of https://git.118im.com/im_team/im_webapp into dev 2026-09-18 18:34:21 +08:00
Your NameandClaude Sonnet 5 7cf8e4f4e0 fix(groups): correct rename-sync event name and unreliable getMeta path
The previous fix listened for a documented-but-wrong event name
(group.profile.updated); live instrumentation of a real rename showed
the actual wire event is group.settings.updated. Fixing only the name
still didn't work: the refresh mechanism it triggered
(getMeta(startMetaQuery().withDesc().build())) was itself unreliable
against this backend -- live-confirmed to either time out (504) on a
brand-new topic or return stale pre-rename data on an established one,
which also explains the "second rename flips back to the original
name" symptom (a late, stale response from an earlier call clobbering
the correct value).

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

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

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

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

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

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

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

Live-verified: recalling a message now shows "⚠ 消息已删除" on the
other party's screen immediately, with no manual refresh.
2026-09-18 16:09:52 +08:00
Your Name 50767f701f 修复已建立的P2P会话详情栏/头像404:话题名不是对方user_id
用户报告:控制台反复出现 GET /v1/im/users/p2pp77kUAkgqpVbZoiTpjPWtg/profile
404。根因:这个应用的多处代码(DetailColumn的好友资料/拉黑/备注/删除好友,以及
本会话早前刚加的头像peerProfileQuery)一直假设"P2P会话的topicId就是对方的
user_id"——这个假设只在"第一次用对方usr...地址打开、还从未真正subscribe过"
这个短暂窗口内成立。读tinode-sdk源码确认:一旦subscribe成功,SDK会把这个话题
从usr...地址重命名为服务端分配的真实p2p...哈希名,而Topic.topicType()自己的
前缀表里根本没有p2p这一项(只有usr映射到P2P类型),导致topic.isP2PType()/
topic.p2pPeerDesc()对已建立的会话恒定返回false/undefined——SDK自己内建的"取
对方身份"方法在这个场景下也是坏的,不只是这个项目自己的代码有问题。

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

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

实机验证:真实会话grpMc5uN...不对,是p2pp77kUAkgqpVbZoiTpjPWtg(systest04↔
systest02,有真实历史消息的老会话)——修复前详情栏"ID:"整行空白、404貫穿控制
台;修复后详情栏正确显示"ID:usrp77kUAkgqpU",好友资料/备注/拉黑状态全部正确
关联到真实好友记录。
2026-09-17 12:52:44 +08:00
Your Name 804618ad63 修复群聊解散通知覆盖不全:主动退出/解散群聊会误报"该群聊已解散"且留下幽灵会话
前情:上一版修复只覆盖了"被动看着群被别人解散"这一种场景(后端走自定义
{info what:"group" event:"group.dissolved"}事件而非原生{pres what:"gone"},
需要单独识别)。这次用户实测发现同一个"话题彻底消失后清理不干净"的问题在另外
两条路径上都复现了:自己点"退出群聊"、自己(群主)点"解散群聊"。

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 22:25:55 +08:00
Your NameandClaude Sonnet 5 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 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 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 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 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 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 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 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 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 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