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>
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.
Aliyun OSS sometimes returns presigned URLs as http:// even though every
page here is served over https:// -- the browser silently blocks these
as mixed content, which is why images (e.g. avatar) failed to display
after upload. Added upgradeToHttps() and wired it into fileDownloadUrl()
(the single chokepoint every image/attachment fetch in the app resolves
through) plus the three chat-attachment send paths, so both freshly
fetched and newly-persisted URLs get the scheme fix.
Also fixed ProfileSettingsPage's avatar upload handler, which extracted
the file id by parsing the upload response's `url` with .split('/').pop()
instead of just reading the `file_id` field the response already
provides -- fragile against exactly this kind of full-URL response and
the most likely actual trigger for the reported bug.
vite.config.ts: dev server port now respects $PORT if set, falls back
to 8094 -- unrelated cleanup, needed it to run a second local dev
server for verification alongside another concurrent session's.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Old VM's blue-green im-webapp is decommissioned; build+push now
targets the mutable :test tag the cluster's kustomize overlay
already tracks, then rolls the k3s deployment instead of running
/opt/apps/im-webapp/deploy.sh.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The old service-worker.js (repo root, removed when web/ was rewritten)
cached every same-origin GET keyed only by frontend PACKAGE_VERSION, so
a backend-only change never invalidated it. Browsers that had visited
this origin before the rewrite kept getting stale cached API responses
(bootstrap/session calls) served instead of hitting the real backend --
looked like "login succeeds then immediately bounces back to login".
Adds a same-path kill-switch service-worker.js that purges all caches
and unregisters itself, plus an explicit update() check on app boot so
already-registered browsers don't have to wait for the browser's own
lazy update schedule.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
public/favicon.svg was a leftover scaffold placeholder -- a generic purple
gradient blob, unrelated to this app's actual brand mark (a plain #1677ff
rounded square with a white "M", as literally rendered on the login/
tenant-entry screens: `bg-accent` + bold white "M"). Replaced it with an
SVG matching that exact design so the browser tab icon is recognizably the
same mark as the rest of the app.
Also removed public/icons.svg while in here -- a sprite sheet of generic
social-media icons (bluesky/discord/github/x) from the same leftover
scaffolding, confirmed unreferenced anywhere in the app.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
文件助手 is a permanent pinned entry that redirects into a real conversation on
the slf topic (see PINNED_ENTRIES's own doc comment -- there's no dedicated
file-browser API to back a real one). Once that topic has any message
history it also comes back from useChatList() as an ordinary contact, so it
rendered a second time as its own chronologically-sorted row further down --
confirmed live: navigating to /messages/slf via either row satisfied both
rows' own "am I active" check at once, highlighting both simultaneously.
Filters the slf topic out of the ordinary contacts list; 文件助手 stays the
one and only way it's represented there.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The deploy pipeline (.gitea/workflows/deploy.yml -> Dockerfile) never got
updated when the frontend was rewritten under web/ -- it was still doing
`npm ci`/`npm run build` at the repo root and copying the old app's umd/,
css/, index.html, etc. into the nginx image. Every deploy therefore kept
shipping the old app regardless of what was actually on dev, no matter how
much of the rewrite had landed.
Dockerfile now builds from web/ (Vite) and copies its self-contained
dist/ output (index.html + content-hashed assets/, no separate umd/css/
img/audio/manifest/service-worker/firebase files -- none of that exists
in the new app). VITE_TINODE_API_KEY is what src/lib/config.ts actually
reads (Vite only exposes VITE_-prefixed env vars to client code), so the
existing TINODE_API_KEY build-arg is now renamed into that on its way
into the build environment instead of passed through under its old name.
nginx.conf updated to match: index.html stays no-cache (its URL never
changes, so a stale cached copy would keep pointing at deleted asset
filenames after a deploy), assets/ gets a long immutable cache (Vite
content-hashes every filename there, so a new build never reuses an old
URL). Dropped the old umd/ and img/ location blocks, neither exists in
the new build.
Verified locally: `docker build` succeeds, the container serves the new
app's real index.html/assets with the intended cache headers, and an
unknown client-side route (/messages/slf) correctly falls back to
index.html.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
- FileAttachment never surfaced a failed authenticated download -- the
card looked identically clickable whether it worked or not, and
clicking while broken silently no-op'd. Added an error state/retry
(useAuthedBlobUrl gained a retry() escape hatch for this).
ImageAttachment/AudioAttachment already handled this; only the file
card didn't.
- --shadow-btn was one token serving two different specs: round 2's fix
(0 6px 16px, from the 50px auth CTAs) actually regressed the 36px
messaging send button (ConversationView/VoiceRecorder), which needs
0 4px 12px per 02-messages.dc.html. Split into --shadow-btn /
--shadow-btn-sm.
- loadOlder's scroll-restore guard compared topicId by value, which
false-positives on an A->B->A round trip within one request's
lifetime (same string, but the DOM was torn down and rebuilt by the
intervening open/close). Switched to a generation counter, same
pattern as topics.ts's leave-race fix.
- errorToast wasn't included in ConversationView/DetailColumn's
topicId-reset effects -- a failed action's toast could keep showing
over a conversation/contact switched to afterward. Also switched
those effects (and MessagesLayout's) from useEffect to
useLayoutEffect -- since none of these components remount on
topicId change, a post-paint effect could let one frame render with
the previous topicId's stale UI still showing.
- VoiceRecorder never listened for MediaRecorder's own onerror -- a
mid-recording failure left the "录音中" timer ticking forever with no
indication the recording had already died.
- VerificationCodeButton's phone-change countdown reset (round 2) only
cleared the button's own state; RegisterPage/ResetPasswordPage's
challengeId was still tied to whatever phone the code was actually
issued for, with nothing cross-checking it against a since-edited
phone number.
- Agreement's checkbox used bare accent-color on a native input, which
only tints the checked fill and leaves size/radius/border to the
browser/OS; replaced with the spec's exact 16x16 border-radius:4px
box + check glyph.
- Added a catch-all route (App.tsx), TenantFeedbackPage image-picker
validation feedback + corrected remove-button style, and a handful of
smaller token/spacing corrections against screens/*.dc.html (NavRail
font-weight, LoginPage/TenantEntryPage logo size+shadow+color,
ChatListColumn row padding/icon size, --shadow-modal alpha).
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>
- DetailColumn: remark-edit draft and the history-search modal's open
state weren't reset on contact switch (no key={topicId}, same long-
lived instance) -- could silently save a typed remark against the
WRONG contact. History-search state lifted to MessagesLayout so
ConversationView's header search icon (previously wired to nothing)
can open the same modal
- ConversationView: loadOlder's in-flight "restore scroll position"
callback and its loading flag weren't guarded against a conversation
switch happening mid-request, and the scroll-to-bottom effect keyed
only on messages.length so two conversations that happened to load
the same page size never got scrolled down on open
- ChatListColumn: pinned 系统消息/文件助手 rows used solid-color-square
avatars like regular contacts; the actual design-code source
(ChatList.dc.html) and reference screenshots use a light tinted
background with a colored icon instead
- MessageBubble: own ("我") messages hash-colored their avatar like any
contact instead of the design's fixed accent blue
- Every send/recall/favorite/forward action swallowed its own failure
silently (empty .catch or, for forward, no catch at all) -- moved
Toast from features/auth into app/ (shared) and wired real error
feedback through it
- MessageContextMenu: added the recall countdown label, Escape-to-close,
and viewport-edge clamping (a right-click near the window edge could
render partially off-screen)
- EmojiPanel now defaults to 最近 per the reference (was defaulting to
表情)
- HistorySearchModal: pagination (searchTopicMessages/getTopicMedia both
supported cursor/next_cursor already, nothing used it -- silently
truncated at one page), Escape-to-close, and a brief "没有找到" flash
on the first keystroke before the debounce caught up
- A handful of design-token corrections (--shadow-btn, NavRail's border/
spacing, LoginPage's logo radius/brand-panel padding, a placeholder
icon color) verified against screens/*.dc.html's exact pixel values
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>
Confirmed live against the real backend: docs/IM-前后端对接文档.md's flat
`/media` items ({thumbnail_file_id,file_id,width,height}) and flat search
hits ({file:{...}}, {url,title,...}) don't match what the server actually
returns. Both endpoints really return the raw original message (`{txt,fmt,
ent}`, search hits wrapped one level deeper in `snapshot`) -- the same shape
lib/messages/drafty.tsx's renderDrafty already knows how to render, reused
here instead of hand-picking fields from a shape that doesn't exist. This is
also why image/video thumbnails were rendering as generic icons: the field
they were reading was never present in a real response.
Extracted useAuthedBlobUrl (authenticated file-fetch-to-blob-URL) out of
AttachmentPreview into lib/hooks.ts so the history modal's own thumbnail
grid can reuse it instead of duplicating the fetch/header logic.
Also adds the URL safety-check gate (docs/IM-前后端对接文档.md:1822,1898)
before opening a link found via the 链接 tab, which this view didn't have
at all before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- EmojiPanel: add missing 手势/物品 categories, 12-column grid per README §3.3
- DetailColumn: add 投诉举报 row, real ID/region/签名 via new getUserProfile
API, chevron_right icons now use --color-icon-arrow token (were tertiary
text color), regroup rows so dividers only sit between sections not every
row (matches design-code reference exactly)
- MessageBubble/ConversationView: right-clicked message bubble now gets the
spec's outline while its context menu is open
- ChatListColumn/AppShell: pinned 系统消息/文件助手 rows using confirmed
bootstrap entries/entry_badges topic names ("sys"/"slf"); route to the
existing SectionPlaceholder pattern since their own dedicated layouts are
Phase 8, not built yet
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- CRITICAL: draft text and voice-recording state were never isolated per
conversation. ConversationView is one long-lived instance reused across
every topic (`:topicId` route param change doesn't remount it), so a
single `draft` string leaked one conversation's unsent text into whatever
conversation was opened next -- and, far more seriously, starting a
voice recording in conversation A and then switching to conversation B
before hitting send would publish that recording into B: VoiceRecorder
keeps its own MediaRecorder running across the switch, but the onSend
callback passed to it is a fresh closure over whatever topic is CURRENT
when it finally fires. Fixed by keying drafts per topicId and force-
resetting `recording` (unmounting VoiceRecorder, which stops its stream)
whenever topicId changes.
- CRITICAL: the detail/settings panel was completely unreachable below
960px -- it only ever rendered inside `lg:block` (>=1280px, docked) or
`md:block lg:hidden` (960-1279px, drawer) wrappers, so tapping the header
"i" button below 960px flipped internal state and fired its data queries
with zero visible effect. Added the README §1.4-mandated <960px full-page
overlay tier, and a small new useMediaQuery hook so the header's "i"
button highlight reflects whether the panel is ACTUALLY visible right
now instead of being hardcoded to a breakpoint (it was previously always
blue at 960-1279px regardless of the drawer's real open state, and always
grey at >=1280px despite the panel being permanently docked open there).
- CRITICAL (visually confirmed against 1a-desktop-chat.png): messages you
send never showed an avatar at all (just an empty spacer) -- every
reference screenshot shows your own "我" avatar beside your bubbles the
same as the other party's. MessageRow now takes myUserId and renders it.
While in there, also moved the failed-send error icon beside the bubble
(README: "气泡左侧") instead of below it with the retry text.
- CRITICAL: ChatListColumn's search box had no value/onChange at all --
typing into it did nothing. Now filters by contact name; added the
search_off/"未找到相关会话" and forum/"暂无会话" empty states README §2.1
calls for, neither of which existed before.
- MEDIUM (real Tailwind cascade bug): the selected chat-list row's
highlight was being overridden by the unconditional `hover:bg-[...]`
class on the same element -- Tailwind emits hover-variant rules after
base rules regardless of source order, so hovering an already-selected
row visually un-selected it until the mouse moved away. Scoped the hover
class to the non-selected branch only.
- NavRail: inactive icons used the wrong color token (icon-default #555c65
instead of the spec's #656b74, this app's text-secondary); the unread
badge prop was never actually supplied by AppShell, so the badge's
otherwise-pixel-correct rendering never appeared in the running app.
Fixed both, and gave BottomTabBar's 消息 tab the same real badge (computed
once in AppShell from useChatList(), passed to both).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- CRITICAL correctness bug: resetTinode() was defined but never called
anywhere. Switching tenants (TenantEntryPage) only updated the session
store; the Tinode singleton it drives is created once and permanently
cached (getTinode() lazily creates it, reusing it forever afterward), so
every subsequent login kept talking to the FIRST tenant's host regardless
of which tenant was actually selected -- multi-tenant switching was
silently broken. Also moved LoginPage's getTinode() call to after its
"no tenant yet" redirect guard, so it can't get created against a
not-yet-resolved tenant in the first place.
- Error strips across all four auth pages were missing the
error_outline icon README §10.1 specifies.
- Fixed a token mix-up affecting every error-state input border across
Register/PasswordInput: README's exact spec is border #d94b4b (this app's
--color-danger-text) with #cf3f3f text below (--color-danger-border) --
the code had them backwards, borders were using danger-border.
- TenantEntryPage: added the missing 企业注册/遇到问题 links (now real,
working pages -- see below), fixed the business icon's color token
(was accent #1677ff, spec calls for focus-border #4091ff).
- LoginPage/ResetPasswordPage: the "切换企业"/"返回登录" back link was nested
inside the vertically-centered form container, so it got pulled to the
page's middle instead of staying pinned to the top like every reference
screenshot shows it. Also gave LoginPage's brand panel the spec's 56/44
split (was an even 50/50), 40px headline (was Tailwind's 36px text-4xl),
and the missing footer links row; added a mobile-only large-logo facade
(matches 1e-mobile-login.png) since the desktop split-panel brand block
is hidden below 900px and mobile had no tenant-identifying visual at all.
- RegisterPage: removed an extra 56px-logo+tenant-name block that appears
in neither the desktop nor mobile reference screenshots; added the
mobile-only 48px back-chevron+title header bar the design calls for
(RegisterPage previously had zero back-navigation on any breakpoint).
- ResetPasswordPage: the password error text said "需要包含字母和数字" while
the actual check only enforces a minimum length (>=8, no character-class
requirement, matching Register's own rule) -- the message was promising
a validation this page doesn't perform.
- VerificationCodeButton: the resend countdown's setInterval was never
cleared on unmount -- a real leak if the user navigates away mid-countdown.
- New: EnterpriseRegisterPage (企业注册, README §10.5) and
TenantFeedbackPage (问题反馈, README §10.6 / `01/1j`), both previously
linked-to-but-nonexistent. lib/api/tenant.ts gained the REST calls they
need (submitTenantApplication, submitTenantEntryFeedback,
uploadPublicFile against the pre-login /v1/im/public/uploads surface,
distinct from the chat-file upload endpoint).
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>
Rounds out the multi-select bottom bar (README §3.6) with 转发/收藏 alongside
the existing 删除 -- reuses the single-message ForwardPickerModal/
favoriteMessages/forwardMessage machinery from the context-menu actions,
just applied to the whole selected set. One "转发" button (not separate
逐条/合并 buttons) matching the old app's real dialog-internal mode toggle,
which this app's simplified single-target picker doesn't model -- already
noted as a scope cut in ForwardPickerModal's own docstring.
Verified live: entering multi-select via a message's context menu shows
转发/收藏/删除, and bulk-favorite POSTs a real /v1/im/favorites/batch request.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Click-to-record (README §3.3's explicit 取消/发送 buttons), not the old
app's press-and-hold-with-swipe-to-cancel gesture -- the new design replaces
that touch gesture with plain buttons, a reasonable desktop-first
adaptation; the underlying MediaRecorder mechanics (audio/webm or
audio/mp4 depending on browser support, 24kbps, 2s minimum/10min maximum
duration) are ported as-is from the old app's audio-recorder.jsx.
- VoiceRecorder.tsx: recording UI + MediaRecorder lifecycle. Silently
discards (no send) recordings under MIN_DURATION, matching the old app.
Deliberately skips the `webm-duration-fix` package the old app pulled in
for Chrome's well-known missing-webm-duration-metadata bug -- this app's
own displayed duration comes from its own recording timer, not the file's
embedded metadata, so only a browser's native <audio> scrubber would be
affected, not anything this app actually shows.
- attachments.ts: sendVoice() -- inline base64 or upload-then-reference,
same size-based branch as images/files, Drafty.insertAudio's confirmed
{mime,val/ref,duration,size} shape.
- AttachmentPreview.tsx: AudioAttachment play/pause button + static
waveform (CSS repeating-gradient, not real waveform analysis) + duration.
Verified the failure path live (this sandbox blocks getUserMedia): the
recorder's error state shows a clear message and recovers cleanly back to
the normal composer with no crash. The actual record -> encode -> send ->
playback round trip could not be exercised in this environment and needs
a manual check with real microphone access.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Plain system-emoji grid (README §3.3's mockup itself has no custom emoji
asset list, just gray placeholder tiles captioned "use system emoji or a
branded pack" -- matches the old app's own reasoning, no WeChat/QQ-style
sticker art available). Recent-emoji tab persists via localStorage, capped
at 30, same as the old app's send-message.jsx.
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>
- ConversationView: infinite-scroll "加载更多" -- scroll-to-40px triggers
topic.msgHasMoreMessages()+getMessagesPage() to fill exactly the missing
seq ranges (not naive since/before paging, since server-side deletions can
punch holes in an otherwise-contiguous range), with scroll position
preserved across the prepend.
- lib/tinode/send.ts: sendReply() ports the old app's exact quote
construction (Drafty.replyContent truncates, Drafty.quote wraps with the
sender's name, Drafty.append inlines the whole quote block ahead of the
new text -- the receiving side never needs to look up the original
message). forwardMessage() sends a single message directly to one picked
target topic (ForwardPickerModal) -- simplified from the old app's
stage-then-compose flow, trading that nicety for a much smaller dialog.
- lib/messages/drafty.tsx: added the 'QQ' Drafty style-tag case ported
against the SDK's actual Drafty.quote() output shape (no `key`/`data` of
its own -- verified by reading tinode.dev.js, not guessed).
- lib/api/messages.ts: favoriteMessages() -- a single message is just a
one-item batch against POST /v1/im/favorites/batch (confirmed in
docs/IM-前后端对接文档.md:1935).
- Context menu now shows 引用/复制/收藏/转发/多选/[撤回]/删除 in spec order
(README §3.5); 翻译 still deliberately absent (no real backend for it,
confirmed by research).
Verified end to end: sent a real quote reply and confirmed the exact wire
packet (head.reply, QQ fmt span, MN entity) and that it renders correctly
via the new QQ formatter case; favorite POSTed and returned created_count:1;
forward POSTed the expected {head:{forwarded:"topic:seq"}} packet.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Four states, one shown at a time (offline > reconnecting > just-reconnected
> realname-reminder priority): no-network (navigator.onLine + online/offline
listeners), reconnecting-with-countdown, just-reconnected, and a skippable
realname-verification reminder (bootstrap.security.realname).
- connection.ts: claims Tinode's onAutoreconnectIteration(timeoutMs, promise?)
callback (single-slot, same pattern as onConnect/onDisconnect) and runs its
own 1s-tick countdown off the first call's timeout -- the SDK gives the
backoff length once per iteration, it doesn't push a running countdown.
Verified the callback's exact three-shape contract (timeoutMs alone starts
a countdown; 0+promise means a retry is now in flight; -1 means retrying
stopped) by reading the SDK's own Connection class, not guessed.
retryReconnectNow() backs "立即重试": clears the countdown and calls
tinode.reconnect(true).
- "已重新连接,正在同步 N 条新消息" intentionally shown without a fake N --
confirmed via research that the old app never implemented this state at
all, and there's no reliable signal here for how many messages arrived
while disconnected; inventing a count would be dishonest data.
Verified live: toggling browser online/offline events correctly shows and
clears the no-network banner.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Closes a real visual gap flagged directly: the detail panel jumped straight
from the quick-action buttons to 备注/置顶聊天/etc, skipping the "查找聊天
记录" row + 4-icon media grid (图片/视频/文件/链接) that's prominently
visible in the reference design (README §3.4) -- deliberately deferred in
an earlier pass, but a dead-end-free real implementation was worth building
now rather than leaving a visible hole.
- lib/api/messages.ts: searchTopicMessages/getTopicMedia, endpoint shapes
from docs/IM-前后端对接文档.md:1251-1254 (verified live: both return real
200s against the actual backend, not just matching the docs on paper).
- HistorySearchModal: centered modal (README §1.5's 弹层 pattern) with the
five tabs, debounced search (lib/hooks.ts's useDebouncedValue, ported from
the old app's use-cached-data.js), empty/loading states.
- DetailColumn: wires the row + grid to open the modal on the right tab.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
avatarBgClass() built its class name via template-literal interpolation
(`bg-avatar-${i + 1}`). Tailwind's build finds utility classes by statically
scanning source text for complete class-name strings -- a dynamically
assembled name is invisible to that scan, so `.bg-avatar-1` through
`.bg-avatar-11` were never generated in the compiled CSS at all. Every
avatar in the app (chat list, conversation header, message bubbles, detail
panel) rendered with a fully transparent background instead of its color --
the element's className looked correct in the DOM, which is why this
shipped across several commits before being caught by checking computed
style rather than the class name itself.
Fixed by writing the 11 class names out as a literal array Tailwind's
scanner can actually see.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Right-click a message for 复制/撤回/删除/多选 (README §3.5). 引用/收藏/翻译/
转发 deliberately left out of this pass -- quote and forward need their own
composer-state flows, and there's no confirmed single-message favorite
endpoint yet; a menu item that doesn't work is worse than a smaller correct
menu.
- lib/messages/actions.ts: gates recall to own messages, server-confirmed
seq (not the SDK's local pending-seq placeholder), and bootstrap's
message_policy.recall_window_seconds -- scoped to P2P for now, no
group-admin recall yet.
- lib/api/bootstrap.ts + api/hooks.ts: first GET /bootstrap wiring
(message_policy, forward limits, etc.), cached indefinitely per session.
- lib/api/messages.ts: recall goes through REST, not native WS -- and
requires a body `{version, reason}` this backend tracks per-message
independently of Tinode's own seq (confirmed in
docs/IM-前后端对接文档.md:1248 and by hitting a live 400
MALFORMED_REQUEST/version first, since no version field is actually
delivered anywhere in the WS {data} packet this app reads messages from).
Defaults to version:1 -- the correct value for any never-before-edited
message, which covers the common case; a real edit/recall race surfaces
as a version-conflict error rather than being silently papered over.
- Basic multi-select: enter via the context menu's "多选" (no long-press
gesture yet), header becomes "已选择 N 条消息"+取消, bottom bar currently
offers only 删除 (bulk 转发/收藏 wait on those features existing).
Verified end to end: recall correctly withheld past the 120s window and
correctly available within it, the real recall REST call round-trips and
the message disappears locally; multi-select mode enters/exits and
pre-selects the long-pressed/right-clicked message.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaces the detail-column placeholder with the actual "聊天设置" panel
(README §3.4): profile header, quick-action buttons, remark, pin/mute
toggles, blacklist, clear-history, delete-friend -- backed by real REST
endpoints (lib/api/friends.ts, ported from the old app's real
contact-management-view.jsx settings panel, not its unrelated mock
direct-message-view.jsx).
First real use of TanStack Query in this app (useQuery for
friends/conversation-settings/blocks, useMutation for the writes) --
previously only wired up in App.tsx with nothing consuming it yet.
Verified end to end: pin toggle PATCHes /conversations/{topic}/settings
and persists; remark PATCHes /friends/{userId} and reflects immediately
via query invalidation; blacklist PUT/DELETEs /blocks/{userId}.
Known minor gap: the drawer variant (960-1279px tier) stays mounted
(CSS-hidden) alongside the docked panel at >=1280px rather than
unmounting, so both instances' queries run -- harmless since TanStack
Query dedupes/shares the cache by key, but worth a follow-up cleanup.
Also still missing: the <960px "detail settings push as a full page"
variant the mobile spec calls for.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Phase 2 core loop: chat list and single-chat conversation now read/write real
Tinode topic data end to end, replacing the earlier hardcoded placeholders.
- lib/tinode/topics.ts: per-topic pub-sub layered on connection.ts's pattern
(Topic.onData/onInfo/etc are single-slot callbacks, claimed once and fanned
out). Implements the old app's subscribe lifecycle: `me` subscribed once
per session (metadata only), each contact gets a one-shot preview
subscription (withLaterData(1), never left), opening a conversation
leaves-then-resubscribes with a full history page (withData, not
withLaterData -- a preview prefetch already nudging _maxSeq would make
withLaterData return empty).
- lib/tinode/hooks.ts: useChatList()/useTopicMessages() via
useSyncExternalStore, snapshots cached and only invalidated on real
topic-change notifications (not recomputed every render).
- lib/tinode/send.ts: sendText/retryMessage via createMessage+publishDraft
(optimistic local echo), not the simpler publish() -- matches the old
app's approach. retryMessage flushes the failed local entry and rebuilds
before republishing, per the old app's context-menu retry handler.
- lib/messages/{time,avatar,attachments,status,preview,dispatch,drafty}:
formatting utilities. Avatar color hash ports the old app's Java-style
string hash but with modulus 11 (this design's palette) not the old app's
16. @mention renders flat accent blue per the new design, not the old
app's per-user hash color -- a deliberate simplification. File-type icons
(PDF/Word/Excel/PPT) are new-design-only; the old app buckets all
documents identically. Date/time formatting is written fresh against the
design spec (today+time combined, weekday tier) since the old app never
combines a relative-date word with a time in one string.
- ChatListColumn, MessageBubble, ConversationView: real components replacing
the placeholder chat list and "占位" conversation view.
- tinode-sdk.d.ts: expanded with the real Topic/MetaGetBuilder/Drafty
surface needed above, verified against the installed SDK source
(createMessage/publishDraft/messages/msgStatus/noteRead/flushMessage/
Drafty.format's exact treeBottomUp contract, MESSAGE_STATUS_* statics).
Verified end to end against the real backend: used the still-functional old
app (a second account, ixp355087) to create a real friendship and exchange
messages with the new app's test account, confirming the new chat list and
conversation view correctly render pre-existing real data, sending a new
message shows the right optimistic-then-delivered status transition, and
the old app receives it live in return.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Rebuilt tenant/login/register/reset-password to match the design handoff's
01-account screens precisely (previously only loosely approximated), and
ported the old app's real business rules instead of guessing:
- LoginPage: desktop split branding panel (>=900px), terms-agreement
checkbox gating submit, forgot-password link, password visibility toggle,
full ctrl-error translation table (lib/tinode/errors.ts, ported from the
old app's CTRL_ERROR_TEXT_CN/translateCtrlError -- registration/login go
over native WS, not REST, so client.ts's REASON_MESSAGES never applied).
- RegisterPage: rebuilt as the real unified username-OR-phone form (not
phone-only) -- username optional unless phone is blank, invite code
required+validated only on the username-only path, matching the server's
actual resolveRegistrationAccount rules. connection.ts's
registerAccountByPhone generalized into registerAccount() supporting both
paths, including the Tinode.credential(null) crash the old app had to
work around on the username-only path.
- New ResetPasswordPage wired to /auth/password-reset/* (a different
endpoint family than registration's /auth/verification-codes/*).
- Shared components: PasswordInput, VerificationCodeButton, Agreement,
Toast.
- Fixed tinode-sdk.d.ts: the real method is getCurrentUserID(), not the
invented getAuthenticatedUserID() -- caught live, login crashed past a
successful auth.
- Fixed a responsive bug present on all four auth pages: fixed w-[380px]
forms overflowed past the viewport edge below ~430px wide.
Verified end-to-end against the real backend (api.118im.com, tenant
"default"): fresh phone registration, password reset, re-login with the
new password, and the username-only path's invite-code rejection all
round-tripped correctly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Add RegisterPage (phone + SMS code) and lib/api/auth.ts verification-code
endpoints, wired into App.tsx routing.
- Fix registerAccountByPhone(): the injected acc.phone extra field must be
bare 11-digit, not E.164 -- server checks digit length before normalizing.
- Fix LoginCtrl shape: SDK's login/loginBasic/createAccountBasic promises
resolve with the ctrl fields flat, not wrapped in {ctrl: {...}}.
- Fix tinode-sdk.d.ts: the real method is getCurrentUserID(), there is no
getAuthenticatedUserID().
All three bugs found and fixed via live testing against the real backend
(api.118im.com, tenant "default"), confirming registration and login both
work end-to-end.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>