Commit Graph
124 Commits
Author SHA1 Message Date
richjohnrambo 361e23b9ec fix p2p conversation avatars 2026-10-14 16:09:38 +08:00
richjohnrambo 8a26a4ccf9 fix production attachment file origin 2026-10-14 15:22:46 +08:00
richjohnrambo c1338985d4 chore: remove unused service worker cleanup 2026-10-14 14:24:37 +08:00
richjohnrambo 440e29975a chore: remove unused web code and dependencies 2026-10-14 14:10:41 +08:00
richjohnrambo ec22b709b5 fix legacy attachment URLs in Vite web 2026-10-14 13:58:33 +08:00
Your Name dd77ba903b Queue message sound while audio is priming 2026-09-30 17:05:32 +08:00
Your Name 06df51ca08 Prime message audio and silence duplicate OS notification sound 2026-09-30 17:01:34 +08:00
Your Name 1c17165c86 Brand web client and add message notification sound 2026-09-30 16:50:14 +08:00
Your Name 7b8216ea70 fix(web): prevent negative unread counts 2026-09-30 15:58:34 +08:00
Your Name bd61be8a86 feat(web): support group avatar updates 2026-09-30 15:28:50 +08:00
Your NameandClaude Sonnet 5 975dc77111 Revert "网页版支持粘贴/选择多张图片暂存,可配文字一起发送"
用户反馈这个交互做得不够好,要求退回上一版(保留粘贴发图/保存图片,去掉多图暂存+图文一起发)。

This reverts commit 6bcfc08943.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:12:29 +08:00
Your NameandClaude Sonnet 5 6bcfc08943 网页版支持粘贴/选择多张图片暂存,可配文字一起发送
老板反馈:粘贴图片、选择文件都是立即发送,体验不好;想要能粘贴多张图片,并且可以带着文字一起发,
不带文字也要能发。

- 粘贴图片、点击图片图标选图(新增 multiple 多选)现在都不再立即发送,改成暂存进输入框上方的
  缩略图预览条,可以继续粘贴/选择叠加更多图片,每张都能单独点掉重新移除
- 发送(Enter / 点击发送按钮)时,把暂存的图片按添加顺序依次发出去,再发文字(如果有输入的话);
  没有文字、只有图片也能发;没有图片、只有文字保持原有行为不变
- 引用回复只附着在这一批里实际发出的第一条消息上,跟"引用只作用于下一条消息"的既有规则保持一致
- 单张图片发送失败不影响这一批里的其他图片和后面的文字,各自独立报错提示
- 参考设计图(语音图片发送状态.png)确认了这个后端每种附件类型都是独立气泡,没有"图文合并成一条
  消息"的协议形态,所以是按多条消息依次发送实现的,不是改协议

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 03:06:24 +08:00
Your NameandClaude Sonnet 5 3d48abdf73 网页版支持粘贴图片发送、聊天图片保存到本地
老板反馈:"没有复制粘贴图片功能,只能保存在电脑里面发,图片也没有保存功能"

- 聊天输入框新增粘贴图片检测:剪贴板里有图片(截图工具/复制图片)时直接走发图逻辑,
  不再要求先存到电脑里再用文件选择器上传;普通文本粘贴不受影响
- "查看大图"预览里新增"保存图片"下载按钮,取消息自带的原始文件名,
  取不到时按mime类型生成默认文件名

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 02:42:21 +08:00
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 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