Commit Graph
27 Commits
Author SHA1 Message Date
Your NameandClaude Sonnet 5 ef31eb2e88 新增点击头像/昵称查看用户资料弹窗,接入消息气泡/@提及/群成员列表
新增全局 UserCardRoot(stores/userCard.ts 驱动,挂载在 AppShell 里,模式同
ConfirmDialogRoot),复用 getUserProfile 数据源,展示头像/昵称/性别地区/签名,
非本人时带发消息/语音/视频快捷操作,底部"查看完整资料"跳转到完整的
/contacts/{id} 页面。

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 17:45:23 +08:00
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 Name b4675b70e1 docs: correct root-cause comment on avatar CORS investigation
The OSS bucket's CORS rule does allow the app's auth headers -- just
scoped to the real web/web1-5.118im.com origins. The earlier failure
was from testing against a local dev origin, never in any CORS
whitelist, not from OSS lacking CORS entirely.
2026-09-18 15:18:32 +08:00
Your Name 74f4e753ef fix: don't route already-absolute avatar URLs through authenticated fetch
Default-avatar placeholders return avatar_file_id as a complete OSS URL
rather than a short file id. The previous fix stopped re-wrapping that
URL into a malformed /v0/file/s/https://... request, but still routed
it through useAuthedBlobUrl's authenticated fetch -- live-confirmed
that OSS bucket serves default avatars with no CORS headers at all, so
the cross-origin fetch carrying auth headers gets blocked at preflight
and the tile silently falls back to the plain letter. An absolute URL
needs no credentials; render it directly as a plain <img src> instead.
2026-09-18 15:13:27 +08:00
Your Name 7fff1a7b3a fix: call media-connect state, blocklist banner staleness, group rename, avatar URL
- stores/calls.ts, CallRoot.tsx: distinguish signaling-connected from
  actual media-joined (mediaJoined flag), so a call whose mic never
  attaches no longer shows a live timer/"网络良好"; propagate a
  disconnected reason on post-accept media failure so both sides agree
  on the outcome instead of one side seeing a normal "已结束".
- DetailColumn.tsx: invalidate conversation-context alongside blocks on
  block/unblock, so the composer's blocked-notice banner updates
  immediately instead of only after a window-focus refetch.
- GroupDetailColumn.tsx: add an owner-only group rename control
  (updateGroup was already implemented but never wired to any UI).
- Avatar.tsx: stop re-wrapping an already-absolute avatar_file_id (seen
  on default-avatar placeholders) into a malformed
  /v0/file/s/https://... request.
2026-09-18 15:01:08 +08:00
Your Name 425b06aa71 消息列表和聊天窗口接入真实上传头像,替换字母色块(联系人页此前已接入)
用户报告:头像上传后,联系人页面已经能正确显示,但消息列表(会话列表)和
聊天对话框(顶部头像+消息气泡头像+详情栏)仍然只显示字母色块。

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

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

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

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

重要:本次修复只是把avatar_file_id正确传给了Avatar组件——实机测试时发现
GET /v0/file/s/{id}这个下载接口目前会302重定向到一个跨域地址,
useAuthedBlobUrl用fetch+鉴权头去请求时,浏览器跟着重定向到那个跨域地址后
被CORS拦截(Failed to fetch,redirect:'manual'下确认type是opaqueredirect)。
用三个不同账号/不同file_id复测结果一致,不是个例。也就是说这条修复目前
"接线是对的",但只要这个后端问题不解决,任何地方(含联系人页、头像上传页
本身)新上传的头像实际都不会真的显示成图片,只会退回字母色块——这是后端
问题,按惯例记录不在这个仓库里修。
2026-09-16 23:37:31 +08:00
Your 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 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 c455644cca NavRail左上角头像补充点击跳转个人资料页
此前是纯展示的Avatar,没有包任何可点击元素。包一层Link指向
/settings/profile,和设置页里"个人资料"入口是同一个目的地。
2026-09-16 16:58:21 +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 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 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 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 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 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 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 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 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 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 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 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