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