Commit Graph
2113 Commits
Author SHA1 Message Date
Your NameandClaude Sonnet 5 9abae5030b 修复codereview复核确认的多选/转发/收藏页/资料页等前端bug
- 多选模式下Drafty链接、名片消息不再意外触发跳转/开新标签页
- 语音/视频消息在多选模式下点击可正常冒泡选中该行
- 转发排除未确认/发送失败消息,识别uncertain转发结果并单独提示
- 群文件"更多操作"菜单竞态、群成员移出状态竞态
- 收藏页链接卡片new URL()崩溃点加防御,并给登录后应用区域加错误边界
- 个人资料页乐观锁版本冲突时清空草稿避免静默覆盖并发修改

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-27 16:47:32 +08:00
Your NameandClaude Sonnet 5 e3eed8bdd4 Add click-to-view lightbox with zoom for chat images
Tapping a chat image opens a full-screen viewer (same resolved
image source, no extra fetch) with zoom in/out buttons, mouse-wheel
zoom, drag-to-pan once zoomed, and double-click to toggle zoom.
Wheel zoom uses a native non-passive listener rather than JSX onWheel,
since a passive listener's preventDefault() is silently ignored and
would leave the page scrolling under the lightbox.

Also closes a gap the row-click-to-select change (1cc6f506) left open:
image/video/audio/file attachments inside a bubble had no selectMode
awareness, so tapping one during bulk-select would also open the
lightbox / play media / start a download alongside toggling the row.
Added a `locked` prop to all four attachment components (disabling
their own click behavior without changing how they look) and wired
`locked={selectMode}` from both MessageBubble.tsx and drafty.tsx's
IM/EX/AU/VD cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:32:07 +08:00
Your NameandClaude Sonnet 5 aca5f8609b Fix misleading "clear chat history" confirmation copy
The dialog said clearing was "local record only" -- reading the real
backend handler (im_be's Topic.replyDelMsg) shows a soft delete
persists a per-account delete watermark server-side, visible across
every device/session on that account and not reversible through any
normal client action. Only the "doesn't affect the other party" half
of the old copy was accurate. Updated both the P2P and group versions
to say what actually happens.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 22:21:56 +08:00
Your NameandClaude Sonnet 5 1cc6f5068a Make whole message row tappable to toggle selection
The selection checkbox was the only clickable target in multi-select
mode; tapping the rest of the row (bubble, avatar, timestamp) did
nothing. Moves the toggle handler to the row container so tapping
anywhere selects/deselects, matching the multi-select UX in every
other list this app has (favorites, blocklist, etc).

Nested controls that keep their own click behavior during selectMode
(retry-failed-send, @mention profile card, quote-bar jump-to-message)
would otherwise double-fire alongside the new row click -- retry stops
propagation, mentions and the quote bar go inert instead (same
treatment this file already gives the avatar/sender-name buttons
during selectMode), so a tap only ever does the one thing: toggle the
row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 13:42:27 +08:00
Your NameandClaude Sonnet 5 95c0677b55 Fix forward comment landing before forwarded content
The comment send was fired in parallel with the forward REST call,
not chained after it -- confirmed live with a 2-source bulk forward,
where the comment (plain WS publish, fast) landed before the forwarded
messages (async endpoint, responds with status:"pending" before the
messages are actually persisted server-side). Chains the comment send
off the forward promise instead of starting both at once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:37:01 +08:00
Your NameandClaude Sonnet 5 4cd2b33c53 Migrate message forwarding to backend REST endpoint
Both real mobile clients (iOS, Android) forward messages via POST
/v1/im/messages/forward, never a client-side {pub} packet -- confirmed
by reading their source directly. Switches web's single-message and
bulk forward paths to the same endpoint, which copies message content
verbatim regardless of shape, eliminating the client-side flat-content
detection this used to require. Comment-sending stays on the existing
sendText() path, unchanged, to preserve current ordering.

Removes send.ts's now-unused forwardMessage/flatForwardPayload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 12:27:37 +08:00
Your NameandClaude Sonnet 5 e7cb4edf3a 读了iOS/Android真实源码后修正:busy确实是真实的通话状态,之前判断错了
早前(Part I/K)判断"busy从未在持久化消息里真实出现过,只是本应用自己在
live信令上用的值",所以没把它并入 CallWebrtcState,只在本地 UI 状态单独
加了个 union。这次拿到手机端真实源码核对,iOS(ServerMessages.swift的7态
枚举)和Android(MsgServerData.java的BUSY)都把busy当成服务端下发的正式
head.webrtc取值之一,和accepted/finished等其余几个是同一级别的东西,
不是本应用凭空发明的——之前的判断依据不足。

补上 case 'busy' 的通话结果文案("对方忙线中",和已有的实时来电忙线提示
用词一致),去掉 stores/calls.ts 里两处不再需要的 `| 'busy'` 单独union。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 11:51:28 +08:00
Your NameandClaude Sonnet 5 1d383b4e93 转发消息的来源标记改用手机端同款字段,修复网页转发在手机端认不出"转发"的问题
网页版转发功能此前一直用自造的 head.forwarded: "topic:seq" 单字符串字段记录
来源,且这个字段在整个网页项目里从未被任何地方读取过(纯写入,无消费者)。

数据库排查(排查转发内容丢失bug时顺带确认)发现手机端自己的转发用的是完全
不同的字段:head['x-im-forwarded']:true + head['x-im-forward-source']:
{seq,from,topic},是真实双端数据。网页版这个自造格式手机端根本不认识,
意味着从网页转发出去的消息,即使内容显示正常,手机端也大概率认不出"这是
一条转发消息",不会显示对应的转发来源提示(如果手机端UI有这个功能的话)。

改成手机端同款的两字段格式。确认这不影响网页自己的任何逻辑(旧字段确认无
读取方)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 11:34:44 +08:00
Your NameandClaude Sonnet 5 ed2920275d 修复资料弹窗点击自己头像必定加载失败
GET /users/{id}/profile 对自己的id恒定400(和更早修过的群内自己资料
400是同一类self-lookup限制),点击群成员格子/列表里自己的头像会一直
显示"加载失败,请重试"。改成自己走专门的 GET /me/profile(没有这个
限制),归一化成和他人资料相同的字段结构供同一套渲染逻辑使用。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 00:49:06 +08:00
Your NameandClaude Sonnet 5 0ba5c97d78 codereview修复:资料弹窗好友关系判断、转发内容克隆、账号切换缓存残留等
三路独立codereview(群成员UI+资料弹窗 / 未读红点+转发修复 / 搜索+复制修复)
发现并修复:

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 00:45:34 +08:00
Your NameandClaude Sonnet 5 105ff76959 排查转发bug同类问题:修复查找聊天记录崩溃 + 复制功能复制出"undefined"
审查了所有"读取消息content做数据处理"的地方,找到两处和转发bug同一根因
(手机端扁平非Drafty内容格式)的真实问题:

1. 查找聊天记录(HistorySearchModal)的搜索结果渲染,对手机端发送的图片/文件/
   语音/视频/名片消息命中关键词时会让整个应用崩溃——Drafty.toPlainText在这
   类扁平内容上返回undefined,highlightMatches对undefined调用.toLowerCase()
   直接抛异常,而这个应用完全没有错误边界(ErrorBoundary)兜底,等于白屏。
   已用真实搜索接口返回确认snapshot里其实带着head字段(此前SearchHit类型
   没建模),据此在渲染前识别这几种扁平内容并显示[图片]/[文件]/[语音]/[名片]
   这样的占位文案,不再无脑喂给renderDrafty;顺手给highlightMatches补了防御
   性判空,避免类似疏漏再次崩溃整个应用。

2. 消息右键菜单"复制"功能,对手机端发送的扁平文本消息(如群公告自动发布的
   那条)和图片/文件/语音/视频消息,复制到剪贴板的是字面字符串"undefined"。
   文本类修成正确提取真实文本;附件类直接从"复制"这个操作里去掉(参考名片/
   通话记录消息已有的排除逻辑),复制一段JSON式的附件数据本来就没有意义。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-25 00:06:10 +08:00
Your NameandClaude Sonnet 5 eb0a6eba7e 修复转发含flat非Drafty内容的消息(手机端文本/图片/语音/视频/名片/通话结果)导致content丢失显示"已删除"
真实数据库排查确认根因:forwardMessage()原来无条件把 msg.content 喂给
Drafty.forwardedContent(toDraftyContent(...)),假设内容永远是纯字符串或真
Drafty文档({txt,fmt,ent})。但手机端把文本/图片/语音/视频/名片都发成扁平非
Drafty JSON(如 {"text":"11"}),只靠 head['x-im-type']/head.webrtc 标记类型
——对这类对象跑 Drafty 处理会退化成空内容,SDK 出站的 simplify() 又会把空
content 整个从包里剥掉,服务端收到的 {pub} 根本没有 content 字段,落库后
content/content_nonce 都是真正的 NULL,客户端据此正确地(从自己的角度)把
它当成"已撤回"渲染成"⚠ 消息已删除"。

用 SSH+kubectl exec 直接查了k3s上的真实MySQL(meeting_im库, im_003_group_
messages_202609/im_003_messages两张表),确认了具体是哪几条消息、它们的
head/content 真实长什么样,而不是猜测。

修复:flatForwardPayload() 识别这几种扁平内容形状,文本类重建成真正的
Drafty文本,附件/名片/通话结果类原样保留内容和 x-im-type/webrtc 标记(接收
端已有的对应dispatch逻辑本来就认识这些形状),只有真正的纯字符串/Drafty
内容才继续走原来的 Drafty.forwardedContent() 路径。

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 18:02:25 +08:00
Your NameandClaude Sonnet 5 ef31eb2e88 新增点击头像/昵称查看用户资料弹窗,接入消息气泡/@提及/群成员列表
新增全局 UserCardRoot(stores/userCard.ts 驱动,挂载在 AppShell 里,模式同
ConfirmDialogRoot),复用 getUserProfile 数据源,展示头像/昵称/性别地区/签名,
非本人时带发消息/语音/视频快捷操作,底部"查看完整资料"跳转到完整的
/contacts/{id} 页面。

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 17:45:23 +08:00
Your NameandClaude Sonnet 5 2427a3782c 群成员格子改回独立"移出"格子,点击打开成员列表,不再是逐头像角标+直接移出
用户再次纠正:不要在每个可移出成员的头像上单独加角标、点击直接弹确认移出;而是
像"+邀请"一样,做一个独立的"-移出"格子(同款虚线方块,仅 isAdmin 可见),点击后
打开"查看全部"成员列表,真正的移出操作留在列表里已有的那一行按钮上。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 17:28:17 +08:00
Your NameandClaude Sonnet 5 05ee23aefa 群成员格子改用头像外角标 ⊖ 移出,替换上一版的行内文字按钮
上一版把 ➖ 加在了"查看全部"弹层的成员行文字按钮里,用户纠正:应该是紧凑头像格子
(群聊设置面板里"查看全部"之前那一屏)里,像"+邀请"那样在头像外侧显示一个独立的
角标,而不是列表行内的文字。canRemoveMember 挪到 lib/api/groups.ts 共享,避免
两处判断逻辑分叉。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 17:19:45 +08:00
Your NameandClaude Sonnet 5 2d589cbe6b 群成员移出按钮补充 remove_circle 图标
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:58:34 +08:00
Your NameandClaude Sonnet 5 304283c8d8 fix(messages): @mentions leaked the sender's private remark
Reported + reproduced live: a group owner's own private friend remark
for a member ("小号1"/"私密备注勿外传" in testing) ended up baked into
that member's @mention text, visible to every other group member with
no idea what it referred to.

Root cause confirmed by reading server/im_http.go's userSummaryPayload:
GET .../members?mentionable=true's `display_name` unconditionally
prefers the CALLING viewer's own remark for that member when one is
set. That's correct for a list only the viewer sees, but MentionPicker
was using it to build the mention's label, which gets baked into the
message's shared Drafty content once (mentions.ts's buildMentionedContent)
and read identically by everyone from then on -- unlike a sender-name
label, which every viewer's own client re-renders fresh per message and
can legitimately show their own remark for.

