Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.
Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Round 2's dedup fixes for connect()/openConversation left two gaps that
only showed up under closer scrutiny (and, for the first one, actual
live reproduction):
- closeConversation() awaited the shared in-flight subscribe promise
before calling leave(), but didn't check whether a NEWER
openConversation() had since claimed the same topic while it was
waiting -- under React 19 StrictMode's mount->cleanup->remount, the
stale cleanup's leave() would detach the subscription the surviving
mount had just established, silently killing live updates for
whatever conversation you'd just opened. Fixed with a per-topic
generation counter: closeConversation only actually leaves if no
newer open has since claimed the topic.
- prefetchPreview (chat-list preview subscribe) never joined the same
in-flight tracking as openConversation, so opening a conversation
while its own preview subscribe was still resolving could still send
two overlapping {sub} packets at the same topic.
- resetTinode() didn't clear connectPromise/restorePromise or
disconnect a connection that was still mid-handshake (only guarded on
isConnected()) -- switching tenants while the old connection was slow
to respond could leave a new login silently waiting on the abandoned
old connection's outcome.
- subscribeMeTopic() got the same in-flight dedup as the others, for
consistency (no concrete concurrent caller today, cheap insurance
against a future one).
Also:
- getBlocks()'s response type was a flat Friend shape; the real
contract (confirmed against both the protocol doc's JSON example and
the old app's own consumer) nests the user under `.user`, so "already
blocked" state in DetailColumn was silently always false.
- useBootstrap() had no tenant in its query key and staleTime:Infinity,
so switching tenants without a full reload kept serving the PREVIOUS
tenant's message_policy/features/permissions/security forever.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- RequireAuth: a page reload lost the session even with a valid token
sitting in sessionStorage -- myUserId is never persisted (by design)
but nothing ever re-derived it from the reusable login token. Added
connection.ts's restoreSession(), which also fixes the race the first
attempt at this ran into live: React 19 StrictMode double-invokes the
restore effect in dev, and Tinode.loginToken() isn't idempotent (second
call on an already-authenticated connection gets 409'd), so both the
connect() and the login itself needed module-level in-flight dedup,
not just a per-effect cancelled flag
- topics.ts: same dedup pattern for openConversation/closeConversation --
StrictMode's mount->cleanup->remount always overlapped an open with
itself, and a leave() landing before its own subscribe() resolved was
silently no-op'ing instead of detaching
- LOGGING_ENABLED was unconditionally on, including prod builds --
Tinode's verbose logging prints the raw login secret and full message
content to the console
- imPost/imPatch/imPut/imDelete could silently drop the Idempotency-Key
header for a caller that passes its own headers (object-spread order
bug, dormant today since no call site does this yet)
- DetailColumn's getFriends()/getBlocks() only ever read page 1 despite
fetchAllPages existing specifically to prevent this bug class
- availableActions() now defers to the server's message_policy.
supported_actions when present instead of only a client-side time
window, and exposes recallRemainingSeconds for the context menu's
countdown label
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three parallel adversarial code-review passes surfaced real defects across
the app. This commit covers the data/logic layer:
- CRITICAL: file upload/download used the wrong auth header entirely.
Built from docs/IM-前后端对接文档.md as `X-MeetingIM-Auth: token <token>`,
but the old app's own upload code carries an explicit comment that the
real header is `Authorization: Token <token>` (capitalized scheme,
standard header name), "verified live against the real server, not just
read from source" -- and separately warns the SDK's own built-in
LargeFileHelper gets this wrong too (sends X-Tinode-APIKey/X-Tinode-Auth,
which this backend doesn't recognize). This means every external
(non-inline) image/file/voice attachment -- anything too large to send as
base64 -- was silently broken; only the earlier live tests happened to
use small inline attachments, which never exercise this code path.
Confirmed fixed live: a real >256KB image now uploads (200, real file_id)
and downloads (blob URL, correct 800x600 dimensions) correctly.
- CRITICAL: TopicMe.onContactUpdate was never wired. Marking a conversation
read (topic.noteRead()) updates the contact's unread count through ONLY
this single-slot callback (confirmed against the SDK's own
Topic.note()/TopicMe._refreshContact -- it does not also fire onData/
onInfo/onPres, unlike every other _refreshContact call site, which do).
Without it the chat list's unread badge/ordering went stale after reading
a conversation until an unrelated topic event happened to invalidate the
shared snapshot cache.
- HIGH: chat-list message preview didn't check isServerDeleted(msg) (the
`hi` soft-delete flag), only `content == null` -- a hi-flagged message
with non-null content leaked its real text/attachment label into the
preview instead of showing "[消息已删除]".
- MEDIUM: upload failures showed raw untranslated SDK ctrl.text (e.g. "too
large" instead of "文件大小超出限制") -- ported the old app's
UPLOAD_CTRL_TEXT_CN table, which it built specifically because this
native-protocol endpoint's errors never pass through the REST layer's
own reason-code translation.
- Ambient SDK type corrections (tinode-sdk.d.ts): AccessMode.mode/given/want
are numeric bitmasks at runtime, not the "JRWPS"-string type they were
declared as (dormant today, nothing reads them yet, but wrong ahead of
Phase 3's group-role logic); Drafty.sanitizeEntities's doc comment
claimed a deep copy it doesn't do (verified it mutates entity .data in
place); onAutoreconnectIteration's first param was misnamed "seconds"
when the SDK always passes milliseconds.
- sendVoice() now passes `filename` to Drafty.insertAudio (was the only one
of sendImage/sendFile/sendVoice omitting it).
- friends.ts's accept/reject-friend-request `version` param used a falsy
check (`version ? ... : ...`) that would silently drop a legitimate `0`.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- lib/api/upload.ts: POST /v0/file/u/ (docs/IM-前后端对接文档.md:460-461) --
a separate surface from /v1/im/*, at the tenant's file base URL, auth'd
with X-MeetingIM-Auth: token <token> rather than Authorization: Bearer
(matches upstream Tinode's own LargeFileHelper convention).
- lib/tinode/attachments.ts: sendImage/sendFile. Files <= the inband limit
go in directly as base64 (Drafty.insertImage's `bits` field / attachFile's
`data` field) with no upload round trip; larger ones upload first and
reference the returned URL. Simplified from the old app's urlPromise
dance (optimistic local preview while the upload runs in the background)
by just awaiting the upload before creating the message -- less
optimistic, meaningfully simpler.
- AttachmentPreview.tsx: image/file bubble rendering. Externally-referenced
(non-inline) attachments need an authenticated fetch into a blob URL,
not a plain <img src>/<a href> -- GET /v0/file/s/{file_id} requires the
same custom auth header uploads do, which a bare element can't attach.
- drafty.tsx: wired the 'IM'/'EX' Drafty style-tag cases to these.
Verified end to end: sent a real inline image (confirmed the exact IM
entity shape in the outgoing WS packet, and that the <img> decoded and
loaded from its own base64 data) and a real inline file (rendered with the
correct icon/size/download affordance).
Known gap: voice messages (MediaRecorder + AU entities) not built yet --
meaningfully more complex (hold-to-record gesture, audio encoding,
duration/waveform) and deliberately left for a following pass rather than
rushed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>