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>