Switches MentionPicker's label (both the picker's own visible list and
the mention baked into the sent message) to the same response's
`nickname` field (copyUserNickname in the same backend function),
which reads straight off the member's public profile and is never
remark-shadowed. Verified live: set a real friend remark, confirmed
the picker still showed the real nickname (not the remark), picked it,
and confirmed the actually-sent message's @mention text was the real
nickname too -- while the unrelated sender-name label elsewhere in the
same conversation correctly kept showing the remark, confirming the
fix didn't touch that intentionally-different behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:10:03 +08:00
Austin cbb01085c4 fix(reply): 修复回复消息中媒体引用的兼容性问题
适配了 Drafty 媒体的 `refurl` 字段,同时兼容旧的 `ref` 和 `val` 字段,确保不同来源的媒体链接都能正确解析用于渲染。
2026-09-23 21:37:07 +08:00
Austin a8d3504424 feat: 实现跨端引用回复协议,新增 QuoteBar 组件支持消息引用
本次提交实现了与 iOS 对齐的跨端消息引用协议,新增 QuoteBar 组件用于渲染引用消息栏,重构发送逻辑支持文本和媒体消息的引用功能。主要改动包括:
- 新增 reply.ts 处理引用数据的构建和解析
- 新增 QuoteBar 组件渲染引用消息预览
- 重构发送逻辑,将引用快照放入消息头部而非内容
- 调整代理配置指向本地开发环境
- 优化消息操作权限,支持名片和媒体消息引用
2026-09-23 20:40:47 +08:00
Your NameandClaude Sonnet 5 fb92f4f703 fix(groups): hover affordance on the remove-member button
Reported: the new "移出" (remove member) button gave no visual
feedback on hover, so it didn't read as clickable. Tailwind's own
preflight reset sets buttons to cursor:default (not the browser's
usual pointer) unless a cursor utility says otherwise -- adds
cursor-pointer + hover:underline, verified live via
getComputedStyle/:hover that both apply while hovering and correctly
reset on the disabled (in-flight) state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:27:17 +08:00
Your NameandClaude Sonnet 5 4961c0ae6e fix(groups,system): remove-member action + stale invite button
Adds removeGroupMember (DELETE /groups/{topic}/members/{user_id}) --
confirmed live the backend now has this route (server/im_http.go's
serveIMGroupMember + canRemoveIMGroupMember), which an earlier pass
recorded as a genuine protocol gap; it isn't anymore. Wired into
GroupMembersModal with a per-row "移出" button gated by the same rule
the backend enforces (owner or admin removes a plain member, only the
owner removes an admin, nobody removes the owner or themselves), with
a confirm dialog before the real DELETE call. Verified live end-to-end
against a real owned group: member count 4 -> 3, list updates
correctly (the side panel's own separate "群成员 N" summary count
still trailed behind after this -- confirmed via a direct GET that the
server's own group payload hadn't recalculated member_count yet, a
backend read-consistency lag unrelated to this fix, not chased
further).

