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