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>
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>
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>
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>
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>