Also fixes the reported "已处理的群邀请消息,即使群解散了,接受邀请
按钮还是一直可以点" bug: the button's visibility used to be derived
purely from the notification's static action payload every render, so
neither a successful accept nor a definitively-failed one (expired,
already handled, group gone) ever stopped it from looking freshly
clickable again. Tracks per-invitation resolved state (accepted/
failed) once either outcome happens and swaps the button for a static
resolved label. Doesn't survive a page reload for invitations resolved
in an earlier visit (the notification record itself carries no
resolved flag to read back) -- a real, acknowledged limit, not
silently hidden.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:08:07 +08:00
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
Austin 0c38adba78 fix: 修复登录时因租户不一致导致的422策略违规错误
在连接建立时记录服务器实际使用的租户代码,而非直接从存储中读取,避免登录时的租户类型判断与会话租户不一致导致的TENANT_CODE_FORBIDDEN错误。新增登录失败后的自修复重试逻辑,当收到422错误且原因是租户代码被禁止时,临时覆盖登录类型并重试一次,同时重置相关状态避免影响后续连接。
2026-09-22 20:17:39 +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
Austin ca7af8baf9 feat(acc-support): 实现动态获取并渲染联系我们条目
新增从后台获取管理员配置的联系我们数据的功能,支持邮箱和电话的跳转链接, fallback 到旧的固定链接配置
2026-09-21 13:12:56 +08:00
Austin 4dadb1bd5a fix(tinode/errors): 处理429账号锁定错误的提示信息
清理429错误消息末尾的状态码后缀,直接使用服务器返回的中文提示,无匹配时使用备用文案
2026-09-20 17:08:59 +08:00
Austin 1952f0ebcd fix(messages): never render raw uid as group sender name (#32)
Senders missing from the member list (left/removed) fell back to msg.from verbatim, and nameless members arrive from the REST API with the uid itself as display_name. Resolve both through a shared groupSenderLabel helper that substitutes a fixed placeholder for uid-shaped names (message bubbles, quoted replies, history search).
2026-09-20 16:42:35 +08:00
Austin 41728017cb Merge branch 'dev' of https://git.118im.com/im_team/im_webapp into dev 2026-09-18 18:34:21 +08:00
Your NameandClaude Sonnet 5 7cf8e4f4e0 fix(groups): correct rename-sync event name and unreliable getMeta path
The previous fix listened for a documented-but-wrong event name
(group.profile.updated); live instrumentation of a real rename showed
the actual wire event is group.settings.updated. Fixing only the name
still didn't work: the refresh mechanism it triggered
(getMeta(startMetaQuery().withDesc().build())) was itself unreliable
against this backend -- live-confirmed to either time out (504) on a
brand-new topic or return stale pre-rename data on an established one,
which also explains the "second rename flips back to the original
name" symptom (a late, stale response from an earlier call clobbering
the correct value).

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

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

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

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

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

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

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

Live-verified: recalling a message now shows "⚠ 消息已删除" on the
other party's screen immediately, with no manual refresh.
2026-09-18 16:09:52 +08:00
Your Name 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