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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:48:43 +08:00
Your NameandClaude Sonnet 5 370bf9d5b0 fix(tinode): trim tenant code before login_type comparison
Code review of the login_type fix (11276804) found tenantCode.toLowerCase()
=== 'default' doesn't trim, so a tenant code with stray leading/trailing
whitespace would be misclassified as enterprise (login_type:0) instead of
personal (login_type:1). tenant.code always comes from the resolve
response, not raw user input, so this was a latent edge case rather than
a reproduced bug -- hardened anyway since the fix is free, matching the
.trim() already used on tenant code elsewhere in this codebase.

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 13:50:41 +08:00
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
Your NameandClaude Sonnet 5 5748f1290d fix(groups): sync group rename in real time for owner and members
PATCH .../groups/{topic} pushes a custom {info what:"group",
event:"group.profile.updated"} that nothing recognized, so the
sidebar/header name (native SDK contact.public.fn) never refreshed
until an unrelated focus-triggered resync happened to touch it.
Add the missing branch to onInfo (mirrors the existing
group.dissolved/message.recalled handling) to re-pull public/desc
via topic.getMeta(...withDesc()), and trigger the same refresh from
the renamer's own save success since they won't receive their own
push.

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

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

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

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

Live-verified: recalling a message now shows "⚠ 消息已删除" on the
other party's screen immediately, with no manual refresh.
2026-09-18 16:09:52 +08:00
Your Name 1a4a96461a feat: show group announcement read count and send read receipts
Backend added read_count/member_count/read_summary to the announcement
payload in today's deploy (previously confirmed absent). Wire it up:
- groups.ts: add the three fields to the GroupAnnouncement type.
- GroupAnnouncementModal.tsx: render "已读N/M" per README §4.8, and
  call readGroupAnnouncement() on open -- that endpoint existed in
  groups.ts already but was never called from anywhere, so opening an
  announcement never told the server this member had read it.
2026-09-18 16:05:04 +08:00
Your Name b4675b70e1 docs: correct root-cause comment on avatar CORS investigation
The OSS bucket's CORS rule does allow the app's auth headers -- just
scoped to the real web/web1-5.118im.com origins. The earlier failure
was from testing against a local dev origin, never in any CORS
whitelist, not from OSS lacking CORS entirely.
2026-09-18 15:18:32 +08:00
Your Name 74f4e753ef fix: don't route already-absolute avatar URLs through authenticated fetch
Default-avatar placeholders return avatar_file_id as a complete OSS URL
rather than a short file id. The previous fix stopped re-wrapping that
URL into a malformed /v0/file/s/https://... request, but still routed
it through useAuthedBlobUrl's authenticated fetch -- live-confirmed
that OSS bucket serves default avatars with no CORS headers at all, so
the cross-origin fetch carrying auth headers gets blocked at preflight
and the tile silently falls back to the plain letter. An absolute URL
needs no credentials; render it directly as a plain <img src> instead.
2026-09-18 15:13:27 +08:00
Your Name 7fff1a7b3a fix: call media-connect state, blocklist banner staleness, group rename, avatar URL
- stores/calls.ts, CallRoot.tsx: distinguish signaling-connected from
  actual media-joined (mediaJoined flag), so a call whose mic never
  attaches no longer shows a live timer/"网络良好"; propagate a
  disconnected reason on post-accept media failure so both sides agree
  on the outcome instead of one side seeing a normal "已结束".
- DetailColumn.tsx: invalidate conversation-context alongside blocks on
  block/unblock, so the composer's blocked-notice banner updates
  immediately instead of only after a window-focus refetch.
- GroupDetailColumn.tsx: add an owner-only group rename control
  (updateGroup was already implemented but never wired to any UI).
- Avatar.tsx: stop re-wrapping an already-absolute avatar_file_id (seen
  on default-avatar placeholders) into a malformed
  /v0/file/s/https://... request.
2026-09-18 15:01:08 +08:00
Your Name 0c5b329179 修复Zentao批量报告的6个前端bug:来电UI不弹出/黑名单发送无提示/群名校验/群人数与通话记录显示uid
用户提供了一份Zentao导出的bug清单(#21/23/24/25/26/27/28共7条),逐条实机复现
排查,修复了其中6个真正的前端bug,1个是纯后端问题(禁言不生效)已记录到
docs/IM-后端问题清单.md,1个(好友申请404/500)未能独立复现只记录了测试团队
的原始报告,另1个(离线状态推送)实测后确认并非报告描述的"永久不更新",只是
有正常的心跳超时延迟。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:25:36 +08:00
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