Compare commits

...
40 Commits
Author SHA1 Message Date
Your NameandClaude Sonnet 5 e3217a5de9 fix(web): 转发选择器好友/群聊分开全选
原来是列表整体一个全选按钮,选中后好友和群聊会一起被勾上。改成
好友、群聊两个分组各自独立的全选/取消全选,互不影响,方便只想
批量选中其中一类的场景(比如只发给所有群,不带上私聊好友)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:15:29 +08:00
Your NameandClaude Sonnet 5 46b3288880 feat(web): 下线自主注册与协议勾选,修复转发消息报错,转发选择器分组+全选
按产品决定移除面向国内客户不适用的自主注册入口:删除 /register 路由
及 RegisterPage、登录页"马上注册"链接、登录/注册共用的协议勾选组件
Agreement,以及仅注册流程使用的 registerAccount/queueAccExtra 等
死代码;登录不再依赖勾选协议才能提交。

修复转发消息报错(真实高频bug,不限于个别场景):`forwardMessage`/
`sendText` 发送前只是 `tinode.getTopic()` 取一个本地话题对象,从未
真正订阅——SDK 要求话题处于 attached 状态才允许发布,而只有当前正
打开的会话才满足这一点,一旦切换过/离开过某个会话(哪怕之前订阅过
预览),它就会变回未订阅状态。也就是说转发给"当前会话之外"几乎任何
一个联系人都会失败。新增 `ensureTopicAttached()`,在转发前按需临时
订阅目标话题,单条转发和多选批量转发两处都已接入,实机验证对未打开
过的好友/群转发均成功。

转发选择器(ForwardPickerModal)按好友/群聊分组显示(原来混在一个
列表里),并加了"全选/取消全选",避免多个接收方时逐条点选。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:04:59 +08:00
Your NameandClaude Sonnet 5 1826d2906f feat(web): 会话头像/名称可点击展开详情栏,移除"关于 MeetingIM"设置页
聊天窗口头部的头像和昵称此前只是静态展示,只有右侧"i"图标能展开
详情栏;现在点击头像或名称同样会触发 onToggleDetail,单聊群聊均
生效。

按产品决定移除设置里的"关于 MeetingIM"页面(桌面目录、移动端"我的"
二级设置列表两处入口一并去掉),该页面唯一内容是版本号和一个后端
本就不支持 platform=web 的检查更新按钮,删除后 AboutPage.tsx 和
仅被它使用的 lib/api/app.ts 一并清理为死代码。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:06:46 +08:00
Your NameandClaude Sonnet 5 9271a0a9c1 fix(web): 隐藏文件助手入口,修复自聊天详情栏错误操作
后端目前没有支持文件助手所需的配额/分类等能力,只是把它当作
普通话题处理;与其做一个功能不全的界面,先从导航栏、会话列表
置顶区、通讯录固定入口、设置页移动端快捷入口中移除该入口。slf
话题现在按普通会话行为处理(排序、未读计数均不再特殊处理)。

同时修复 DetailColumn 对自聊天(slf)显示语音/视频通话、备注、
拉黑、举报、删除好友等对自己无意义操作的问题,改为仅显示适用
的发消息/查找聊天记录/置顶/免打扰/清空聊天记录等项。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 23:35:58 +08:00
Your NameandClaude Sonnet 5 3065e232be fix(web): favicon didn't match the app's real logo mark
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>
2026-09-09 22:18:30 +08:00
Your NameandClaude Sonnet 5 d1c81d432e fix(web): 文件助手 duplicated as its own chat-list row once slf has real history
文件助手 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>
2026-09-09 22:01:50 +08:00
Your NameandClaude Sonnet 5 db0f8e4543 fix(deploy): build/serve the new web/ Vite app instead of the old webpack build
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>
2026-09-09 20:56:21 +08:00
Your NameandClaude Sonnet 5 5c3f3b7653 feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes
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>
2026-09-09 20:16:33 +08:00
Your NameandClaude Sonnet 5 72b7b79caf fix(web): code review round 3 -- UI bugs and remaining design fidelity
- 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>
2026-09-08 05:27:41 +08:00
Your NameandClaude Sonnet 5 8ec74dffcc fix(web): code review round 3 -- surviving races and a shape bug
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>
2026-09-08 05:27:16 +08:00
Your NameandClaude Sonnet 5 c9f4615c40 fix(web): code review round 2 -- messaging UI bugs and design fidelity
- 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>
2026-09-08 04:48:53 +08:00
Your NameandClaude Sonnet 5 d3a0eb0dbc fix(web): code review round 2 -- critical session/connection races
- 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>
2026-09-08 04:48:28 +08:00
Your NameandClaude Sonnet 5 1bd8d7f804 fix(web): history search used response shapes the real server doesn't send
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>
2026-09-08 03:58:22 +08:00
Your NameandClaude Sonnet 5 93d4077d1e fix(web): code review round 1 remaining UI fixes
- 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>
2026-09-08 03:58:06 +08:00
Your NameandClaude Sonnet 5 a7da4dc079 fix(web): code review round 1 -- messaging UI critical/high findings
- 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>
2026-09-08 03:23:54 +08:00
Your NameandClaude Sonnet 5 569f266390 fix(web): code review round 1 -- auth pages design fidelity + missing screens
- 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>
2026-09-08 03:23:30 +08:00
Your NameandClaude Sonnet 5 986432addd fix(web): code review round 1 -- critical data-layer bugs
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>
2026-09-08 03:23:01 +08:00
Your NameandClaude Sonnet 5 d5440b962b feat(web): bulk forward/favorite in multi-select mode
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>
2026-09-08 02:51:29 +08:00
Your NameandClaude Sonnet 5 03b8098b3c feat(web): voice messages (record, send, playback)
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>
2026-09-08 02:49:17 +08:00
Your NameandClaude Sonnet 5 aa3e464b35 feat(web): emoji picker
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>
2026-09-08 02:45:55 +08:00
Your NameandClaude Sonnet 5 59d1033a92 feat(web): image and file message attachments (send + render)
- 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>
2026-09-08 02:44:12 +08:00
Your NameandClaude Sonnet 5 6fcd779530 feat(web): pagination, quote/reply, favorite, and single-message forward
- 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>
2026-09-08 02:39:23 +08:00
Your NameandClaude Sonnet 5 bccbdff9b2 feat(web): offline/reconnect/realname top banners (README §3.7)
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>
2026-09-08 02:31:32 +08:00
Your NameandClaude Sonnet 5 80bf0aa41f feat(web): 查找聊天记录 history search + media grid in the detail panel
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>
2026-09-08 02:27:12 +08:00
Your NameandClaude Sonnet 5 0b48346d02 fix(web): avatar background color was invisible everywhere (transparent)
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>
2026-09-08 02:23:20 +08:00
Your NameandClaude Sonnet 5 2272b42c6f feat(web): message context menu, recall, and basic multi-select
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>
2026-09-08 02:01:53 +08:00
Your NameandClaude Sonnet 5 7f5756671d feat(web): real P2P chat-settings detail panel
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>
2026-09-08 01:54:54 +08:00
Your NameandClaude Sonnet 5 f549248c08 feat(web): real chat list + P2P conversation view against live Tinode data
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>
2026-09-08 01:49:14 +08:00
Your NameandClaude Sonnet 5 aa1c46ff22 feat(web): full auth screens rework to match design + old-app business rules
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>
2026-09-08 01:11:21 +08:00
Your NameandClaude Sonnet 5 63c1b84b41 feat(web): phone registration + fix login/register response handling
- 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>
2026-09-08 00:51:06 +08:00
Your NameandClaude Sonnet 5 8cdd84152d 企业码解析+登录跑通真实后端:Tinode连接适配层、类型化API客户端、会话store
lib/tinode/connection.ts:单例Tinode连接+自己的发布订阅层——SDK的onConnect/onDisconnect/onLogin
是单槽位属性(赋值会互相覆盖),不是真正的多监听器事件源,所以这里统一接管这几个槽位,自己
扇出给任意数量的React订阅方(lib/tinode/hooks.ts的useSyncExternalStore)。installTenantHiPatch
照搬旧应用tinode-web.jsx的实现搬过来——服务端{hi}包的tenant字段SDK本身没有API,只能在原始
sendText层面注入,这是后端协议的真实约束,不是旧代码库特有的写法。

lib/api/client.ts:REST客户端按旧应用src/lib/im-api.js的request函数+完整REASON_MESSAGES错误码
翻译表搬过来(含请求头X-MeetingIM-APIKey等几个月联调换来的正确写法),这次是重新用TypeScript
写、不是复制文件,但端点行为对齐。

实机联调过程中发现并修正了一处凭记忆猜错的地方:tenants/resolve真实响应结构是
{endpoints:{api,file,websocket}, tenant:{code,name}},不是我最初假设的顶层{code,name}——
已用真实请求验证修正。用企业码default解析成功(显示"Default Tenant")+ 一次真实错误密码
登录尝试(服务端真实401,错误提示正确显示"账号或密码错误")两步都对着真实后端验证通过,
不是只测了正确路径。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 00:38:50 +08:00
Your NameandClaude Sonnet 5 8bcd9f3a5b 新分支:Vite+React19+TS+Tailwind v4 重新搭建,先跑通四级响应式布局壳
不再沿用旧 im_webapp 的技术栈(webpack + class组件 + 15000行全局CSS + 手搓History路由),
按业务重新选型:Vite/React Router/Zustand预留/TanStack Query/保留tinode-sdk真实依赖
(协议客户端本身没有重写的业务理由)。新代码放在 web/ 子目录,旧代码原样保留在仓库根目录,
两者共存到重写完成。

这一步先验证布局壳本身:NavRail(消息/通讯录/收藏/文件助手/通话记录/设置)+ 会话列表 +
主区域 + 详情栏,按设计稿的四档断点(<640/640-959/960-1279/≥1280)用真实浏览器宽度逐档验证
过——两列及以下"列表/主区域二选一+返回键"、三列"详情栏抽屉化"、四列"详情栏常驻"均已用
真实点击/返回验证正确,不是只看了截图。会话列表和聊天内容目前是占位数据,业务数据接入和
Tinode连接是下一步。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 00:24:11 +08:00
Your NameandClaude Sonnet 5 4e8f45d7d8 路由从 hash 换成 History API(pushState/replaceState/popstate),不再依赖 location.hash
HashNavigation 改名 Navigation(src/lib/navigation.js),接口不变,内部改用真实路径:
parseUrlHash/composeUrlHash 改名 parseUrl/composeUrl;navigateTo/navigateReplace 改调
pushState/replaceState 后手动派发 popstate(pushState/replaceState 不会像赋值 location.hash
那样自动触发事件,这里复用 navigateReplace 早就在用的同一个手动 dispatch 手法);goBack()
不用改,history.back() 本来就是真实浏览器历史机制。新增 Navigation.currentUrl() 替代调用方
原本直接读的 window.location.hash(这个类从来没包装过这个读取)。

一处需要专门处理的边界情况:当 path[0] 是空字符串("不选中侧栏视图、只开一个会话"这个仓库里
的合法状态)时,拼出的真实路径开头会变成双斜杠,被浏览器当协议相对地址解析、试图跳到一个叫
会话id的host,pushState 直接抛 SecurityError。composeUrl/parseUrl 内部用占位 token "_" 处理
这种情况,对外的调用方(19个文件)完全无感。

tinode-web.jsx 的 handleHashRoute 改名 handleRouteChange,监听从 hashchange 换成 popstate;
service-worker.js 的推送通知点击跳转手动改写成同样的路径格式(这个文件在打包之外单独执行,
没法 import Navigation)。index.html/index-dev.html 加了 <base href="/">——所有静态资源引用
都是相对路径,hash 路由下页面路径永远是 /,换真实路径后深层地址刷新会按当前目录解析出错误
资源地址导致白屏,这一行是标准修法。nginx.conf 的 try_files 兜底规则本来就适用两种路由,只
更新了注释。

实机验证:地址栏不再有 #;点击会话/设置等各类视图正常跳转;浏览器前进后退和应用内"返回"
按钮都正常;收藏页内部的类型 tab 切换(navigateReplace)确认不产生新的历史记录;在会话页/
个人中心页刷新、直接用完整深层路径打开新标签页都能正确恢复到位,资源正常加载不白屏。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 21:00:39 +08:00
Your NameandClaude Sonnet 5 97a6b88d59 修复"查找聊天记录"列表条目上下错位、长内容不换行溢出的问题
.library-message-results > button 沿用全局 button{height:2.2rem} 的重置,与自己的
min-height:5.5rem 叠加后变成一个"clamp出来的定长"而非auto——内容一旦比这个定长高(比如摘要
换行),这一条本身不会长高,而是让自己末尾的 <small> 时间戳溢出到下一条的发送人名字位置,
视觉上像是变成了下一条的开头,看着上下错位。同一个全局重置还带了 white-space:nowrap,摘要
<p> 继承后完全不换行,比栏宽的内容(尤其是链接)直接顶穿右边界。

改用 height:auto 让每一条按内容真实需要的高度伸展;<p> 补 white-space:normal +
overflow-wrap:anywhere(跟 .bubble div.message-content 处理长链接的方式一致)。移动端窄屏下
实机复现后验证:摘要正确换行、每条自己的时间戳不再越界到下一条。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 20:09:06 +08:00
Your NameandClaude Sonnet 5 7981083cad 修复发出消息里的超链接因为和气泡背景同色而完全不可见的问题
上一版把发出气泡背景改成蓝色(--clr-primary-accent)时漏了一处:全局 a{color} 用的正是同一个
token,链接文字和气泡背景撞成一模一样的颜色,肉眼完全看不见(但DOM里其实是好的,实测<a href>
标签和完整URL文本都在)。用真实账号发一条带链接的消息复现后确认。改为链接色跟随气泡文字的白色+
下划线,颜色不能再区分链接和普通文字时用下划线区分。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:58:49 +08:00
Your NameandClaude Sonnet 5 48baeceaa8 修复长按消息气泡弹出自定义菜单时,浏览器/系统原生右键菜单跟着一起弹出的问题
chat-message.jsx 新增 suppressNativeContextMenu,在气泡上监听 contextmenu 事件并 preventDefault,
避免长按(或桌面端右键/触屏模拟长按)触发的原生上下文菜单和应用自己的长按菜单重叠显示。
css/base.css 给 .bubble 补了 -webkit-touch-callout:none,同时覆盖 iOS Safari 那条独立于
contextmenu 事件之外的长按呼出菜单(复制/查询/分享...),不影响 user-select,桌面端正常的
点击拖拽选字复制不受影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:50:06 +08:00
Your NameandClaude Sonnet 5 4ce666042e 修复系统消息/我的收藏页"加载更多"按钮文字下沉被裁切的问题
.system-notification-load-more/.library-load-more 只覆盖了全局button的字号和内边距,没覆盖
line-height,实测继承的全局button{line-height:2.2rem}比这两个按钮自己的行高需求高出一大截,
把文字挤到框内偏下的位置,底部还被裁切掉约2px(用getBoundingClientRect量出文字整体比按钮
边框低9.9px)。改用flex居中+height:auto,不再依赖固定line-height撑出的位置。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:41:59 +08:00
Your NameandClaude Sonnet 5 f0091fb146 修复消息气泡配色未跟设计稿核对过的问题:发出气泡改蓝底白字,接收气泡改浅灰,日期分隔去掉气泡背景
对照 docs/images/个人对话框(我的状态/对方的状态).png:发出=蓝色实心+白字,接收=浅灰+深字,
与其余相关截图口径一致;旧配色(接收浅绿#c4e0a3、发出用页面同款off-white)不匹配任何参考图,
是尚未跟设计稿核对过的原始默认值。顺带修了一个自查时发现的新问题:已读回执图标复用的.blue
工具类和新的蓝色发出气泡背景是同一个色值,之前会变成看不见的勾选图标,改为不透明白色。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 19:41:18 +08:00
Your NameandClaude Sonnet 5 a8b81a51e3 修复本轮UI走查发现的6处样式/交互问题
1. 企业注册-统一社会信用代码:扫码图标误用 icon+onIconClick(渲染在输入框左侧、蓝色),
   跟设计稿(图标在右侧、深灰色)不符。原因是 Field 组件里 icon 出现在 <input> 之前;
   全仓库唯一一处 onIconClick 用法,改用 PasswordField 同款的 action(渲染在右侧),
   复用已有的 .auth-inline-icon 灰色样式,不新增CSS。

2. 意见反馈:图片上传按钮跟设计稿差异较大(当前是通栏虚线框+蓝色图标文字,设计稿是
   独立白色方块+灰色图标文字);字数"0/300"计数器卡在textarea自带边框和外层白卡的接缝
   处,看起来像叠在两层盒子中间。改法:拆分原本跟"举报证据上传"共用的CSS规则,给
   .feedback-upload>button 单独设置白底/无边框/灰色/7.5rem方块;去掉textarea自身的
   border,让计数器所在的外层卡片成为唯一可见的白色容器。"图片"标题跟数量合并回一行
   (原来用space-between分居两端),maxFiles真实上限(5,早前已确认是后端真实值)不变。

3. 青少年模式(开启/关闭/验证三个页面互跳):三处跳转全部误用push(HashNavigation.
   navigateTo),导致点返回会先回到流程内已经处理完的页面,跟本工程之前排查过的
   同类"tab切换污染历史记录"是同一个模式。新增 navigateReplace()(跟 navigate() 同结构,
   内部换成 HashNavigation.navigateReplace,抄的是 message-center-view.jsx/
   content-library-view.jsx已验证过的写法),三处调用改用它。

4. 修改密码页面:复用了 profile-editor-page 的通用label/input样式,但没有像
   BirthDateEditor那样带上专属wrapper class,导致三个label的<span>不加粗不换行、
   紧贴在一起零间距,input还留着给别的编辑器计数器/清空按钮预留的3rem右侧空白。
   新增 change-password-fields 样式(参照birth-date-editor的label/span/input规则)。

5. 消息长按弹出菜单:全历史检索确认这个功能从来不是真正的长按手势,唯一实现是点击
   气泡右上角一个不透明度只有0.3的小箭头按钮——参考图"消息页面—长按弹出.png"命名的
   功能需求和实现从未对齐。给 .bubble 补上真实的touchstart/touchmove/touchend长按
   识别(500ms计时器,移动超过10px视为滚动取消),复用同一套菜单构建逻辑(从
   handleContextClick里抽出openContextMenu(x,y)公用)。验证时发现并修复了一个重构
   引入的真bug:openContextMenu里遗留了两行没改完的e.pageX/e.pageY(应为参数x/y),
   会导致点击小按钮和长按两条路径双双抛ReferenceError,已修正并重新验证两条路径都正常。

6. 群公告横幅(群聊导航栏下方常驻提示条):当前是通栏无圆角、贴边无留白、底部硬分隔线、
   文字用的是全局默认灰色(#666)文本token,设计稿是浮在灰底上的白色圆角卡片、四周留白、
   无分隔线、文字接近黑色。改法:加margin+border-radius+box-shadow做出浮起的卡片感,
   去掉border-bottom,颜色改用--clr-bubble-text(消息正文同款深色token,而不是改全局
   的--clr-text-primary,避免影响其它用到这个默认文本色的地方)。

验证:本地dev-proxy逐项验证——企业注册图标位置颜色(截图核对)、意见反馈布局(截图核对,
0/5计数器无缝落在白卡右下角)、青少年模式三处调用grep确认无遗漏+navigateReplace底层
history.replaceState机制单独验证不增加history.length、修改密码页面(截图核对label加粗
间距生效)、长按弹出(真实dispatch TouchEvent触发菜单成功,且验证点击小按钮路径未被
重构破坏)、群公告横幅(getComputedStyle核对margin/borderRadius/boxShadow/color/
borderBottom五个属性全部生效)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:24:24 +08:00
Your NameandClaude Sonnet 5 6ab2867f27 重新构建dev产物,反映本轮死代码清理+收藏批量删除弹层+通讯录CSS修复三处改动
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 01:59:33 +08:00
159 changed files with 18549 additions and 395 deletions
+17 -16
View File
@@ -1,14 +1,21 @@
# ---- Stage 1: build static assets ----
# The frontend was fully rewritten from scratch under web/ (Vite + React + TS) on branch
# redesign/desktop-multicolumn, merged into dev 2026-09-09 -- this replaces the old webpack build
# that used to live at the repo root (umd/, css/base.min.css, index.html, etc.). See web/README-
# equivalent context in the redesign plan for why: this Dockerfile previously never touched web/ at
# all, so every "deploy" kept shipping the old app regardless of what was on dev.
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
WORKDIR /app/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY . .
COPY web/. .
# TINODE_API_KEY is read by webpack.config.js (via DefinePlugin) at build time.
# VITE_TINODE_API_KEY is read by src/lib/config.ts (import.meta.env.VITE_TINODE_API_KEY) at build
# time -- Vite only exposes env vars prefixed VITE_ to client code, so the ARG is renamed on its
# way into the build's environment rather than passed through under its old webpack-era name.
ARG TINODE_API_KEY=
ENV TINODE_API_KEY=$TINODE_API_KEY
ENV VITE_TINODE_API_KEY=$TINODE_API_KEY
RUN npm run build
@@ -19,17 +26,11 @@ COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
WORKDIR /usr/share/nginx/html
COPY --from=build /app/umd ./umd
COPY --from=build /app/css/base.min.css ./css/base.min.css
COPY --from=build /app/index.html ./index.html
COPY --from=build /app/index-dev.html ./index-dev.html
COPY --from=build /app/manifest.json ./manifest.json
COPY --from=build /app/service-worker.js ./service-worker.js
COPY --from=build /app/version.js ./version.js
COPY --from=build /app/img ./img
COPY --from=build /app/audio ./audio
# Push notifications are not configured for this deployment yet.
RUN echo "" > ./firebase-init.js
# Vite's build output is fully self-contained: index.html + favicon.svg/icons.svg (from web/public/,
# copied verbatim) + assets/ (content-hashed JS/CSS chunks) -- no separate umd/css/img/audio/
# manifest/service-worker/firebase files the way the old build had, none of that machinery exists
# in the new app.
COPY --from=build /app/web/dist/. ./
HEALTHCHECK --interval=1m --timeout=3s --start-period=10s \
CMD wget -q -O /dev/null http://localhost/ || exit 1
+103 -14
View File
@@ -133,15 +133,18 @@
--clr-input-invalid-shadow: light-dark(#e5737340, #1a0c0c40);
--clr-input-placeholder-text: var(--clr-text-placeholder);
/* Chat Bubbles */
--clr-bubble-left-bg: light-dark(#c4e0a3, #3e5c1f);
--clr-bubble-left-bg-flash: light-dark(#94a97c, #2c4517);
--clr-bubble-right-bg: var(--clr-background-off-white);
--clr-bubble-right-bg-flash: var(--clr-border-light);
/* Chat Bubbles -- docs/images/个人对话框(我的状态).png 与 ...(对方的状态).png:发出=蓝色实心+白字,
收到=浅灰+深字,这两份参考图和之前所有相关截图口径一致;旧值(收到浅绿#c4e0a3、发出用页面同款
off-white)不匹配任何一张参考图,是尚未跟设计稿核对过的原始默认值,这次改成实际设计要求的配色。 */
--clr-bubble-left-bg: light-dark(#f0f0f0, #2c2c2e);
--clr-bubble-left-bg-flash: light-dark(#d8d8d8, #3a3a3c);
--clr-bubble-right-bg: var(--clr-primary-accent);
--clr-bubble-right-bg-flash: var(--clr-primary-accent-hover);
--clr-bubble-text: light-dark(#212121, #bbc);
--clr-bubble-right-text: #fff;
--clr-bubble-meta-text: var(--clr-text-primary);
--clr-bubble-meta-deleted-bg: var(--clr-primary-wash);
--clr-bubble-meta-date-bg: light-dark(rgba(255 255 255 / 80%), rgba(40 40 40 / 80%));
--clr-bubble-meta-date-bg: transparent;
--clr-bubble-timestamp-text: light-dark(#777, #888);
--clr-bubble-author-text: light-dark(#777, #666);
@@ -3581,10 +3584,12 @@ img.channel {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0.5rem 0.75rem 0;
padding: 0.6rem 1rem;
background-color: var(--clr-bright);
border-bottom: 1px solid var(--clr-divider);
color: var(--clr-text-primary);
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
color: var(--clr-bubble-text);
flex: none;
}
#group-announcement-banner i:first-child { color: var(--clr-warning-text, #e0a82e); }
@@ -4581,6 +4586,11 @@ video {
transition: ease-out background-color 0.5s;
transition-delay: 0.3s;
filter: drop-shadow(0.5px 0.5px 0.7px var(--clr-shadow-bubble-drop));
/* iOS Safari's press-and-hold callout (Copy/Look Up/Share...) is a separate mechanism from the
`contextmenu` DOM event suppressed in chat-message.jsx -- needs its own opt-out so it doesn't
stack with our own long-press menu. Doesn't touch user-select, so normal click-drag text
selection on desktop is untouched. */
-webkit-touch-callout: none;
}
.chat-box li.single {
@@ -4618,6 +4628,7 @@ video {
.right .bubble {
background-color: var(--clr-bubble-right-bg);
color: var(--clr-bubble-right-text);
margin-left: 1.75rem;
}
@@ -4625,6 +4636,18 @@ video {
background-color: var(--clr-bubble-right-bg-flash);
}
/* Same collision as the read-receipt checkmark below: the global `a` rule (css/base.css:1970)
colors links with --clr-primary-accent, which is now this bubble's own background -- an
auto-linked URL in a sent message rendered with 0 contrast, invisible text that was still
very much there in the DOM (confirmed live: a real <a href> with the full URL as its text).
Underline is what marks it as a link now that color can't. */
.right .bubble a,
.right .bubble a:hover,
.right .bubble a:focus {
color: var(--clr-bubble-right-text);
text-decoration: underline;
}
.right .bubble.tip {
border-bottom-right-radius: 0;
}
@@ -4774,6 +4797,22 @@ Font from 3x to almost normal, from 1 to 5 emojis in a message */
user-select: none;
}
/* --clr-bubble-timestamp-text is a mid-gray tuned for the light-gray received bubble; on the new
solid-blue sent bubble it reads as too low-contrast, so the sent side gets its own translucent
white instead of a second global token. */
.right .bubble .timestamp {
color: rgba(255, 255, 255, 0.85);
}
/* deliveryMarker()'s "read" checkmark uses the .blue utility class (lib/utils.js), which resolves
to --clr-primary-accent -- the exact same blue this bubble's background now is. Left as-is, a
read receipt on your own message would render an invisible checkmark. Opaque white keeps it a
shade brighter than the translucent timestamp text next to it, so "read" still reads as distinct
from plain "sent". */
.right .bubble .timestamp .blue {
color: #fff !important;
}
/* The name of the person who sent the message
* (group chat only)
*/
@@ -7658,8 +7697,11 @@ div.toast.show {
}
.system-notification-load-more {
display: block;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: auto;
margin: 0;
padding: 0.7rem;
border: 1px solid #eceef1;
@@ -10996,8 +11038,11 @@ div.toast.show {
}
.library-load-more {
display: block;
display: flex;
align-items: center;
justify-content: center;
width: min(100%, 52rem);
height: auto;
margin: 0 auto;
padding: 0.8rem;
border: 1px solid #eceef1;
@@ -11767,6 +11812,7 @@ div.toast.show {
align-items: start;
gap: 0.8rem;
width: 100%;
height: auto;
min-height: 5.5rem;
margin: 0;
padding: 0.8rem 1rem;
@@ -11784,10 +11830,22 @@ div.toast.show {
min-width: 0;
}
/* The global button{} reset's height:2.2rem clamps up to this row's own min-height (5.5rem) but
stays a DEFINITE height rather than becoming auto -- so a snippet long enough to wrap past what
5.5rem holds (any real search hit, not just short mock text) doesn't grow the row to fit; it
overflows past the row's own border-bottom into the next entry's space instead (confirmed live:
this row's own trailing <small> timestamp rendered overlapping the NEXT row's sender name).
height:auto above lets the row grow past min-height like a plain block would. Same reset also
sets white-space:nowrap, which the snippet <p> inherits -- with no wrapping at all, anything past
the column width (a long link, not just long plain text) runs off the right edge instead of
wrapping. overflow-wrap:anywhere matches .bubble div.message-content's own handling of the same
kind of long, space-free URL text. */
.library-message-results p {
margin: 0.3rem 0;
color: #666e78;
line-height: 1.5;
white-space: normal;
overflow-wrap: anywhere;
}
.library-message-results mark {
@@ -13427,6 +13485,23 @@ div.toast.show {
padding: 0 0.75rem;
}
.change-password-fields label {
display: block;
width: min(100%, 26rem);
margin-top: 1.5rem;
}
.change-password-fields label span {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
}
.change-password-fields label input {
height: 3.2rem;
padding: 0 0.75rem;
}
.personal-qr-page > main {
padding: 1rem;
}
@@ -14491,8 +14566,7 @@ div.toast.show {
margin: 0;
padding: 0.75rem 0.8rem 2rem;
resize: vertical;
border: 1px solid #dfe3e7;
border-radius: 0.35rem;
border: 0;
box-shadow: none;
background: #fff;
line-height: 1.5;
@@ -14574,8 +14648,7 @@ div.toast.show {
background: transparent;
}
.evidence-upload,
.feedback-upload > button {
.evidence-upload {
display: flex;
align-items: center;
justify-content: center;
@@ -14591,6 +14664,22 @@ div.toast.show {
flex-direction: column;
}
.feedback-upload > button {
display: flex;
align-items: center;
justify-content: center;
width: 7.5rem;
height: 7.5rem;
margin: 0;
padding: 0.7rem;
border: 0;
border-radius: 0.35rem;
box-shadow: none;
color: #858d96;
background: #fff;
flex-direction: column;
}
.evidence-upload .material-icons,
.feedback-upload > button .material-icons {
margin-bottom: 0.25rem;
+1 -1
View File
File diff suppressed because one or more lines are too long
+11 -11
View File
@@ -5,20 +5,20 @@ server {
root /usr/share/nginx/html;
index index.html;
# Tinode webapp uses hash-based client-side routing, so any unknown path
# can safely fall back to index.html.
# The webapp uses real (History API) client-side routing, so any unknown path can safely fall
# back to index.html -- the app resolves the actual view/topic client-side from the path itself
# once index.html loads. index.html itself is never content-hashed, so it must always be
# revalidated -- otherwise a browser holding a cached copy would keep requesting the *previous*
# deploy's now-deleted /assets/*.js filenames after a new deploy replaces them.
location / {
add_header Cache-Control "no-cache";
try_files $uri $uri/ /index.html;
}
# Filenames here aren't content-hashed, so a long/immutable cache would keep
# serving a stale bundle after every deploy. Force revalidation instead:
# cheap 304s when unchanged, immediate pickup of new content when not.
location /umd/ {
add_header Cache-Control "no-cache";
}
location /img/ {
add_header Cache-Control "public, max-age=86400";
# Vite content-hashes every filename under assets/ (e.g. index-DYjopaLI.js) -- a new build never
# reuses an old asset's URL, so these can be cached aggressively and indefinitely; a stale cached
# copy simply never gets requested again once index.html points at the new hashed filenames.
location /assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
+6
View File
@@ -4,6 +4,12 @@
<head>
<title>MeetingIM</title>
<meta charset="utf-8" />
<!-- Real (History API) routing means the address bar can show a deep, multi-segment path like
/messages-home/grpXXX -- every asset reference below is a relative path (css/base.css,
umd/index.dev.js, etc.), which would otherwise resolve against that deep path instead of the
app's actual root on a deep-linked load or a refresh, 404ing everything. This pins relative
resolution to root regardless of the current path. -->
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="MeetingIM, version for developers" />
<meta name="description" content="MeetingIM instant messenger, development version" />
+6
View File
@@ -3,6 +3,12 @@
<head>
<title>MeetingIM</title>
<meta charset="utf-8" />
<!-- Real (History API) routing means the address bar can show a deep, multi-segment path like
/messages-home/grpXXX -- every asset reference below is a relative path (css/base.min.css,
umd/index.prod.js, etc.), which would otherwise resolve against that deep path instead of the
app's actual root on a deep-linked load or a refresh, 404ing everything. This pins relative
resolution to root regardless of the current path. -->
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="application-name" content="MeetingIM" />
<meta name="description" content="MeetingIM instant messenger in a browser" />
+9 -4
View File
@@ -131,7 +131,12 @@ self.addEventListener('notificationclick', event => {
return;
}
const urlHash = '#/' + data.topic;
// '_' mirrors the placeholder Navigation.composeUrl (src/lib/navigation.js) substitutes for an
// empty path[0] ("no sidepanel view, just open this topic") -- a real empty first segment would
// make the path start with '//', which pushState/URL resolve as a protocol-relative reference
// (host becomes "data.topic") rather than a same-origin path. This file runs outside the webpack
// bundle (importScripts, not an ES module) so it can't import the constant; kept in sync by hand.
const urlPath = '/_/' + data.topic;
event.waitUntil(self.clients.matchAll({
type: 'window',
@@ -141,7 +146,7 @@ self.addEventListener('notificationclick', event => {
let anyClient = null;
for (let i = 0; i < windowClients.length; i++) {
const url = new URL(windowClients[i].url);
if (url.hash.includes(data.topic)) {
if (url.pathname.includes(data.topic)) {
// Found the Tinode tab with the right topic open.
return windowClients[i].focus();
} else {
@@ -154,7 +159,7 @@ self.addEventListener('notificationclick', event => {
// navigate to the right topic.
if (anyClient) {
const url = new URL(anyClient.url);
url.hash = urlHash;
url.pathname = urlPath;
return anyClient.focus().then(thisClient => {
return thisClient.navigate(url);
});
@@ -162,7 +167,7 @@ self.addEventListener('notificationclick', event => {
// Did not find a Tinode browser tab. Open one.
const url = new URL(self.location.origin);
url.hash = urlHash;
url.pathname = urlPath;
return clients.openWindow(url);
}));
});
+2 -2
View File
@@ -7,7 +7,7 @@ import { createRoot } from 'react-dom/client';
import { IntlProvider } from 'react-intl';
import TinodeWeb from './views/tinode-web.jsx';
import HashNavigation from './lib/navigation.js';
import Navigation from './lib/navigation.js';
// Insert google analytics script and tag if configured.
if (typeof FIREBASE_INIT != 'undefined' && FIREBASE_INIT && FIREBASE_INIT.measurementId) {
@@ -45,7 +45,7 @@ const rtl = ['ar', 'fa', 'he', 'ur'];
// Detect human language to use in the UI:
// Check parameters from URL hash #?hl=ru, then browser, then use 'en' as a fallback.
const { params } = HashNavigation.parseUrlHash(window.location.hash);
const { params } = Navigation.parseUrl(Navigation.currentUrl());
const language = (params && params.hl) ||
(navigator.languages && navigator.languages[0]) ||
navigator.language ||
+79 -44
View File
@@ -1,7 +1,7 @@
// Utility class for hash navigation.
// Utility class for client-side routing via the History API.
// Every `navigateTo` call assigns `location.hash`, which the browser already records as a normal
// history entry -- `history.back()` would land on whatever the user actually saw before, for free.
// Every `navigateTo` call pushes a real history entry -- `history.back()` would land on whatever
// the user actually saw before, for free.
// The product's "back" buttons never used that: each page hardcodes a single destination view name
// (e.g. Profile's `back={friend ? 'friend-profile' : 'chat-standard'}`), which is only ever right for
// the one entry point its author had in mind. Reach the same page from a second place (search result
@@ -12,16 +12,28 @@
// counter is in-memory and intentionally does not survive those).
let navigationCount = 0;
export default class HashNavigation {
// Parse hash as in http://www.example.com/path#hash as if it were
// path and arguments.
static parseUrlHash(hash) {
// Placeholder path segment for "no sidepanel view selected, just a topic" (path[0] === '', the last
// entry in tinode-web.jsx's own SIDE_PANEL_VIEWS whitelist). A real empty path segment can't be
// represented safely as the FIRST segment of a real URL path: joining ['', 'grpXXX'] would produce
// "/grpXXX" before the leading '/' this class adds is even accounted for, so the final string comes
// out "//grpXXX" -- and a path starting with '//' is resolved by pushState/replaceState as a
// protocol-relative reference (same scheme, host becomes "grpXXX"), not a same-origin path. Swapping
// in this token keeps composeUrl/parseUrl round-tripping path[0] === '' exactly like before, entirely
// inside this file -- no caller needs to know the placeholder exists.
const EMPTY_VIEW_PLACEHOLDER = '_';
export default class Navigation {
// Parse a route string (path + optional '?args') into path segments and params.
static parseUrl(url) {
// Split 'path/?args' into 'path/' and 'args', path -> parts[0], args->path[1].
const parts = hash.split('?', 2);
const parts = url.split('?', 2);
const params = {};
let path = [];
if (parts[0]) {
path = parts[0].replace('#', '').split('/');
path = parts[0].replace(/^\//, '').split('/');
if (path.length > 1 && path[0] === EMPTY_VIEW_PLACEHOLDER) {
path[0] = '';
}
}
if (parts[1]) {
parts[1].split('&').forEach((arg) => {
@@ -35,34 +47,45 @@ export default class HashNavigation {
return {path: path, params: params};
}
// The current route string, in the same shape `navigateTo`/`navigateReplace` consume and
// `parseUrl` expects -- callers that used to read `window.location.hash` directly read this
// instead (this class never wrapped that read itself, so every call site has to switch explicitly).
static currentUrl() {
return window.location.pathname + window.location.search;
}
static navigateTo(url) {
navigationCount++;
window.location.hash = url;
window.history.pushState(null, '', url);
// pushState never fires popstate on its own (only real back/forward/go() does) -- dispatch a
// synthetic one so tinode-web.jsx's listener re-derives state from the new URL, same trick
// navigateReplace below already uses for replaceState's identical silence.
window.dispatchEvent(new PopStateEvent('popstate'));
}
// For switching between sub-tabs of the *same* page (e.g. content-library-view.jsx's favorites/
// history type tabs), not for navigating to a different page. `navigateTo`/`location.hash =`
// always pushes a new history entry, so clicking through N tabs pushes N entries -- the system
// back button (and this class's own goBack()) then has to step back through every tab the user
// ever visited before it reaches whatever page they were actually on before this one. `replaceState`
// updates the hash (still correct for a refresh or a copied link) without adding a history entry,
// so it doesn't inflate navigationCount either -- a tab switch was never a real in-app navigation
// to begin with. replaceState doesn't fire `hashchange` on its own, so dispatch one manually for
// the existing `window.addEventListener('hashchange', ...)` listener (tinode-web.jsx's
// handleHashRoute) to pick up; that handler re-reads window.location.hash itself rather than the
// event, so a synthetic event with no detail is indistinguishable from a real one to it.
// history type tabs), not for navigating to a different page. `navigateTo` always pushes a new
// history entry, so clicking through N tabs pushes N entries -- the system back button (and this
// class's own goBack()) then has to step back through every tab the user ever visited before it
// reaches whatever page they were actually on before this one. `replaceState` updates the URL
// (still correct for a refresh or a copied link) without adding a history entry, so it doesn't
// inflate navigationCount either -- a tab switch was never a real in-app navigation to begin with.
// replaceState doesn't fire `popstate` on its own, so dispatch one manually for the existing
// `window.addEventListener('popstate', ...)` listener (tinode-web.jsx's handleRouteChange) to pick
// up; that handler re-reads the current URL itself rather than the event, so a synthetic event with
// no detail is indistinguishable from a real one to it.
static navigateReplace(url) {
window.history.replaceState(null, '', '#' + url);
window.dispatchEvent(new Event('hashchange'));
window.history.replaceState(null, '', url);
window.dispatchEvent(new PopStateEvent('popstate'));
}
// Real "back": retraces the hash the user was actually on before, via native browser history,
// Real "back": retraces the route the user was actually on before, via native browser history,
// rather than a page's own guess at where it must have been reached from. Returns whether it
// actually did that -- every call site already has its own hardcoded-destination `navigate(...)`
// fallback (a page's `back`/`backUid`/`backTopic` props) for the one case this can't cover: a
// fresh load, deep link, or refresh, where there's no in-app history yet to go back to (this
// counter is in-memory by design and does not survive those) -- callers keep using that fallback
// unchanged, just gated behind `if (!HashNavigation.goBack()) { ...existing fallback... }`.
// unchanged, just gated behind `if (!Navigation.goBack()) { ...existing fallback... }`.
static goBack() {
if (navigationCount > 0) {
navigationCount--;
@@ -72,8 +95,20 @@ export default class HashNavigation {
return false;
}
static composeUrlHash(path, params) {
let url = path.join('/');
static composeUrl(path, params) {
let segments = path;
// !segments[0] (not === '') deliberately also catches a sparse hole at index 0 -- parseUrl('')
// returns path: [] (empty, not ['']; inherited as-is from the original hash-based parseUrlHash,
// which had the identical `if (parts[0])` guard), and setUrlTopic('', topic) then does
// `parsed.path[1] = topic` on that empty array, leaving index 0 an actual hole rather than ''.
// Array#join treats a hole the same as an empty string either way, so this was always silently
// producing the same "no view, just a topic" shape -- harmless under the old scheme since a
// leading '#//' is still just inert fragment text, but a real path starting '//' is parsed by
// pushState/URL as protocol-relative (steals the topic id as the new host) and throws.
if (segments.length > 1 && !segments[0]) {
segments = [EMPTY_VIEW_PLACEHOLDER, ...segments.slice(1)];
}
let url = '/' + segments.join('/');
const args = [];
for (const key in params) {
if (params.hasOwnProperty(key)) {
@@ -88,50 +123,50 @@ export default class HashNavigation {
return url;
}
static addUrlParam(hash, key, value) {
const parsed = HashNavigation.parseUrlHash(hash);
static addUrlParam(url, key, value) {
const parsed = Navigation.parseUrl(url);
parsed.params[key] = value;
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
static removeUrlParam(hash, key) {
const parsed = HashNavigation.parseUrlHash(hash);
static removeUrlParam(url, key) {
const parsed = Navigation.parseUrl(url);
delete parsed.params[key];
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
static removeUrlParams(hash, keys) {
static removeUrlParams(url, keys) {
if (!Array.isArray(keys)) {
keys = [keys];
}
const parsed = HashNavigation.parseUrlHash(hash);
const parsed = Navigation.parseUrl(url);
for (const key of keys) {
delete parsed.params[key];
}
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
static setUrlSidePanel(hash, sidepanel) {
const parsed = HashNavigation.parseUrlHash(hash);
static setUrlSidePanel(url, sidepanel) {
const parsed = Navigation.parseUrl(url);
parsed.path[0] = sidepanel;
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
static setUrlInfoPanel(hash, infopanel) {
const parsed = HashNavigation.parseUrlHash(hash);
static setUrlInfoPanel(url, infopanel) {
const parsed = Navigation.parseUrl(url);
if (infopanel) {
parsed.params.info = infopanel;
} else {
delete parsed.params.info;
}
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
static setUrlTopic(hash, topic) {
const parsed = HashNavigation.parseUrlHash(hash);
static setUrlTopic(url, topic) {
const parsed = Navigation.parseUrl(url);
parsed.path[1] = topic;
// Close InfoView on topic change.
delete parsed.params.info;
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
}
+7 -6
View File
@@ -1,6 +1,6 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { BASE_APP_NAME } from '../config.js';
import {
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
@@ -16,8 +16,8 @@ const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// params is optional and additive only -- every existing call site that passes just `view` keeps
// producing the exact same bare hash as the old `HashNavigation.navigateTo(view)` did.
const navigate = (view, params) => HashNavigation.navigateTo(HashNavigation.composeUrlHash([view], params || {}));
// producing the exact same bare hash as the old `Navigation.navigateTo(view)` did.
const navigate = (view, params) => Navigation.navigateTo(Navigation.composeUrl([view], params || {}));
function Icon({children, className = ''}) {
return <i className={'material-icons ' + className} aria-hidden="true">{children}</i>;
@@ -32,7 +32,7 @@ function AppMark({tenantName}) {
function Header({title, back = 'tenant'}) {
return <header className="auth-header">
<button type="button" className="auth-icon-button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回">
<button type="button" className="auth-icon-button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回">
<Icon>arrow_back_ios_new</Icon>
</button>
<h1>{title}</h1>
@@ -247,8 +247,9 @@ function EnterpriseRegister() {
<FormAlert text={errors.form} />
<label className="auth-label">统一社会信用代码</label>
<Field value={creditCode} onChange={e => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
placeholder="请输入18位统一社会信用代码" maxLength="18" icon="qr_code_scanner" error={errors.creditCode}
onIconClick={() => setToast('扫码识别暂不可用,请手动输入')} />
placeholder="请输入18位统一社会信用代码" maxLength="18" error={errors.creditCode}
action={<button type="button" className="auth-inline-icon"
onClick={() => setToast('扫码识别暂不可用,请手动输入')}><Icon>qr_code_scanner</Icon></button>} />
<p className="auth-help">请填写营业执照上的统一社会信用代码</p>
<label className="auth-label">上传营业执照 <span>选填</span></label>
+2 -2
View File
@@ -1,8 +1,8 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
const navigate = view => HashNavigation.navigateTo(view);
const navigate = view => Navigation.navigateTo(view);
export const CALL_EXPERIENCE_VIEWS = [
'call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active',
+12 -12
View File
@@ -2,7 +2,7 @@ import React from 'react';
import QRCode from 'qrcodejs';
import { pinyin } from 'pinyin-pro';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import {
getFriends, getFriendRequests, createFriendRequest, acceptFriendRequest, rejectFriendRequest,
deleteFriend, updateFriendRemark, searchUsers, getUserProfile, createMyQRCode, getBlocks,
@@ -14,9 +14,9 @@ import { readCachedData, writeCachedData, useCachedData } from '../lib/use-cache
// Carries an optional `uid` (and anything else already in the hash) across chapter-5 navigation --
// e.g. address-book -> friend-profile -> friend-settings all need to know which person this is.
const navigate = (view, uid) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
hash = uid ? HashNavigation.addUrlParam(hash, 'uid', uid) : HashNavigation.removeUrlParam(hash, 'uid');
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
hash = uid ? Navigation.addUrlParam(hash, 'uid', uid) : Navigation.removeUrlParam(hash, 'uid');
Navigation.navigateTo(hash);
};
export const CONTACT_MANAGEMENT_VIEWS = [
@@ -44,7 +44,7 @@ function alphaInitial(name) {
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'messages-home', backUid, right, onRight}) {
return <header className="people-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backUid); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>;
return <header className="people-header"><button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back, backUid); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>;
}
// `person.avatar_file_id` (real API field) is loaded lazily via a cached authenticated fetch --
@@ -589,8 +589,8 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
};
return <div className="people-page"><Header title="聊天设置" back="friend-profile" backUid={uid} /><main className="people-settings">
<section className="people-settings-profile"><Avatar person={profile} /><strong>{(profile && profile.display_name) || ''}</strong></section>
<section><button type="button" onClick={() => HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'history-search'), 'topic', uid))}>
<section><button type="button" onClick={() => Navigation.navigateTo(
Navigation.addUrlParam(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'history-search'), 'topic', uid))}>
<span>查看聊天记录</span><Icon>chevron_right</Icon>
</button>
<div><span>消息传输方式</span><small>已启动加密传输</small></div></section>
@@ -622,11 +622,11 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
// The local navigate(view, uid) helper only carries a single `uid` param -- the report
// flow (security-support-view.jsx's ReportReasons/ReportDetails) needs target_type/
// target_id/target_name, so build the hash directly here rather than through it.
let hash = HashNavigation.setUrlSidePanel(window.location.hash, 'abuse-report-reasons');
hash = HashNavigation.addUrlParam(hash, 'target_type', 'user');
hash = HashNavigation.addUrlParam(hash, 'target_id', uid);
hash = HashNavigation.addUrlParam(hash, 'target_name', (profile && profile.display_name) || uid);
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), 'abuse-report-reasons');
hash = Navigation.addUrlParam(hash, 'target_type', 'user');
hash = Navigation.addUrlParam(hash, 'target_id', uid);
hash = Navigation.addUrlParam(hash, 'target_name', (profile && profile.display_name) || uid);
Navigation.navigateTo(hash);
}}><span>投诉与举报</span><Icon>chevron_right</Icon></button>
</section>
{blocked ? null : <button type="button" className="people-danger" onClick={() => navigate('friend-delete-confirm', uid)}>删除好友</button>}
+9 -9
View File
@@ -4,7 +4,7 @@ import { Drafty, Tinode } from 'tinode-sdk';
import AudioPlayer from '../widgets/audio-player.jsx';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
import {
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
@@ -40,15 +40,15 @@ function resolveSourceName(topic, onDone) {
}
// Generic multi-param navigate -- content-library-view.jsx's original helper was a bare
// `HashNavigation.navigateTo(view)`, replacing the whole hash and dropping any existing params on
// `Navigation.navigateTo(view)`, replacing the whole hash and dropping any existing params on
// every internal tab/link click; nothing in this file could carry a topic/favoriteId through its own
// navigation even if a caller threaded one in from outside.
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
Navigation.navigateTo(hash);
};
// Same as navigate() above but for switching this page's own type tabs (Favorites' 全部/图片/.../链接,
@@ -56,7 +56,7 @@ const navigate = (view, params) => {
// which page the user is on, so they shouldn't push a browser-history entry the way a real navigate()
// does. Previously every tab click called navigate(), so N tab switches pushed N history entries and
// the system back button just cycled through past tabs instead of leaving the page.
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
const navigateReplace = (view) => Navigation.navigateReplace(Navigation.setUrlSidePanel(Navigation.currentUrl(), view));
export const CONTENT_LIBRARY_VIEWS = [
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
@@ -68,7 +68,7 @@ export const CONTENT_LIBRARY_VIEWS = [
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'messages-home', backParams, right, onRight}) {
return <header className="library-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backParams); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back, backParams); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}
</header>;
@@ -695,11 +695,11 @@ function History({view, hashParams, myUserId, onToast}) {
}).catch(() => {});
});
}, [items]);
// Mirrors tinode-web.jsx's own handleTopicSelected exactly (HashNavigation.setUrlTopic('', topicName)
// Mirrors tinode-web.jsx's own handleTopicSelected exactly (Navigation.setUrlTopic('', topicName)
// -- an empty base hash, not the current one, per its "Clear newTopicParams after use" comment) so
// this lands the same way a normal chat-list click does, then layers the jump target on as a param;
// navigate()'s own helper only ever touches path[0]+params, never path[1] (the actual topic slot).
const jump = seq => HashNavigation.navigateTo(HashNavigation.addUrlParam(HashNavigation.setUrlTopic('', topic), 'jumpseq', seq));
const jump = seq => Navigation.navigateTo(Navigation.addUrlParam(Navigation.setUrlTopic('', topic), 'jumpseq', seq));
const nameOf = from => {
if (!from) return '';
if (from === myUserId || ('usr' + from) === myUserId) return '我';
+2 -2
View File
@@ -1,8 +1,8 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
const navigate = view => HashNavigation.navigateTo(view);
const navigate = view => Navigation.navigateTo(view);
const VIEW_CONFIG = {
'chat-inbound-request': {relationship: 'pending_inbound', variant: 'inbound-request'},
+18 -18
View File
@@ -2,7 +2,7 @@ import React from 'react';
import QRCode from 'qrcodejs';
import { pinyin } from 'pinyin-pro';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import {
getGroups, createGroup, getGroup, updateGroup, getGroupSettings, updateGroupMemberSettings,
updateMyGroupProfile, getMyGroupProfile, leaveGroup, getGroupMembers, createGroupQRCode, getGroupJoinRequests,
@@ -17,9 +17,9 @@ import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-c
// Carries the group `topic` (and anything else already in the hash) across navigation, the same way
// contact-management-view.jsx threads `uid` -- these pages all need to know *which* group.
const navigate = (view, topic) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic');
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
hash = topic ? Navigation.addUrlParam(hash, 'topic', topic) : Navigation.removeUrlParam(hash, 'topic');
Navigation.navigateTo(hash);
};
// 'group-member-profile' is contact-management-view.jsx's Profile component (Ch.5) reused as-is for
@@ -45,8 +45,8 @@ const navigateToProfile = (topic, userId, member) => {
...(member.gender ? {gender: member.gender} : {})
});
}
HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
Navigation.navigateTo(
Navigation.addUrlParam(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'group-member-profile'), 'uid', userId));
// 群场景下真正该用的是 GET /groups/{topic}/members/{user_id}/profile——群上下文里权限判断更
// 宽松,实测对方哪怕不是好友也能拿到 {capabilities, relationship}(Profile 组件目标页读的
// GET /users/{id}/profile 对非好友是 403,只能回退用种子)。异步补种:请求回来后如果用户还
@@ -140,7 +140,7 @@ function Icon({children}) { return <i className="material-icons" aria-hidden="tr
// navigate 辅助函数只会写 query 参数),这类情况传一个自己的回调进来。
function Header({title, back = 'group-list', backTopic, onBack, right, onRight}) {
return <header className="group-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) { if (onBack) onBack(); else navigate(back, backTopic); } }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<button type="button" onClick={() => { if (!Navigation.goBack()) { if (onBack) onBack(); else navigate(back, backTopic); } }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}
</header>;
@@ -266,7 +266,7 @@ function JoinRequests({topic, fromChat, onToast}) {
// ambiguity above -- without it, returning from a click on the in-chat banner always landed on
// "group-settings" (closing the open conversation) instead of back to the chat itself.
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.setUrlSidePanel('', ''), topic));
}, [topic]);
const [items, setItems] = React.useState(() => (topic && readCachedData('group-join-requests:' + topic)) || null);
const [profiles, setProfiles] = React.useState({});
@@ -337,14 +337,14 @@ function Announcement({topic, myRole, fromChat, onToast}) {
const canEdit = myRole === 'owner' || myRole === 'admin';
// Coming from the announcement banner atop the chat itself (messages-view.jsx's
// handleShowAnnouncement) vs. from group-settings' "群公告" row land on the identical
// path[0]=group-announcement-detail hash -- `fromChat` is the only thing telling them apart, so
// path[0]=group-announcement-detail URL -- `fromChat` is the only thing telling them apart, so
// "back" can return to the chat (close the sidepanel) instead of always forcing group-settings.
// 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼 hash,所以 path[1] 是空的),返回应该
// 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼路径,所以 path[1] 是空的),返回应该
// 关掉侧栏回到那个会话。原来写的是 navigate('', topic)——navigate 只把 topic 写成 query 参数,
// path[1] 依旧为空,tinode-web 的 handleHashRoute 就把 topicSelected 清成 null,会话跟着一起关了。
// path[1] 依旧为空,tinode-web 的 handleRouteChange 就把 topicSelected 清成 null,会话跟着一起关了。
// 这里显式把 topic 写回 path[1]。
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.setUrlSidePanel('', ''), topic));
}, [topic]);
React.useEffect(() => {
if (!topic) return;
@@ -491,13 +491,13 @@ function GroupSettings({topic, myUserId, tinode, onToast}) {
{/* target_type='group' 这条链路早就是通的(tinode-web.jsx 的 handleReportTopic 就在用),
这里原来一直是个"暂未开放"的死桩。跟 contact-management-view.jsx 的好友举报入口一样,
举报页要的是 target_type/target_id/target_name 三个参数,本文件的 navigate 辅助函数
只会写 topic,所以直接拼 hash。 */}
只会写 topic,所以直接拼路径。 */}
<button type="button" onClick={() => {
let hash = HashNavigation.setUrlSidePanel('', 'abuse-report-reasons');
hash = HashNavigation.addUrlParam(hash, 'target_type', 'group');
hash = HashNavigation.addUrlParam(hash, 'target_id', topic);
hash = HashNavigation.addUrlParam(hash, 'target_name', (group && group.name) || topic);
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel('', 'abuse-report-reasons');
hash = Navigation.addUrlParam(hash, 'target_type', 'group');
hash = Navigation.addUrlParam(hash, 'target_id', topic);
hash = Navigation.addUrlParam(hash, 'target_name', (group && group.name) || topic);
Navigation.navigateTo(hash);
}}><span>投诉/举报</span><Icon>chevron_right</Icon></button>
</section>
<button type="button" className="group-danger" onClick={leave}>退出群组</button>
+2 -2
View File
@@ -10,7 +10,7 @@ import CheckBox from '../widgets/checkbox.jsx';
import ContactList from '../widgets/contact-list.jsx';
import ErrorPanel from '../widgets/error-panel.jsx';
import GroupManager from '../widgets/group-manager.jsx';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import MenuCancel from '../widgets/menu-cancel.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx';
import ShowQRCode from '../widgets/show-qrcode.jsx';
@@ -455,7 +455,7 @@ class InfoView extends React.Component {
// system back button had to be pressed once per level ever visited, and each press could land back
// inside a sub-panel the user had already explicitly backed out of.
handleBackNavigate() {
if (HashNavigation.goBack()) {
if (Navigation.goBack()) {
return;
}
const args = (this.props.panel || 'info').split('/');
+6 -6
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { Tinode } from 'tinode-sdk';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { BASE_APP_NAME } from '../config.js';
import { getBootstrap, getRealnameStatus, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
@@ -15,9 +15,9 @@ import { topicDisplayName } from '../lib/utils.js';
// group-management-view.jsx 的同名辅助函数保持一致(不带 topic 时显式清掉,避免把上一个页面
// 残留的 topic 参数带进不相关的页面)。
const navigate = (view, topic) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic');
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
hash = topic ? Navigation.addUrlParam(hash, 'topic', topic) : Navigation.removeUrlParam(hash, 'topic');
Navigation.navigateTo(hash);
};
// BottomNavigation's 消息<->工作台 switch is the app's root-level tab bar, not a navigation to a
@@ -27,7 +27,7 @@ const navigate = (view, topic) => {
// two bottom tabs) made the back button just cycle through past tab switches instead of leaving the
// app, exactly like a standard bottom-tab-bar app's back button should not have to unwind every past
// tab switch to back out.
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
const navigateReplace = (view) => Navigation.navigateReplace(Navigation.setUrlSidePanel(Navigation.currentUrl(), view));
// Was a hardcoded FREQUENT_CONTACTS mock (5 fake names, always clicking through to the same
// direct-message-view.jsx demo regardless of which avatar was tapped -- confirmed independently by
@@ -501,7 +501,7 @@ function SystemMessages({authenticated}) {
};
return <div className="system-messages-page">
<header className="messages-sub-header"><button onClick={() => { if (!HashNavigation.goBack()) navigate('messages-home'); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
<header className="messages-sub-header"><button onClick={() => { if (!Navigation.goBack()) navigate('messages-home'); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
<main className="system-notification-list">
{loading ? <p className="messages-empty"><Icon>hourglass_empty</Icon>加载中…</p> : null}
{error ? <p className="messages-empty"><Icon>error_outline</Icon>{error}</p> : null}
+2 -2
View File
@@ -1,8 +1,8 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
const navigate = view => HashNavigation.navigateTo(view);
const navigate = view => Navigation.navigateTo(view);
export const MESSAGE_COMPOSER_VIEWS = [
'compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message',
+15 -15
View File
@@ -49,7 +49,7 @@ import { topicDisplayName } from '../lib/utils.js';
import { acceptGroupInvitation, checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap,
getConversationContext, getFavorites, getFriendRequests, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement,
getTopicDraft, recallMessage, updateTopicDraft } from '../lib/im-api.js';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
// Run timer with this frequency (ms) for checking notification queue.
@@ -725,7 +725,7 @@ class MessagesView extends React.Component {
.then(ctrl => {
if (ctrl.code == 303) {
// Redirect to another topic requested.
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic));
Navigation.navigateTo(Navigation.setUrlTopic('', ctrl.params.topic));
return;
}
if (this.state.topic != ctrl.topic) {
@@ -1441,28 +1441,28 @@ class MessagesView extends React.Component {
}).catch(_ => {});
}
// Note: setUrlSidePanel only overwrites path[0] -- called with window.location.hash while a topic is
// Note: setUrlSidePanel only overwrites path[0] -- called with Navigation.currentUrl() while a topic is
// open (path[1] is the open topic), it leaves that segment in place, producing e.g.
// "#group-announcement-detail/grpXXX?topic=grpXXX". handleHashRoute then re-parses path[1] as a topic
// selector on every hashchange, fighting with the sidepanel that's trying to open and leaving the
// whole view blank. Build the target hash from an empty path instead of the current one.
// "/group-announcement-detail/grpXXX?topic=grpXXX". handleRouteChange then re-parses path[1] as a topic
// selector on every route change, fighting with the sidepanel that's trying to open and leaving the
// whole view blank. Build the target URL from an empty path instead of the current one.
handleShowAnnouncement(e) {
e.preventDefault();
// `from=chat` lets Announcement (group-management-view.jsx) tell this entry point apart from
// the one via group-settings -- both land on the identical path[0]/topic hash otherwise, so
// without it the "back" button can't know whether to return to group-settings or to this chat.
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-announcement-detail'), 'topic', this.state.topic);
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
HashNavigation.navigateTo(hash);
let hash = Navigation.addUrlParam(Navigation.setUrlSidePanel('', 'group-announcement-detail'), 'topic', this.state.topic);
hash = Navigation.addUrlParam(hash, 'from', 'chat');
Navigation.navigateTo(hash);
}
handleShowJoinRequests(e) {
e.preventDefault();
// `from=chat` mirrors handleShowAnnouncement above -- lets JoinRequests (group-management-view.jsx)
// return to this chat instead of always landing on group-governance.
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
HashNavigation.navigateTo(hash);
let hash = Navigation.addUrlParam(Navigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
hash = Navigation.addUrlParam(hash, 'from', 'chat');
Navigation.navigateTo(hash);
}
handleSendFriendVerification(e) {
@@ -1471,8 +1471,8 @@ class MessagesView extends React.Component {
// current hash, since a topic is open) -- this one additionally never set `uid`, which the
// friend-verification page needs (contact-management-view.jsx's Verification reads hashParams.uid)
// to know who the verification is for; for a P2P topic that's simply the topic name itself.
HashNavigation.navigateTo(HashNavigation.addUrlParam(
HashNavigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
Navigation.navigateTo(Navigation.addUrlParam(
Navigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
}
// 6.3 加入群聊.png:被邀请人在跟邀请人的会话里收到一张"邀请你加入群聊……立即加入"的卡片。
@@ -2239,7 +2239,7 @@ class MessagesView extends React.Component {
if (!topic || !seq || isNaN(seq)) {
return;
}
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'jumpseq'));
Navigation.navigateTo(Navigation.removeUrlParam(Navigation.currentUrl(), 'jumpseq'));
const jump = () => window.setTimeout(() => this.handleQuoteClick(seq), 150);
if (topic.findMessage(seq)) {
jump();
+6 -6
View File
@@ -9,7 +9,7 @@ import NewTopicById from '../widgets/new-topic-by-id.jsx';
import NewTopicGroup from '../widgets/new-topic-group.jsx';
import SearchContacts from '../widgets/search-contacts.jsx';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { asEmail, asPhone, theCard } from '../lib/utils.js';
const messages = defineMessages({
@@ -34,7 +34,7 @@ class NewTopicView extends React.Component {
constructor(props) {
super(props);
const urlParams = HashNavigation.parseUrlHash(window.location.hash).params;
const urlParams = Navigation.parseUrl(Navigation.currentUrl()).params;
const initialTab = urlParams['tab'] || 'find';
const initialQuery = initialTab == 'find' ? (urlParams['q'] || null) : null;
@@ -60,7 +60,7 @@ class NewTopicView extends React.Component {
// page -- navigateTo() pushed a history entry per click purely to keep the `tab` url param in
// sync, so repeatedly switching tabs made the back button cycle through past tabs instead of
// leaving this page. navigateReplace() keeps the param in sync without that side effect.
HashNavigation.navigateReplace(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
Navigation.navigateReplace(Navigation.addUrlParam(Navigation.currentUrl(), 'tab', e.currentTarget.dataset.id));
this.setState({tabSelected: e.currentTarget.dataset.id});
}
@@ -91,19 +91,19 @@ class NewTopicView extends React.Component {
handleSearchResultSelected(topicName) {
if (this.state.tabSelected == 'find') {
HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));
Navigation.navigateTo(Navigation.removeUrlParams(Navigation.currentUrl(), ['tab', 'q']));
this.props.onCreateTopic(topicName);
}
}
handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) {
HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));
Navigation.navigateTo(Navigation.removeUrlParams(Navigation.currentUrl(), ['tab', 'q']));
this.props.onCreateTopic(undefined,
{public: theCard(name, dataUrl, null, description), private: priv, tags: tags}, isChannel);
}
handleGroupByID(topicName) {
HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));
Navigation.navigateTo(Navigation.removeUrlParams(Navigation.currentUrl(), ['tab', 'q']));
this.props.onCreateTopic(topicName);
}
+22 -11
View File
@@ -1,7 +1,7 @@
import React from 'react';
import QRCode from 'qrcodejs';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { BASE_APP_NAME } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
@@ -12,11 +12,22 @@ import {
} from '../lib/im-api.js';
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
Navigation.navigateTo(hash);
};
// Same as navigate() but replaces the current history entry instead of pushing a new one -- for
// steps within a single flow (e.g. youth-mode off/on/verify) so "back" leaves the flow instead of
// re-walking its already-completed steps.
const navigateReplace = (view, params) => {
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
});
Navigation.navigateReplace(hash);
};
export const PERSONAL_SETTINGS_VIEWS = [
@@ -33,7 +44,7 @@ function Icon({children}) { return <i className="material-icons" aria-hidden="tr
function Header({title, back = 'personal-center', action, actionLabel, onAction, actionDisabled}) {
return <header className="personal-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{action ? <button type="button" className="text" onClick={onAction} disabled={actionDisabled}>{actionLabel || action}</button> : <span />}
</header>;
@@ -319,8 +330,8 @@ function SettingsList({onToast, onLogout, tinode, chatList, onDeleteAccount}) {
<MenuRow icon="verified_user" title="实名认证" value={realnameLabel} route="realname" />
{/* security-support-view.jsx 的 FraudAwareness 组件自己的注释就写着这应该是个
"always-reachable safety-info page",但此前没有任何页面真的链接过去——只能靠手改
hash 才能打开。这是 personal-settings-view.jsx 之外的一个路由,和本文件其它跳转
用的是同一套 hash 导航,可以直接指过去。 */}
地址栏才能打开。这是 personal-settings-view.jsx 之外的一个路由,和本文件其它跳转
用的是同一套导航机制,可以直接指过去。 */}
<MenuRow icon="shield" title="安全教育" route="fraud-risk-warning" />
</section>
<section><MenuRow icon="no_accounts" title="注销账号" onClick={() => setSheet('delete')} danger /></section>
@@ -460,7 +471,7 @@ function ChangePassword({onUpdatePassword, onToast}) {
});
};
return <div className="personal-page profile-editor-page"><Header title="修改密码" back="android-settings" action="完成"
actionLabel={busy ? '处理中…' : '完成'} actionDisabled={busy} onAction={submit} /><main>
actionLabel={busy ? '处理中…' : '完成'} actionDisabled={busy} onAction={submit} /><main className="change-password-fields">
<label><span>原密码</span><input type="password" value={oldPwd} onChange={e => setOldPwd(e.target.value)} placeholder="请输入当前密码" /></label>
<label><span>新密码</span><input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} placeholder="至少6位" /></label>
<label><span>确认新密码</span><input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} placeholder="再次输入新密码" /></label>
@@ -648,7 +659,7 @@ function YouthMode({onToast}) {
window.setTimeout(() => navigate('identity-check', {ym: 'enable'}), 400);
return;
}
navigate('youth-mode-verify', {ym: 'enable'});
navigateReplace('youth-mode-verify', {ym: 'enable'});
};
if (!settings) return <div className="personal-page youth-mode-page"><Header title="青少年模式" back="android-settings" /></div>;
@@ -663,7 +674,7 @@ function YouthMode({onToast}) {
</main>
<footer>
<button type="button" className={settings.enabled ? 'outline' : ''}
onClick={() => settings.enabled ? navigate('youth-mode-verify', {ym: 'disable'}) : startEnable()}>
onClick={() => settings.enabled ? navigateReplace('youth-mode-verify', {ym: 'disable'}) : startEnable()}>
{settings.enabled ? '关闭青少年模式' : '开启青少年模式'}
</button>
<small>可在设置中查看青少年模式状态</small>
@@ -718,7 +729,7 @@ function YouthModeVerify({hashParams, onToast}) {
// flip, which is a more confusing flash than the plain loading state they had before caching.
if (settings) writeCachedData('youth-mode', {...settings, enabled: mode === 'enable'});
onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭');
window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
window.setTimeout(() => navigateReplace(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
}).catch(err => {
// Server locks out further attempts for retry_after_seconds after too many wrong passwords
// (any request during that window 429s regardless of whether THIS password is correct) --
+6 -6
View File
@@ -1,15 +1,15 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { uploadLargeFile, getAbuseReasons, submitAbuseReport, getFeedbackConfig, submitFeedback } from '../lib/im-api.js';
import { readCachedData, useCachedData } from '../lib/use-cached-data.js';
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
Navigation.navigateTo(hash);
};
export const SECURITY_SUPPORT_VIEWS = [
@@ -20,7 +20,7 @@ export const SECURITY_SUPPORT_VIEWS = [
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back}) {
return <header className="support-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
return <header className="support-header"><button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
}
function Toast({message, onDone}) {
@@ -246,7 +246,7 @@ function UserFeedback({tinode, onToast}) {
<small>{content.length}/{maxLength}</small>
</label>
<section className="feedback-upload">
<header><span><strong>图片</strong></span><em>{images.length}/{maxFiles}</em></header>
<header><strong>图片({images.length}/{maxFiles})</strong></header>
<button type="button" disabled={uploading} onClick={() => fileInputRef.current && fileInputRef.current.click()}><Icon>add_a_photo</Icon><span>{uploading ? '上传中…' : '点击上传'}</span></button>
<input ref={fileInputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={addImage} />
</section>
+71 -71
View File
@@ -31,7 +31,7 @@ import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/hos
import LocalStorageUtil from '../lib/local-storage.js';
import { getTenantContext, resolveQRCode, createGroupJoinRequest, getBootstrap } from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { secondsToTime } from '../lib/strformat.js'
import { defaultWallpaper, theCard, topicDisplayName, updateFavicon } from '../lib/utils.js';
@@ -101,7 +101,7 @@ const messages = defineMessages({
}
});
// 全部合法的侧栏视图名。抽成模块级常量而不是内联在 handleHashRoute 里,是因为初始化时的
// 全部合法的侧栏视图名。抽成模块级常量而不是内联在 handleRouteChange 里,是因为初始化时的
// "要不要保留 hash" 判断也需要同一份名单——那里原来写的是上游 Tinode 遗留的
// ['cred','reset','register'] 三个名字,本工程新增的上百个路由一个都不在里面,导致带任何新路由的
// 地址刷新/深链进入都会被 navigateTo('') 清空 hash 弹回首页。
@@ -231,7 +231,7 @@ class TinodeWeb extends React.Component {
this.state = this.getBlankState();
this.handleResize = this.handleResize.bind(this);
this.handleHashRoute = this.handleHashRoute.bind(this);
this.handleRouteChange = this.handleRouteChange.bind(this);
this.handleOnline = this.handleOnline.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.handleColorSchemeChange = this.handleColorSchemeChange.bind(this);
@@ -470,7 +470,7 @@ class TinodeWeb extends React.Component {
window.addEventListener('online', this.handleOnlineOn);
this.handleOnlineOff = _ => { this.handleOnline(false); }
window.addEventListener('offline', this.handleOnlineOff);
window.addEventListener('hashchange', this.handleHashRoute);
window.addEventListener('popstate', this.handleRouteChange);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.handleColorSchemeChange);
// Window/tab visible or invisible for pausing timers.
@@ -550,24 +550,24 @@ class TinodeWeb extends React.Component {
}
// Parse the hash navigation params.
const parsedNav = HashNavigation.parseUrlHash(window.location.hash);
const parsedNav = Navigation.parseUrl(Navigation.currentUrl());
// Maybe navigate to home screen.
if (!SIDE_PANEL_VIEWS.includes(parsedNav.path[0]) || !parsedNav.path[0]) {
// Save possible topic name.
this.setState({requestedTopic: parsedNav.path[1]});
const path = parsedNav.params && parsedNav.params.cred_done ?
HashNavigation.addUrlParam('', 'cred_done', parsedNav.params.cred_done):
Navigation.addUrlParam('', 'cred_done', parsedNav.params.cred_done):
'';
HashNavigation.navigateTo(path);
Navigation.navigateTo(path);
} else {
this.handleHashRoute();
this.handleRouteChange();
}
});
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('hashchange', this.handleHashRoute);
window.removeEventListener('popstate', this.handleRouteChange);
window.removeEventListener('online', this.handleOnlineOn);
window.removeEventListener('offline', this.handleOnlineOff);
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this.handleColorSchemeChange);
@@ -742,7 +742,7 @@ class TinodeWeb extends React.Component {
alertVisible: false
});
// Navigate to contacts or login view
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
Navigation.navigateTo(Navigation.setUrlTopic('', null));
}
}
@@ -766,9 +766,9 @@ class TinodeWeb extends React.Component {
}
}
// Handle for hash navigation (hashchange) event: update state.
handleHashRoute() {
const hash = HashNavigation.parseUrlHash(window.location.hash);
// Handler for route navigation (popstate) event: update state.
handleRouteChange() {
const hash = Navigation.parseUrl(Navigation.currentUrl());
// Browser back navigation after a successful login re-renders one of the pre-login views
// (most commonly 'login') while the underlying Tinode connection is still authenticated --
// hash routing and the WS session's lifecycle are independent, so nothing else here would
@@ -780,7 +780,7 @@ class TinodeWeb extends React.Component {
// untranslated "already authenticated (409)" for registration. Redirecting away before any of
// that code runs removes the only way to reach it.
if (hash.path && AUTH_FLOW_VIEWS.includes(hash.path[0]) && this.tinode.isAuthenticated()) {
HashNavigation.navigateTo('');
Navigation.navigateTo('');
return;
}
// Start with panel parameters.
@@ -804,13 +804,13 @@ class TinodeWeb extends React.Component {
// A syntactically valid topic name is not enough on its own: mobilePanel='topic-view' only
// ever pairs correctly with MessagesView actually being allowed to render, which requires
// !chapterOneActive (i.e. this.tinode.isAuthenticated()) -- see tinode-web.jsx's render().
// Without this check, opening (or being redirected/deep-linked to) a #/<topic> URL while
// Without this check, opening (or being redirected/deep-linked to) a `/<topic>` URL while
// logged out set mobilePanel to 'topic-view' anyway; on mobile that made *both* the
// SidepanelView gate (`mobilePanel=='sidepanel'`) and the MessagesView gate
// (`!chapterOneActive`) false at once, so neither rendered -- a permanently blank white
// screen (app-container empty, no thrown error) that a hash change alone couldn't recover
// from, only a full reload. Reproduced live: logging out, then setting location.hash to a
// real group's `#/<topic>` URL. Falling back to the same "invalid topic" branch already used
// screen (app-container empty, no thrown error) that a route change alone couldn't recover
// from, only a full reload. Reproduced live: logging out, then navigating to a real group's
// `/<topic>` URL. Falling back to the same "invalid topic" branch already used
// for a malformed name reuses a path already proven safe with topicName=null.
if (!Tinode.topicType(topicName) || !this.tinode.isAuthenticated()) {
// Clear invalid topic name and hide messages view on mobile.
@@ -1009,7 +1009,7 @@ class TinodeWeb extends React.Component {
doLogin(login, password, tmpToken, cred) {
if (this.tinode.isAuthenticated()) {
// Already logged in. Go to default screen.
HashNavigation.navigateTo('');
Navigation.navigateTo('');
return;
}
@@ -1017,7 +1017,7 @@ class TinodeWeb extends React.Component {
if (!(login && password) && !token) {
// No login credentials provided.
// Make sure we are on the login page.
HashNavigation.navigateTo('');
Navigation.navigateTo('');
this.setState({loginDisabled: false});
return;
}
@@ -1064,7 +1064,7 @@ class TinodeWeb extends React.Component {
if (token) {
// 存的 token 失效(过期/被撤销)——这属于"会话没了",回到登录入口是对的。
this.handleLogout();
HashNavigation.navigateTo('');
Navigation.navigateTo('');
}
// 用户手输账号密码失败(401 之类)时**不要**导航。原来这里无条件 navigateTo(''),
// 会把 sidePanelSelected 清成 '',render 里 '' 又落到 'tenant',于是密码打错一次就被
@@ -1085,12 +1085,12 @@ class TinodeWeb extends React.Component {
if (!Array.isArray(params.cred) || !params.cred.length) {
return false;
}
const parsed = HashNavigation.parseUrlHash(window.location.hash);
const parsed = Navigation.parseUrl(Navigation.currentUrl());
parsed.path[0] = 'cred';
parsed.params['method'] = params.cred[0];
parsed.params['token'] = params.token;
parsed.params['code'] = params.code;
HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params));
Navigation.navigateTo(Navigation.composeUrl(parsed.path, parsed.params));
return true;
}
@@ -1120,7 +1120,7 @@ class TinodeWeb extends React.Component {
// Only the failure branch of doLogin's promise chain reset this; a success never needed to
// since it navigates away immediately -- until the same Login form could be reached again
// afterwards (browser back) with this still stuck true, permanently disabling the submit
// button. handleHashRoute's redirect above now closes off that specific path, but resetting
// button. handleRouteChange's redirect above now closes off that specific path, but resetting
// it here too means this can't get stuck regardless of how the form ends up rendered again.
loginDisabled: false,
credMethod: undefined,
@@ -1142,7 +1142,7 @@ class TinodeWeb extends React.Component {
this.tinode.disconnect();
localStorage.removeItem('auth-token');
this.handleError(err.message, 'err');
HashNavigation.navigateTo('');
Navigation.navigateTo('');
}).finally(_ => {
this.setState({loadSpinnerVisible: false});
});
@@ -1151,17 +1151,17 @@ class TinodeWeb extends React.Component {
// actually see. Land on the workbench instead (matches the "工作台优先" home screen in the mocks).
//
// 但只在"真的刚走完登录流程"时才这么跳。这个方法在页面刷新触发自动登录时也会执行,原来无条件
// 改写 hash,等于把用户刷新前所在的页面冲掉——不管停在个人中心、群设置还是收藏,刷新后一律
// 被弹到工作台。现在先看当前 hash 是不是一个有效的、非登录流程的视图,是就原样保留。
const currentView = HashNavigation.parseUrlHash(window.location.hash).path[0];
// 改写路由,等于把用户刷新前所在的页面冲掉——不管停在个人中心、群设置还是收藏,刷新后一律
// 被弹到工作台。现在先看当前路由是不是一个有效的、非登录流程的视图,是就原样保留。
const currentView = Navigation.parseUrl(Navigation.currentUrl()).path[0];
const cameFromAuthFlow = !currentView || AUTH_FLOW_VIEWS.includes(currentView) ||
!SIDE_PANEL_VIEWS.includes(currentView);
let urlHash = cameFromAuthFlow ?
HashNavigation.setUrlSidePanel(window.location.hash, 'workbench') : window.location.hash;
Navigation.setUrlSidePanel(Navigation.currentUrl(), 'workbench') : Navigation.currentUrl();
if (goToTopic) {
urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic);
urlHash = Navigation.setUrlTopic(urlHash, goToTopic);
}
HashNavigation.navigateTo(urlHash);
Navigation.navigateTo(urlHash);
}
tnMeMetaDesc(desc) {
@@ -1425,7 +1425,7 @@ class TinodeWeb extends React.Component {
topicSelectedAcs: this.tinode.getTopicAccessMode(topicName),
forwardMessage: null
});
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName));
Navigation.navigateTo(Navigation.setUrlTopic('', topicName));
}
} else {
// Currently selected contact deleted
@@ -1440,7 +1440,7 @@ class TinodeWeb extends React.Component {
forwardMessage: null
});
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
Navigation.navigateTo(Navigation.setUrlTopic('', null));
}
}
@@ -1457,8 +1457,8 @@ class TinodeWeb extends React.Component {
this.setState({
mobilePanel: 'sidepanel'
});
if (!HashNavigation.goBack()) {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, null));
if (!Navigation.goBack()) {
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), null));
}
}
@@ -1563,7 +1563,7 @@ class TinodeWeb extends React.Component {
handleNewAccount() {
this.handleError();
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'register'));
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'register'));
}
// Actual registration of a new account.
@@ -1666,7 +1666,7 @@ class TinodeWeb extends React.Component {
handleSelectWallpapers() {
this.handleError();
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'wallpapers'));
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'wallpapers'));
}
applyWallpaperSettings(wallpaper, size, blur, effectiveSchema) {
@@ -1707,7 +1707,7 @@ class TinodeWeb extends React.Component {
handleSettings() {
this.handleError();
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(),
this.state.myUserId ? 'edit' : 'settings'));
}
@@ -1741,7 +1741,7 @@ class TinodeWeb extends React.Component {
secureConnection: secureConnection,
});
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, ''));
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), ''));
}
// Tenant was resolved via POST /v1/im/tenants/resolve (see auth-onboarding-view.jsx TenantEntry).
@@ -1776,13 +1776,13 @@ class TinodeWeb extends React.Component {
// User chose 'Archived chats'.
handleShowArchive() {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(),
this.state.myUserId ? 'archive' : ''));
}
// User viewes 'Blocked chats'.
handleShowBlocked() {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(),
this.state.myUserId ? 'blocked' : ''));
}
@@ -1838,7 +1838,7 @@ class TinodeWeb extends React.Component {
// User clicked Cancel button in Setting or Sign Up panel.
handleSidepanelCancel() {
const parsed = HashNavigation.parseUrlHash(window.location.hash);
const parsed = Navigation.parseUrl(Navigation.currentUrl());
let path = '';
if (['security','support','general','notif','acc_general'].includes(parsed.path[0])) {
path = 'edit';
@@ -1859,18 +1859,18 @@ class TinodeWeb extends React.Component {
delete parsed.params.scheme;
delete parsed.params.token;
}
HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params));
Navigation.navigateTo(Navigation.composeUrl(parsed.path, parsed.params));
this.setState({errorText: '', errorLevel: null});
}
// Sidepanel navigator. No need to bind to 'this'.
basicNavigator(hash) {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash));
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), hash));
}
// Topic info navigator. No need to bind to 'this'.
infoNavigator(hash) {
HashNavigation.navigateTo(HashNavigation.setUrlInfoPanel(window.location.hash, hash));
Navigation.navigateTo(Navigation.setUrlInfoPanel(Navigation.currentUrl(), hash));
}
// Request to start a topic, new or selected from search results, or "by ID".
@@ -1911,7 +1911,7 @@ class TinodeWeb extends React.Component {
nextState.topicSelected = newName;
}
this.setState(nextState, _ => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', newName));
Navigation.navigateTo(Navigation.setUrlTopic('', newName));
});
}
@@ -2033,7 +2033,7 @@ class TinodeWeb extends React.Component {
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
this.tinode.onInfoMessage = this.handleInfoMessage;
this.tinode.onDataMessage = this.handleDataMessage;
HashNavigation.navigateTo('');
Navigation.navigateTo('');
})
});
}
@@ -2053,7 +2053,7 @@ class TinodeWeb extends React.Component {
// Request to hard-delete topic.
topic.delTopic(true).then(_ => {
// Hide MessagesView and InfoView panels.
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), ''));
}).catch(err => {
this.handleError(err.message, 'err');
});
@@ -2078,7 +2078,7 @@ class TinodeWeb extends React.Component {
topic.leave(true).then(_ => {
// Hide MessagesView and InfoView panels.
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), ''));
}).catch(err => {
this.handleError(err.message, 'err');
});
@@ -2093,7 +2093,7 @@ class TinodeWeb extends React.Component {
topic.updateMode(null, '-JP')
.then(_ => {
// Hide MessagesView and InfoView panels.
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), ''));
})
.catch(err => this.handleError(err.message, 'err'));
}
@@ -2118,10 +2118,10 @@ class TinodeWeb extends React.Component {
// Remove J and P permissions (the "block" half of "block and report").
topic.updateMode(null, '-JP')
.then(_ => {
let hash = HashNavigation.setUrlTopic(window.location.hash, '');
hash = HashNavigation.setUrlSidePanel(hash, 'abuse-report-reasons');
Object.entries(params).forEach(([key, value]) => { hash = HashNavigation.addUrlParam(hash, key, value); });
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlTopic(Navigation.currentUrl(), '');
hash = Navigation.setUrlSidePanel(hash, 'abuse-report-reasons');
Object.entries(params).forEach(([key, value]) => { hash = Navigation.addUrlParam(hash, key, value); });
Navigation.navigateTo(hash);
})
.catch(err => this.handleError(err.message, 'err'));
}
@@ -2352,10 +2352,10 @@ class TinodeWeb extends React.Component {
...data.user,
capabilities: {can_add_friend: !!data.user.can_add}
});
let hash = HashNavigation.setUrlTopic(window.location.hash, '');
hash = HashNavigation.setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile');
hash = HashNavigation.addUrlParam(hash, 'uid', data.user.user_id);
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlTopic(Navigation.currentUrl(), '');
hash = Navigation.setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile');
hash = Navigation.addUrlParam(hash, 'uid', data.user.user_id);
Navigation.navigateTo(hash);
return;
}
this.setState({scannerOpen: false});
@@ -2400,7 +2400,7 @@ class TinodeWeb extends React.Component {
// The "信息" context-menu item used to always open the old, pre-migration InfoView (info-view.jsx)
// -- for P2P/group topics that's a real, previously-unnoticed gap: the new contact-management-view.jsx
// "friend-settings" / group-management-view.jsx "group-settings" pages (built and verified across
// Ch.5/6, with real HashNavigation.goBack()-based back navigation) had no way to actually be reached
// Ch.5/6, with real Navigation.goBack()-based back navigation) had no way to actually be reached
// from an open chat's own header -- every test of them this engagement typed the hash directly.
// InfoView's own back button (handleBackNavigate in info-view.jsx) still hardcodes a single fixed
// parent per panel and never used real browser history, so routing here fixed both problems at once
@@ -2408,25 +2408,25 @@ class TinodeWeb extends React.Component {
handleShowInfoView() {
const topicName = this.state.topicSelected;
if (Tinode.isP2PTopicName(topicName)) {
// Base the new hash on '' (not window.location.hash) -- the current hash's path[1] still holds
// Base the new URL on '' (not Navigation.currentUrl()) -- the current URL's path[1] still holds
// the open chat's own topic, and setUrlSidePanel only ever touches path[0]. Reusing the current
// hash as-is here leaves that topic sitting in path[1] where it collides with group-settings'/
// friend-settings' own query params, and handleHashRoute re-interprets the leftover path[1] as a
// URL as-is here leaves that topic sitting in path[1] where it collides with group-settings'/
// friend-settings' own query params, and handleRouteChange re-interprets the leftover path[1] as a
// fresh topic selection, blanking the whole view -- the exact bug already found and fixed for
// handleShowAnnouncement/handleShowJoinRequests/handleSendFriendVerification; same fix here.
let hash = HashNavigation.setUrlSidePanel('', 'friend-settings');
hash = HashNavigation.addUrlParam(hash, 'uid', topicName);
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel('', 'friend-settings');
hash = Navigation.addUrlParam(hash, 'uid', topicName);
Navigation.navigateTo(hash);
return;
}
if (!Tinode.isSelfTopicName(topicName)) {
let hash = HashNavigation.setUrlSidePanel('', 'group-settings');
hash = HashNavigation.addUrlParam(hash, 'topic', topicName);
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel('', 'group-settings');
hash = Navigation.addUrlParam(hash, 'topic', topicName);
Navigation.navigateTo(hash);
return;
}
// "已保存的消息" (self topic) has no equivalent new settings page -- keep the old InfoView for it.
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', 'info'));
Navigation.navigateTo(Navigation.addUrlParam(Navigation.currentUrl(), 'info', 'info'));
this.setState({infoPanel: 'info'});
}
@@ -2459,7 +2459,7 @@ class TinodeWeb extends React.Component {
if (this.tinode.isAuthenticated()) {
// Adding new email or phone number in account setting.
this.tinode.getMeTopic().setMeta({cred: {meth: cred, resp: code}})
.then(_ => HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'contacts')))
.then(_ => Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'contacts')))
.catch(err => this.handleError(err.message, 'err'));
} else {
// Credential validation on signup.
@@ -2487,7 +2487,7 @@ class TinodeWeb extends React.Component {
.then(_ => this.tinode.updateAccountBasic(null, null, newPassword, {scheme: tempAuth.scheme, secret: secret}))
.then(_ => {
this.handleError(this.props.intl.formatMessage(messages.password_reset_success), 'info');
HashNavigation.navigateTo('');
Navigation.navigateTo('');
})
.catch(err => {
this.handleError(err.message, 'err');
+2 -2
View File
@@ -1,7 +1,7 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { WALLPAPER_DEFAULTS } from '../config.js';
import { wallpaperNameFromUrl, wallpaperTypeFromUrl } from '../lib/utils.js';
import imageIndex from '../../img/bkg/index.json';
@@ -31,7 +31,7 @@ export default class WallpapersView extends React.PureComponent {
e.preventDefault();
// Same-page tab switch (pattern / image), not a navigation to a different page -- see the same
// fix/comment in new-topic-view.jsx's handleTabClick.
HashNavigation.navigateReplace(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
Navigation.navigateReplace(Navigation.addUrlParam(Navigation.currentUrl(), 'tab', e.currentTarget.dataset.id));
this.setState({tab: e.currentTarget.dataset.id});
}
+7 -7
View File
@@ -2,7 +2,7 @@ import React from 'react';
import ContactList from '../widgets/contact-list.jsx';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import {
uploadLargeFile, getMe, getFileUrl, getUserProfile,
getWorkbench,
@@ -15,20 +15,20 @@ import {
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
Navigation.navigateTo(hash);
};
// Same bug/fix as content-library-view.jsx's navigateReplace: DailyReportList/MonthlyReportList's
// 我的日报|月报 <-> 团队统计 switch is a same-page tab toggle (same component, just a `team` boolean),
// not a navigation to a different page -- using navigate() here pushed a history entry per click, so
// switching back and forth a few times made the system back button (and this app's own Header back
// button, gated on HashNavigation.goBack()) cycle through past toggle states instead of leaving the
// button, gated on Navigation.goBack()) cycle through past toggle states instead of leaving the
// report page. replaceState-based navigateReplace keeps the hash in sync without that side effect.
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
const navigateReplace = (view) => Navigation.navigateReplace(Navigation.setUrlSidePanel(Navigation.currentUrl(), view));
export const WORKPLACE_COLLABORATION_VIEWS = [
'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail',
@@ -41,7 +41,7 @@ function Icon({children}) { return <i className="material-icons" aria-hidden="tr
function Header({title, back = 'workbench', action, actionLabel, onAction, actionDisabled}) {
return <header className="workplace-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{action ? <button type="button" onClick={onAction} aria-label={actionLabel || '操作'} disabled={actionDisabled}><Icon>{action}</Icon></button> : <span />}
</header>;
+73 -9
View File
@@ -11,7 +11,7 @@ import ReceivedMarker from './received-marker.jsx'
import { fullFormatter } from '../lib/formatters.js';
import { sanitizeUrl } from '../lib/utils.js';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
// 4.9 语音未读红点:纯客户端概念,没有对应的后端字段——原生 Tinode 的 recv/read 只标记到
// "这条消息本身有没有被看到",跟"这条语音有没有被真的点开听过"是两回事(文字消息看一眼就算
@@ -62,6 +62,10 @@ class BaseChatMessage extends React.PureComponent {
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
this.suppressNativeContextMenu = this.suppressNativeContextMenu.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.handleDraftyClick = this.handleDraftyClick.bind(this);
this.handleVoicePlayed = this.handleVoicePlayed.bind(this);
@@ -100,17 +104,17 @@ class BaseChatMessage extends React.PureComponent {
// tinode:topic/usr123abc -> usr123abc
const pathname = new URL(e.target.dataset.val)?.pathname;
const parts = pathname.split('/').filter(Boolean);
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', parts.pop() || ''));
Navigation.navigateTo(Navigation.setUrlTopic('', parts.pop() || ''));
} catch (error) {
console.error("Invalid URL:", error);
}
break;
case 'contact_find':
e.preventDefault();
let hashUrl = HashNavigation.setUrlSidePanel(window.location.hash, 'newtpk');
hashUrl = HashNavigation.addUrlParam(hashUrl, 'q', e.target.dataset.val);
hashUrl = HashNavigation.addUrlParam(hashUrl, 'tab', 'find');
HashNavigation.navigateTo(hashUrl);
let hashUrl = Navigation.setUrlSidePanel(Navigation.currentUrl(), 'newtpk');
hashUrl = Navigation.addUrlParam(hashUrl, 'q', e.target.dataset.val);
hashUrl = Navigation.addUrlParam(hashUrl, 'tab', 'find');
Navigation.navigateTo(hashUrl);
break;
default:
// No special handling; let the browser deal with it.
@@ -163,9 +167,67 @@ class BaseChatMessage extends React.PureComponent {
this.props.onFormResponse(e.target.dataset.act, text, data);
}
// docs/images/消息页面—长按弹出.png names this "long-press", but the only wiring that ever existed
// was handleContextClick below, bound to a click on the tiny .menuTrigger chevron -- no touchstart/
// setTimeout path, checked across this file's entire history. Real touch devices have no way to
// trigger it that matches the reference image. This adds an actual press-and-hold gesture on the
// bubble itself, reusing the same menu-building logic so both paths stay in sync.
handleTouchStart(e) {
if (!this.props.showContextMenu || this.props.selectMode) {
return;
}
const touch = e.touches && e.touches[0];
this.longPressOrigin = {x: touch ? touch.pageX : e.pageX, y: touch ? touch.pageY : e.pageY};
this.longPressTimer = window.setTimeout(() => {
this.longPressTimer = null;
this.openContextMenu(this.longPressOrigin.x, this.longPressOrigin.y);
}, 500);
}
handleTouchMove(e) {
if (!this.longPressOrigin) {
return;
}
const touch = e.touches && e.touches[0];
const x = touch ? touch.pageX : e.pageX;
const y = touch ? touch.pageY : e.pageY;
// A real press-and-hold shouldn't fire if the finger is actually scrolling the list.
if (Math.abs(x - this.longPressOrigin.x) > 10 || Math.abs(y - this.longPressOrigin.y) > 10) {
this.cancelLongPress();
}
}
handleTouchEnd() {
this.cancelLongPress();
}
cancelLongPress() {
if (this.longPressTimer) {
window.clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
this.longPressOrigin = null;
}
// The press-and-hold gesture above is our own timer-based approximation of "long press" -- it
// doesn't stop the browser's own native context menu (right-click on desktop, or the OS-level
// long-press menu some browsers still fire as a real `contextmenu` event even for a touch/mouse
// hold). Left alone, both menus show up stacked on top of each other.
suppressNativeContextMenu(e) {
e.preventDefault();
}
componentWillUnmount() {
this.cancelLongPress();
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
this.openContextMenu(e.pageX, e.pageY);
}
openContextMenu(x, y) {
const menuItems = [];
if (this.props.received == Tinode.MESSAGE_STATUS_FAILED) {
menuItems.push('menu_item_send_retry');
@@ -203,8 +265,8 @@ class BaseChatMessage extends React.PureComponent {
head: this.props.head,
userFrom: this.props.userFrom,
userName: this.props.userName,
y: e.pageY,
x: e.pageX,
y: y,
x: x,
pickReply: this.props.pickReply,
editMessage: this.props.editMessage,
timestamp: this.props.timestamp,
@@ -418,7 +480,9 @@ class BaseChatMessage extends React.PureComponent {
{isFailed && failReason ?
<i className="material-icons red message-fail-icon">error</i> : null}
<div>
<div className={bubbleClass}>
<div className={bubbleClass}
onTouchStart={this.handleTouchStart} onTouchMove={this.handleTouchMove} onTouchEnd={this.handleTouchEnd}
onContextMenu={this.suppressNativeContextMenu}>
{showVoiceUnreadDot ? <i className="voice-unread-dot" /> : null}
<div className="content-meta">
<div className={textSizeClass}>
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,2 +1,2 @@
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[686],{3686:function(e,a,t){t.r(a),t.d(a,{default:function(){return c}});var s=t(1594),l=t.n(s),i=t(8181),r=t(184),n=t(3272),d=t(7849),h=t(1781),p=t(4848);class c extends l().PureComponent{constructor(e){super(e);const a=(0,d.MC)(this.props.wallpaper),t=(0,d.t7)(this.props.wallpaper);this.state={tab:a||n.S.type,selectedType:a||n.S.type,wallpaper:t||h[n.S.type][n.S.index].name,blur:0|this.props.wallpaperBlur},this.blurValues=[0,1,2,4,8,16],this.handleTabClick=this.handleTabClick.bind(this),this.handleWallpaperSelected=this.handleWallpaperSelected.bind(this),this.handleBlurChanged=this.handleBlurChanged.bind(this),this.hasChanged=this.hasChanged.bind(this)}handleTabClick(e){e.preventDefault(),r.A.navigateTo(r.A.addUrlParam(window.location.hash,"tab",e.currentTarget.dataset.id)),this.setState({tab:e.currentTarget.dataset.id})}handleWallpaperSelected(e){let a,t,s;if(e)e.preventDefault(),t=this.state.tab,a=e.currentTarget.dataset.id,s="patt"==t?0:this.state.blur;else{if(!this.hasChanged())return;t=n.S.type,a=n.S.index,s=0}const l=h[t][a].name,i="patt"==t?h[t][a].size:0;this.setState({tab:t,wallpaper:l,selectedType:t,blur:s}),this.props.onWallpaperSelected(`../${n.S.path}${l}`,i,this.blurValues[s])}handleBlurChanged(e){e.preventDefault();const a=e.currentTarget.value;this.setState({blur:a}),"img"==this.state.selectedType&&this.props.onWallpaperSelected(`../${n.S.path}${this.state.wallpaper}`,0,this.blurValues[a])}hasChanged(){return this.state.selectedType!=n.S.type||this.state.wallpaper!=h[n.S.type][n.S.index].name||0!=this.state.blur}render(){return(0,p.jsxs)("div",{className:"flex-column",children:[(0,p.jsx)("div",{className:"panel-form-row"+(this.hasChanged()?" clean-clickable":""),children:(0,p.jsxs)("a",{className:"flat-button"+(this.hasChanged()?"":" disabled"),onClick:()=>this.handleWallpaperSelected(null),children:[(0,p.jsx)("i",{className:"material-icons",children:"undo"})," ",(0,p.jsx)(i.FormattedMessage,{id:"button_restore",defaultMessage:"Restore default",description:"Reset setting to default"})]})}),(0,p.jsxs)("ul",{className:"tabbar",children:[(0,p.jsx)("li",{className:"patt"===this.state.tab?"active":null,children:(0,p.jsx)("a",{href:"#","data-id":"patt",onClick:this.handleTabClick,children:(0,p.jsx)(i.FormattedMessage,{id:"tabtitle_pattern",defaultMessage:"pattern",description:"Tab title Pattern"})})}),(0,p.jsx)("li",{className:"img"===this.state.tab?"active":null,children:(0,p.jsx)("a",{href:"#","data-id":"img",onClick:this.handleTabClick,children:(0,p.jsx)(i.FormattedMessage,{id:"tabtitle_image",defaultMessage:"image",description:"Tab title Image"})})})]}),(0,p.jsx)("div",{id:"settings-form",className:"scrollable-panel",children:"patt"===this.state.tab?(0,p.jsx)("div",{className:"image-grid",children:h.patt.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"",s="dark"==this.props.colorSchema?"inverted":null;return(0,p.jsx)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsx)("img",{src:n.S.path+e.name,alt:e.name,className:s,style:{width:`${e.size}px`,height:`${e.size}px`}})},a)})}):(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)("div",{className:"panel-form-column",children:[(0,p.jsx)("div",{className:"panel-form-row",children:(0,p.jsx)("label",{className:"small",children:(0,p.jsx)(i.FormattedMessage,{id:"label_blur_wallpaper",defaultMessage:"Blur:",description:"Label adjusting blur amount"})})}),(0,p.jsx)("div",{className:"panel-form-row",children:(0,p.jsx)("input",{type:"range",id:"blur",name:"blur",min:"0",max:"5",step:"1",value:this.state.blur,onChange:this.handleBlurChanged})})]}),(0,p.jsx)("div",{className:"image-grid",children:h.img.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"";return(0,p.jsx)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsx)("img",{src:n.S.path+e.pr,alt:e.name,style:{width:"100%",height:"100%"}})},a)})})]})})]})}}}}]);
"use strict";(self.webpackChunktinode_webapp=self.webpackChunktinode_webapp||[]).push([[686],{3686:function(e,a,t){t.r(a),t.d(a,{default:function(){return c}});var s=t(1594),l=t.n(s),i=t(8181),r=t(184),n=t(3272),d=t(7849),h=t(1781),p=t(4848);class c extends l().PureComponent{constructor(e){super(e);const a=(0,d.MC)(this.props.wallpaper),t=(0,d.t7)(this.props.wallpaper);this.state={tab:a||n.S.type,selectedType:a||n.S.type,wallpaper:t||h[n.S.type][n.S.index].name,blur:0|this.props.wallpaperBlur},this.blurValues=[0,1,2,4,8,16],this.handleTabClick=this.handleTabClick.bind(this),this.handleWallpaperSelected=this.handleWallpaperSelected.bind(this),this.handleBlurChanged=this.handleBlurChanged.bind(this),this.hasChanged=this.hasChanged.bind(this)}handleTabClick(e){e.preventDefault(),r.A.navigateReplace(r.A.addUrlParam(r.A.currentUrl(),"tab",e.currentTarget.dataset.id)),this.setState({tab:e.currentTarget.dataset.id})}handleWallpaperSelected(e){let a,t,s;if(e)e.preventDefault(),t=this.state.tab,a=e.currentTarget.dataset.id,s="patt"==t?0:this.state.blur;else{if(!this.hasChanged())return;t=n.S.type,a=n.S.index,s=0}const l=h[t][a].name,i="patt"==t?h[t][a].size:0;this.setState({tab:t,wallpaper:l,selectedType:t,blur:s}),this.props.onWallpaperSelected(`../${n.S.path}${l}`,i,this.blurValues[s])}handleBlurChanged(e){e.preventDefault();const a=e.currentTarget.value;this.setState({blur:a}),"img"==this.state.selectedType&&this.props.onWallpaperSelected(`../${n.S.path}${this.state.wallpaper}`,0,this.blurValues[a])}hasChanged(){return this.state.selectedType!=n.S.type||this.state.wallpaper!=h[n.S.type][n.S.index].name||0!=this.state.blur}render(){return(0,p.jsxs)("div",{className:"flex-column",children:[(0,p.jsx)("div",{className:"panel-form-row"+(this.hasChanged()?" clean-clickable":""),children:(0,p.jsxs)("a",{className:"flat-button"+(this.hasChanged()?"":" disabled"),onClick:()=>this.handleWallpaperSelected(null),children:[(0,p.jsx)("i",{className:"material-icons",children:"undo"})," ",(0,p.jsx)(i.FormattedMessage,{id:"button_restore",defaultMessage:"Restore default",description:"Reset setting to default"})]})}),(0,p.jsxs)("ul",{className:"tabbar",children:[(0,p.jsx)("li",{className:"patt"===this.state.tab?"active":null,children:(0,p.jsx)("a",{href:"#","data-id":"patt",onClick:this.handleTabClick,children:(0,p.jsx)(i.FormattedMessage,{id:"tabtitle_pattern",defaultMessage:"pattern",description:"Tab title Pattern"})})}),(0,p.jsx)("li",{className:"img"===this.state.tab?"active":null,children:(0,p.jsx)("a",{href:"#","data-id":"img",onClick:this.handleTabClick,children:(0,p.jsx)(i.FormattedMessage,{id:"tabtitle_image",defaultMessage:"image",description:"Tab title Image"})})})]}),(0,p.jsx)("div",{id:"settings-form",className:"scrollable-panel",children:"patt"===this.state.tab?(0,p.jsx)("div",{className:"image-grid",children:h.patt.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"",s="dark"==this.props.colorSchema?"inverted":null;return(0,p.jsx)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsx)("img",{src:n.S.path+e.name,alt:e.name,className:s,style:{width:`${e.size}px`,height:`${e.size}px`}})},a)})}):(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)("div",{className:"panel-form-column",children:[(0,p.jsx)("div",{className:"panel-form-row",children:(0,p.jsx)("label",{className:"small",children:(0,p.jsx)(i.FormattedMessage,{id:"label_blur_wallpaper",defaultMessage:"Blur:",description:"Label adjusting blur amount"})})}),(0,p.jsx)("div",{className:"panel-form-row",children:(0,p.jsx)("input",{type:"range",id:"blur",name:"blur",min:"0",max:"5",step:"1",value:this.state.blur,onChange:this.handleBlurChanged})})]}),(0,p.jsx)("div",{className:"image-grid",children:h.img.map((e,a)=>{const t=this.state.wallpaper===e.name?" selected":"";return(0,p.jsx)("div",{"data-id":a,className:`image-grid-cell${t}`,onClick:this.handleWallpaperSelected,children:(0,p.jsx)("img",{src:n.S.path+e.pr,alt:e.name,style:{width:"100%",height:"100%"}})},a)})})]})})]})}}}}]);
//# sourceMappingURL=686.prod.js.map
+1 -1
View File
File diff suppressed because one or more lines are too long
+2
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+140 -94
View File
@@ -4557,16 +4557,20 @@ class LocalStorageUtil {
"use strict";
__webpack_require__.r(__webpack_exports__);
/* harmony export */ __webpack_require__.d(__webpack_exports__, {
/* harmony export */ "default": function() { return /* binding */ HashNavigation; }
/* harmony export */ "default": function() { return /* binding */ Navigation; }
/* harmony export */ });
let navigationCount = 0;
class HashNavigation {
static parseUrlHash(hash) {
const parts = hash.split('?', 2);
const EMPTY_VIEW_PLACEHOLDER = '_';
class Navigation {
static parseUrl(url) {
const parts = url.split('?', 2);
const params = {};
let path = [];
if (parts[0]) {
path = parts[0].replace('#', '').split('/');
path = parts[0].replace(/^\//, '').split('/');
if (path.length > 1 && path[0] === EMPTY_VIEW_PLACEHOLDER) {
path[0] = '';
}
}
if (parts[1]) {
parts[1].split('&').forEach(arg => {
@@ -4581,13 +4585,17 @@ class HashNavigation {
params: params
};
}
static currentUrl() {
return window.location.pathname + window.location.search;
}
static navigateTo(url) {
navigationCount++;
window.location.hash = url;
window.history.pushState(null, '', url);
window.dispatchEvent(new PopStateEvent('popstate'));
}
static navigateReplace(url) {
window.history.replaceState(null, '', '#' + url);
window.dispatchEvent(new Event('hashchange'));
window.history.replaceState(null, '', url);
window.dispatchEvent(new PopStateEvent('popstate'));
}
static goBack() {
if (navigationCount > 0) {
@@ -4597,8 +4605,12 @@ class HashNavigation {
}
return false;
}
static composeUrlHash(path, params) {
let url = path.join('/');
static composeUrl(path, params) {
let segments = path;
if (segments.length > 1 && !segments[0]) {
segments = [EMPTY_VIEW_PLACEHOLDER, ...segments.slice(1)];
}
let url = '/' + segments.join('/');
const args = [];
for (const key in params) {
if (params.hasOwnProperty(key)) {
@@ -4612,45 +4624,45 @@ class HashNavigation {
}
return url;
}
static addUrlParam(hash, key, value) {
const parsed = HashNavigation.parseUrlHash(hash);
static addUrlParam(url, key, value) {
const parsed = Navigation.parseUrl(url);
parsed.params[key] = value;
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
static removeUrlParam(hash, key) {
const parsed = HashNavigation.parseUrlHash(hash);
static removeUrlParam(url, key) {
const parsed = Navigation.parseUrl(url);
delete parsed.params[key];
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
static removeUrlParams(hash, keys) {
static removeUrlParams(url, keys) {
if (!Array.isArray(keys)) {
keys = [keys];
}
const parsed = HashNavigation.parseUrlHash(hash);
const parsed = Navigation.parseUrl(url);
for (const key of keys) {
delete parsed.params[key];
}
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
static setUrlSidePanel(hash, sidepanel) {
const parsed = HashNavigation.parseUrlHash(hash);
static setUrlSidePanel(url, sidepanel) {
const parsed = Navigation.parseUrl(url);
parsed.path[0] = sidepanel;
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
static setUrlInfoPanel(hash, infopanel) {
const parsed = HashNavigation.parseUrlHash(hash);
static setUrlInfoPanel(url, infopanel) {
const parsed = Navigation.parseUrl(url);
if (infopanel) {
parsed.params.info = infopanel;
} else {
delete parsed.params.info;
}
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
static setUrlTopic(hash, topic) {
const parsed = HashNavigation.parseUrlHash(hash);
static setUrlTopic(url, topic) {
const parsed = Navigation.parseUrl(url);
parsed.path[1] = topic;
delete parsed.params.info;
return HashNavigation.composeUrlHash(parsed.path, parsed.params);
return Navigation.composeUrl(parsed.path, parsed.params);
}
}
@@ -5604,7 +5616,7 @@ const PHONE_RE = /^\d{11}$/;
const INVITE_RE = /^[A-Za-z]{5}$/;
const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const navigate = (view, params) => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].composeUrlHash([view], params || {}));
const navigate = (view, params) => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].composeUrl([view], params || {}));
function Icon({
children,
className = ''
@@ -6005,9 +6017,15 @@ function EnterpriseRegister() {
onChange: e => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, '')),
placeholder: "请输入18位统一社会信用代码",
maxLength: "18",
icon: "qr_code_scanner",
error: errors.creditCode,
onIconClick: () => setToast('扫码识别暂不可用,请手动输入')
action: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("button", {
type: "button",
className: "auth-inline-icon",
onClick: () => setToast('扫码识别暂不可用,请手动输入'),
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)(Icon, {
children: "qr_code_scanner"
}, void 0, false)
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("p", {
className: "auth-help",
children: "请填写营业执照上的统一社会信用代码"
@@ -7514,7 +7532,7 @@ __webpack_require__.r(__webpack_exports__);
const navigate = (view, uid) => {
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, view);
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), view);
hash = uid ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'uid', uid) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].removeUrlParam(hash, 'uid');
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(hash);
};
@@ -8493,7 +8511,7 @@ function Settings({
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("section", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
onClick: () => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, 'history-search'), 'topic', uid)),
onClick: () => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), 'history-search'), 'topic', uid)),
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("span", {
children: "查看聊天记录"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)(Icon, {
@@ -8595,7 +8613,7 @@ function Settings({
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_6__.jsxDEV)("button", {
type: "button",
onClick: () => {
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, 'abuse-report-reasons');
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), 'abuse-report-reasons');
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'target_type', 'user');
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'target_id', uid);
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'target_name', profile && profile.display_name || uid);
@@ -8981,13 +8999,13 @@ function resolveSourceName(topic, onDone) {
}).catch(() => {});
}
const navigate = (view, params) => {
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, view);
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, key, value) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].removeUrlParam(hash, key);
});
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(hash);
};
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, view));
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), view));
const CONTENT_LIBRARY_VIEWS = ['favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice', 'favorites-links', 'favorites-image', 'favorites-video', 'favorites-select', 'favorite-share', 'file-download', 'file-downloading', 'file-downloaded', 'history-search', 'history-files', 'history-links', 'history-images', 'history-videos'];
function Icon({
children
@@ -9444,8 +9462,9 @@ function Favorites({
});
}, [items]);
const toggle = id => setSelected(values => values.includes(id) ? values.filter(v => v !== id) : values.concat(id));
const deleteSelected = () => {
if (!window.confirm(`确定删除已选择的 ${selected.length} 项收藏吗?`)) return;
const [confirmingDelete, setConfirmingDelete] = react__WEBPACK_IMPORTED_MODULE_0___default().useState(false);
const confirmDelete = () => {
setConfirmingDelete(false);
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_5__.batchDeleteFavorites)(selected).then(result => {
const okIds = new Set((result && result.items || []).filter(r => r.ok).map(r => r.favorite_id));
setItems(values => {
@@ -9552,14 +9571,37 @@ function Favorites({
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
type: "button",
disabled: !selected.length,
onClick: deleteSelected,
onClick: () => setConfirmingDelete(true),
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(Icon, {
children: "delete_outline"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("span", {
children: "删除"
}, void 0, false)]
}, void 0, true)]
}, void 0, true) : null]
}, void 0, true) : null, confirmingDelete ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("div", {
className: "library-sheet-backdrop",
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("section", {
className: "library-confirm-sheet",
role: "dialog",
"aria-modal": "true",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)(Icon, {
children: "delete_outline"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("h2", {
children: "删除收藏"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("p", {
children: ["确定删除已选择的 ", selected.length, " 项收藏吗?"]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
type: "button",
className: "danger",
onClick: confirmDelete,
children: "确定"
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_8__.jsxDEV)("button", {
type: "button",
onClick: () => setConfirmingDelete(false),
children: "取消"
}, void 0, false)]
}, void 0, true)
}, void 0, false) : null]
}, void 0, true);
}
function FavoriteShare({
@@ -10844,7 +10886,7 @@ __webpack_require__.r(__webpack_exports__);
const navigate = (view, topic) => {
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, view);
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), view);
hash = topic ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(hash, 'topic', topic) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].removeUrlParam(hash, 'topic');
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(hash);
};
@@ -10865,7 +10907,7 @@ const navigateToProfile = (topic, userId, member) => {
} : {})
});
}
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_3__["default"].currentUrl(), 'group-member-profile'), 'uid', userId));
if (topic && userId) {
(0,_lib_im_api_js__WEBPACK_IMPORTED_MODULE_4__.getGroupMemberProfile)(topic, userId).then(real => {
const current = (0,_lib_use_cached_data_js__WEBPACK_IMPORTED_MODULE_5__.readCachedData)('profile-seed:' + userId) || {};
@@ -13276,11 +13318,11 @@ __webpack_require__.r(__webpack_exports__);
const navigate = (view, topic) => {
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(window.location.hash, view);
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view);
hash = topic ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(hash, 'topic', topic) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].removeUrlParam(hash, 'topic');
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateTo(hash);
};
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(window.location.hash, view));
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view));
function frequentContacts(chatList) {
if (!chatList || chatList.length === 0) return [];
return chatList.filter(item => item.topic && tinode_sdk__WEBPACK_IMPORTED_MODULE_1__.Tinode.isP2PTopicName(item.topic)).slice().sort((a, b) => new Date(b.touched || 0).getTime() - new Date(a.touched || 0).getTime()).slice(0, 8).map(item => ({
@@ -14960,7 +15002,7 @@ const messages = (0,react_intl__WEBPACK_IMPORTED_MODULE_1__.defineMessages)({
class NewTopicView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component) {
constructor(props) {
super(props);
const urlParams = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].parseUrlHash(window.location.hash).params;
const urlParams = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].currentUrl()).params;
const initialTab = urlParams['tab'] || 'find';
const initialQuery = initialTab == 'find' ? urlParams['q'] || null : null;
this.state = {
@@ -14978,7 +15020,7 @@ class NewTopicView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
}
handleTabClick(e) {
e.preventDefault();
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].currentUrl(), 'tab', e.currentTarget.dataset.id));
this.setState({
tabSelected: e.currentTarget.dataset.id
});
@@ -15008,12 +15050,12 @@ class NewTopicView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
}
handleSearchResultSelected(topicName) {
if (this.state.tabSelected == 'find') {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(window.location.hash, ['tab', 'q']));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].currentUrl(), ['tab', 'q']));
this.props.onCreateTopic(topicName);
}
}
handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(window.location.hash, ['tab', 'q']));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].currentUrl(), ['tab', 'q']));
this.props.onCreateTopic(undefined, {
public: (0,_lib_utils_js__WEBPACK_IMPORTED_MODULE_8__.theCard)(name, dataUrl, null, description),
private: priv,
@@ -15021,7 +15063,7 @@ class NewTopicView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
}, isChannel);
}
handleGroupByID(topicName) {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(window.location.hash, ['tab', 'q']));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].removeUrlParams(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_7__["default"].currentUrl(), ['tab', 'q']));
this.props.onCreateTopic(topicName);
}
render() {
@@ -15136,12 +15178,19 @@ __webpack_require__.r(__webpack_exports__);
const navigate = (view, params) => {
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(window.location.hash, view);
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(hash, key, value) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].removeUrlParam(hash, key);
});
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateTo(hash);
};
const navigateReplace = (view, params) => {
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(hash, key, value) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].removeUrlParam(hash, key);
});
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(hash);
};
const PERSONAL_SETTINGS_VIEWS = ['personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date', 'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings', 'change-password', 'app-version', 'privacy-settings', 'friend-discovery-settings', 'youth-mode-off', 'youth-mode-on', 'youth-mode-verify', 'notification-permission', 'permission-settings-reminder', 'camera-permission-info', 'microphone-permission-info'];
function Icon({
children
@@ -15978,6 +16027,7 @@ function ChangePassword({
actionDisabled: busy,
onAction: submit
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("main", {
className: "change-password-fields",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("label", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("span", {
children: "原密码"
@@ -16321,7 +16371,7 @@ function YouthMode({
}), 400);
return;
}
navigate('youth-mode-verify', {
navigateReplace('youth-mode-verify', {
ym: 'enable'
});
};
@@ -16354,7 +16404,7 @@ function YouthMode({
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_7__.jsxDEV)("button", {
type: "button",
className: settings.enabled ? 'outline' : '',
onClick: () => settings.enabled ? navigate('youth-mode-verify', {
onClick: () => settings.enabled ? navigateReplace('youth-mode-verify', {
ym: 'disable'
}) : startEnable(),
children: settings.enabled ? '关闭青少年模式' : '开启青少年模式'
@@ -16406,7 +16456,7 @@ function YouthModeVerify({
enabled: mode === 'enable'
});
onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭');
window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
window.setTimeout(() => navigateReplace(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
}).catch(err => {
if (err.reason === 'TOO_MANY_ATTEMPTS' && err.details && err.details.retry_after_seconds) {
setLockedUntil(Date.now() + err.details.retry_after_seconds * 1000);
@@ -16632,7 +16682,7 @@ __webpack_require__.r(__webpack_exports__);
const navigate = (view, params) => {
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].setUrlSidePanel(window.location.hash, view);
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].addUrlParam(hash, key, value) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_1__["default"].removeUrlParam(hash, key);
});
@@ -17149,14 +17199,10 @@ function UserFeedback({
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("section", {
className: "feedback-upload",
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("header", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("span", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("strong", {
children: "图片"
}, void 0, false)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("em", {
children: [images.length, "/", maxFiles]
}, void 0, true)]
}, void 0, true), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("button", {
children: (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("strong", {
children: ["图片(", images.length, "/", maxFiles, ")"]
}, void 0, true)
}, void 0, false), (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_4__.jsxDEV)("button", {
type: "button",
disabled: uploading,
onClick: () => fileInputRef.current && fileInputRef.current.click(),
@@ -18015,7 +18061,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
this.previewPrefetchAttempted = new Set();
this.state = this.getBlankState();
this.handleResize = this.handleResize.bind(this);
this.handleHashRoute = this.handleHashRoute.bind(this);
this.handleRouteChange = this.handleRouteChange.bind(this);
this.handleOnline = this.handleOnline.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.handleColorSchemeChange = this.handleColorSchemeChange.bind(this);
@@ -18208,7 +18254,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
this.handleOnline(false);
};
window.addEventListener('offline', this.handleOnlineOff);
window.addEventListener('hashchange', this.handleHashRoute);
window.addEventListener('popstate', this.handleRouteChange);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.handleColorSchemeChange);
document.addEventListener('visibilitychange', this.handleVisibilityEvent);
document.addEventListener('keydown', this.handleEscapeKey);
@@ -18256,7 +18302,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
this.handleError(err.message, 'err');
});
}
const parsedNav = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash);
const parsedNav = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl());
if (!SIDE_PANEL_VIEWS.includes(parsedNav.path[0]) || !parsedNav.path[0]) {
this.setState({
requestedTopic: parsedNav.path[1]
@@ -18264,13 +18310,13 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
const path = parsedNav.params && parsedNav.params.cred_done ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam('', 'cred_done', parsedNav.params.cred_done) : '';
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(path);
} else {
this.handleHashRoute();
this.handleRouteChange();
}
});
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('hashchange', this.handleHashRoute);
window.removeEventListener('popstate', this.handleRouteChange);
window.removeEventListener('online', this.handleOnlineOn);
window.removeEventListener('offline', this.handleOnlineOff);
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this.handleColorSchemeChange);
@@ -18459,8 +18505,8 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
};
};
}
handleHashRoute() {
const hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash);
handleRouteChange() {
const hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl());
if (hash.path && AUTH_FLOW_VIEWS.includes(hash.path[0]) && this.tinode.isAuthenticated()) {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo('');
return;
@@ -18710,12 +18756,12 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
if (!Array.isArray(params.cred) || !params.cred.length) {
return false;
}
const parsed = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash);
const parsed = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl());
parsed.path[0] = 'cred';
parsed.params['method'] = params.cred[0];
parsed.params['token'] = params.token;
parsed.params['code'] = params.code;
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].composeUrlHash(parsed.path, parsed.params));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].composeUrl(parsed.path, parsed.params));
return true;
}
handleLoginSuccessful() {
@@ -18750,9 +18796,9 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
loadSpinnerVisible: false
});
});
const currentView = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash).path[0];
const currentView = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl()).path[0];
const cameFromAuthFlow = !currentView || AUTH_FLOW_VIEWS.includes(currentView) || !SIDE_PANEL_VIEWS.includes(currentView);
let urlHash = cameFromAuthFlow ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, 'workbench') : window.location.hash;
let urlHash = cameFromAuthFlow ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'workbench') : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl();
if (goToTopic) {
urlHash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(urlHash, goToTopic);
}
@@ -18964,7 +19010,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
mobilePanel: 'sidepanel'
});
if (!_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].goBack()) {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, null));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), null));
}
}
handleSendMessage(msg, uploadCompletionPromise, uploader, head) {
@@ -19049,7 +19095,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
}
handleNewAccount() {
this.handleError();
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, 'register'));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'register'));
}
handleNewAccountRequest(login_, password_, public_, cred_, tags_) {
this.handleError();
@@ -19122,7 +19168,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
}
handleSelectWallpapers() {
this.handleError();
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, 'wallpapers'));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'wallpapers'));
}
applyWallpaperSettings(wallpaper, size, blur, effectiveSchema) {
document.documentElement.style.setProperty('--wallpaper-url', `url('${wallpaper}')`);
@@ -19171,7 +19217,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
}
handleSettings() {
this.handleError();
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, this.state.myUserId ? 'edit' : 'settings'));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), this.state.myUserId ? 'edit' : 'settings'));
}
handleGlobalSettings(settings) {
const serverAddress = settings.serverAddress || this.state.serverAddress;
@@ -19198,7 +19244,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
transport: transport,
secureConnection: secureConnection
});
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, ''));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), ''));
}
handleTenantResolved(data, requestedCode) {
const tenant = data.tenant || {};
@@ -19225,10 +19271,10 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
}
}
handleShowArchive() {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, this.state.myUserId ? 'archive' : ''));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), this.state.myUserId ? 'archive' : ''));
}
handleShowBlocked() {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, this.state.myUserId ? 'blocked' : ''));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), this.state.myUserId ? 'blocked' : ''));
}
toggleFCMToken(enabled) {
if (enabled) {
@@ -19299,7 +19345,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
});
}
handleSidepanelCancel() {
const parsed = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrlHash(window.location.hash);
const parsed = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl());
let path = '';
if (['security', 'support', 'general', 'notif', 'acc_general'].includes(parsed.path[0])) {
path = 'edit';
@@ -19320,17 +19366,17 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
delete parsed.params.scheme;
delete parsed.params.token;
}
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].composeUrlHash(parsed.path, parsed.params));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].composeUrl(parsed.path, parsed.params));
this.setState({
errorText: '',
errorLevel: null
});
}
basicNavigator(hash) {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, hash));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), hash));
}
infoNavigator(hash) {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlInfoPanel(window.location.hash, hash));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlInfoPanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), hash));
}
handleStartTopicRequest(topicName, newTopicParams, isChannel) {
if (topicName && this.tinode.isTopicCached(topicName)) {
@@ -19499,7 +19545,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
return;
}
topic.delTopic(true).then(_ => {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, ''));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), ''));
}).catch(err => {
this.handleError(err.message, 'err');
});
@@ -19517,7 +19563,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
return;
}
topic.leave(true).then(_ => {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, ''));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), ''));
}).catch(err => {
this.handleError(err.message, 'err');
});
@@ -19528,7 +19574,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
return;
}
topic.updateMode(null, '-JP').then(_ => {
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, ''));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), ''));
}).catch(err => this.handleError(err.message, 'err'));
}
handleReportTopic(topicName) {
@@ -19549,7 +19595,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
target_name: targetName
};
topic.updateMode(null, '-JP').then(_ => {
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, '');
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), '');
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(hash, 'abuse-report-reasons');
Object.entries(params).forEach(([key, value]) => {
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(hash, key, value);
@@ -19763,7 +19809,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
can_add_friend: !!data.user.can_add
}
});
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(window.location.hash, '');
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlTopic(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), '');
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile');
hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(hash, 'uid', data.user.user_id);
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(hash);
@@ -19830,7 +19876,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(hash);
return;
}
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(window.location.hash, 'info', 'info'));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'info', 'info'));
this.setState({
infoPanel: 'info'
});
@@ -19861,7 +19907,7 @@ class TinodeWeb extends (react__WEBPACK_IMPORTED_MODULE_0___default().Component)
meth: cred,
resp: code
}
}).then(_ => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(window.location.hash, 'contacts'))).catch(err => this.handleError(err.message, 'err'));
}).then(_ => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'contacts'))).catch(err => this.handleError(err.message, 'err'));
} else {
this.setState({
credMethod: cred,
@@ -20622,13 +20668,13 @@ __webpack_require__.r(__webpack_exports__);
const navigate = (view, params) => {
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(window.location.hash, view);
let hash = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(hash, key, value) : _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].removeUrlParam(hash, key);
});
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateTo(hash);
};
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(window.location.hash, view));
const navigateReplace = view => _lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), view));
const WORKPLACE_COLLABORATION_VIEWS = ['workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail', 'daily-report-editor', 'daily-report-mine', 'daily-report-team', 'daily-report-detail', 'monthly-report-editor', 'monthly-report-mine', 'monthly-report-team', 'monthly-report-detail', 'executive-mailbox', 'executive-mailbox-notice'];
function Icon({
children
@@ -70732,7 +70778,7 @@ const messageLoader = {
const rtl = ['ar', 'fa', 'he', 'ur'];
const {
params
} = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_4__["default"].parseUrlHash(window.location.hash);
} = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_4__["default"].parseUrl(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_4__["default"].currentUrl());
const language = params && params.hl || navigator.languages && navigator.languages[0] || navigator.language || navigator.userLanguage || 'en';
const normalized = language.replace('_', '-');
const baseLanguage = normalized.split('-')[0].toLowerCase();
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+56 -4
View File
@@ -1828,7 +1828,7 @@ class MessagesView extends (react__WEBPACK_IMPORTED_MODULE_0___default().Compone
if (!topic || !seq || isNaN(seq)) {
return;
}
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].removeUrlParam(window.location.hash, 'jumpseq'));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].navigateTo(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].removeUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_20__["default"].currentUrl(), 'jumpseq'));
const jump = () => window.setTimeout(() => this.handleQuoteClick(seq), 150);
if (topic.findMessage(seq)) {
jump();
@@ -2715,6 +2715,10 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
this.handleFormButtonClick = this.handleFormButtonClick.bind(this);
this.handleQuoteClick = this.handleQuoteClick.bind(this);
this.handleContextClick = this.handleContextClick.bind(this);
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
this.suppressNativeContextMenu = this.suppressNativeContextMenu.bind(this);
this.handleCancelUpload = this.handleCancelUpload.bind(this);
this.handleDraftyClick = this.handleDraftyClick.bind(this);
this.handleVoicePlayed = this.handleVoicePlayed.bind(this);
@@ -2753,7 +2757,7 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
break;
case 'contact_find':
e.preventDefault();
let hashUrl = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].setUrlSidePanel(window.location.hash, 'newtpk');
let hashUrl = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].setUrlSidePanel(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].currentUrl(), 'newtpk');
hashUrl = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].addUrlParam(hashUrl, 'q', e.target.dataset.val);
hashUrl = _lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].addUrlParam(hashUrl, 'tab', 'find');
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_9__["default"].navigateTo(hashUrl);
@@ -2803,9 +2807,53 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
const text = e.target.dataset.title || 'unknown';
this.props.onFormResponse(e.target.dataset.act, text, data);
}
handleTouchStart(e) {
if (!this.props.showContextMenu || this.props.selectMode) {
return;
}
const touch = e.touches && e.touches[0];
this.longPressOrigin = {
x: touch ? touch.pageX : e.pageX,
y: touch ? touch.pageY : e.pageY
};
this.longPressTimer = window.setTimeout(() => {
this.longPressTimer = null;
this.openContextMenu(this.longPressOrigin.x, this.longPressOrigin.y);
}, 500);
}
handleTouchMove(e) {
if (!this.longPressOrigin) {
return;
}
const touch = e.touches && e.touches[0];
const x = touch ? touch.pageX : e.pageX;
const y = touch ? touch.pageY : e.pageY;
if (Math.abs(x - this.longPressOrigin.x) > 10 || Math.abs(y - this.longPressOrigin.y) > 10) {
this.cancelLongPress();
}
}
handleTouchEnd() {
this.cancelLongPress();
}
cancelLongPress() {
if (this.longPressTimer) {
window.clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
this.longPressOrigin = null;
}
suppressNativeContextMenu(e) {
e.preventDefault();
}
componentWillUnmount() {
this.cancelLongPress();
}
handleContextClick(e) {
e.preventDefault();
e.stopPropagation();
this.openContextMenu(e.pageX, e.pageY);
}
openContextMenu(x, y) {
const menuItems = [];
if (this.props.received == tinode_sdk__WEBPACK_IMPORTED_MODULE_2__.Tinode.MESSAGE_STATUS_FAILED) {
menuItems.push('menu_item_send_retry');
@@ -2842,8 +2890,8 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
head: this.props.head,
userFrom: this.props.userFrom,
userName: this.props.userName,
y: e.pageY,
x: e.pageX,
y: y,
x: x,
pickReply: this.props.pickReply,
editMessage: this.props.editMessage,
timestamp: this.props.timestamp
@@ -3051,6 +3099,10 @@ class BaseChatMessage extends (react__WEBPACK_IMPORTED_MODULE_0___default().Pure
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_10__.jsxDEV)("div", {
children: [(0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_10__.jsxDEV)("div", {
className: bubbleClass,
onTouchStart: this.handleTouchStart,
onTouchMove: this.handleTouchMove,
onTouchEnd: this.handleTouchEnd,
onContextMenu: this.suppressNativeContextMenu,
children: [showVoiceUnreadDot ? (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_10__.jsxDEV)("i", {
className: "voice-unread-dot"
}, void 0, false) : null, (0,react_jsx_dev_runtime__WEBPACK_IMPORTED_MODULE_10__.jsxDEV)("div", {
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -46,7 +46,7 @@ class WallpapersView extends (react__WEBPACK_IMPORTED_MODULE_0___default().PureC
}
handleTabClick(e) {
e.preventDefault();
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].navigateReplace(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].addUrlParam(_lib_navigation_js__WEBPACK_IMPORTED_MODULE_2__["default"].currentUrl(), 'tab', e.currentTarget.dataset.id));
this.setState({
tab: e.currentTarget.dataset.id
});
File diff suppressed because one or more lines are too long
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:400,500,700" />
<title>MeetingIM</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3083
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -0,0 +1,36 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.5",
"agora-rtc-sdk-ng": "^4.24.8",
"jsqr": "^1.4.0",
"qrcode": "^1.5.4",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"react-intl": "^7.1.6",
"react-router-dom": "^7.9.6",
"tinode-sdk": "^0.25.3",
"zustand": "^5.0.9"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.16",
"@types/node": "^24.13.3",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"@vitejs/plugin-react": "^6.1.0",
"oxlint": "^1.79.0",
"tailwindcss": "^4.1.16",
"typescript": "^5.6.0",
"vite": "^8.2.2"
}
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#1677ff"/>
<text x="16" y="23" text-anchor="middle" font-family="system-ui, -apple-system, Segoe UI, Roboto, sans-serif" font-weight="700" font-size="19" fill="#fff">M</text>
</svg>

After

Width:  |  Height:  |  Size: 289 B

+102
View File
@@ -0,0 +1,102 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom';
import { AppShell } from '@/app/AppShell';
import { RequireAuth } from '@/app/RequireAuth';
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
import { LoginPage } from '@/features/auth/LoginPage';
import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage';
import { TenantEntryPage } from '@/features/auth/TenantEntryPage';
import { TenantFeedbackPage } from '@/features/auth/TenantFeedbackPage';
import { CallHistoryView } from '@/features/calls/CallHistoryView';
import { BlocklistView } from '@/features/contacts/BlocklistView';
import { ContactsLayout } from '@/features/contacts/ContactsLayout';
import { EmptyContactsMain } from '@/features/contacts/EmptyContactsMain';
import { FriendProfileView } from '@/features/contacts/FriendProfileView';
import { GroupListView } from '@/features/contacts/GroupListView';
import { NewFriendsView } from '@/features/contacts/NewFriendsView';
import { FavoritesLayout } from '@/features/favorites/FavoritesLayout';
import { ConversationView } from '@/features/messages/ConversationView';
import { EmptyMain } from '@/features/messages/EmptyMain';
import { MessagesLayout } from '@/features/messages/MessagesLayout';
import { GeneralSettingsPage } from '@/features/settings/GeneralSettingsPage';
import { NotificationsSettingsPage } from '@/features/settings/NotificationsSettingsPage';
import { PrivacySettingsPage } from '@/features/settings/PrivacySettingsPage';
import { ProfileSettingsPage } from '@/features/settings/ProfileSettingsPage';
import { SecuritySettingsPage } from '@/features/settings/SecuritySettingsPage';
import { SettingsHome } from '@/features/settings/SettingsHome';
import { SettingsLayout } from '@/features/settings/SettingsLayout';
import { SystemMessagesView } from '@/features/system/SystemMessagesView';
const queryClient = new QueryClient();
const router = createBrowserRouter([
{ path: 'tenant', element: <TenantEntryPage /> },
{ path: 'login', element: <LoginPage /> },
{ path: 'reset-password', element: <ResetPasswordPage /> },
{ path: 'enterprise-register', element: <EnterpriseRegisterPage /> },
{ path: 'feedback', element: <TenantFeedbackPage /> },
{
element: <RequireAuth />,
children: [
{
element: <AppShell />,
children: [
{ index: true, element: <Navigate to="/messages" replace /> },
{
path: 'messages',
element: <MessagesLayout />,
children: [
{ index: true, element: <EmptyMain /> },
{ path: 'system', element: <SystemMessagesView /> },
{ path: ':topicId', element: <ConversationView /> },
],
},
{
path: 'contacts',
element: <ContactsLayout />,
children: [
{ index: true, element: <EmptyContactsMain /> },
{ path: 'requests', element: <NewFriendsView /> },
{ path: 'groups', element: <GroupListView /> },
{ path: 'blocklist', element: <BlocklistView /> },
{ path: ':userId', element: <FriendProfileView /> },
],
},
{ path: 'favorites', element: <FavoritesLayout /> },
// README §9.2's dedicated file-assistant browser (categories, storage quota, upload
// progress) has NO backing REST API at all (confirmed via a research pass -- the design
// spec's own state-management notes admit reuse of existing APIs, i.e. reading the `slf`
// topic's messages, same as any other conversation) -- redirecting into the real
// conversation on that topic is honest; a fake quota card with invented numbers isn't.
{ path: 'files', element: <Navigate to="/messages/slf" replace /> },
{ path: 'calls', element: <CallHistoryView /> },
{
path: 'settings',
element: <SettingsLayout />,
children: [
{ index: true, element: <SettingsHome /> },
{ path: 'profile', element: <ProfileSettingsPage /> },
{ path: 'security', element: <SecuritySettingsPage /> },
{ path: 'notifications', element: <NotificationsSettingsPage /> },
{ path: 'privacy', element: <PrivacySettingsPage /> },
{ path: 'general', element: <GeneralSettingsPage /> },
],
},
],
},
],
},
// A stale bookmark/link to a route renamed during this redesign should land somewhere the app
// itself resolves (tenant/login/messages, per RequireAuth's own gating), not React Router's
// generic unstyled default error boundary.
{ path: '*', element: <Navigate to="/" replace /> },
]);
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { CallRoot } from '@/features/calls/CallRoot';
import { useBootstrap } from '@/lib/api/hooks';
import { useChatList } from '@/lib/tinode/hooks';
import { initCallSignaling } from '@/stores/calls';
import { BottomTabBar } from './BottomTabBar';
import { NavRail } from './NavRail';
/**
* Top-level chrome: persistent icon rail (>=640px) or bottom tab bar (<640px), wrapping whichever
* feature section is active. Each feature section owns its own list/main/detail responsive split
* internally (see e.g. features/messages) -- this shell only decides "rail vs tabs", nothing about
* columns within a section.
*/
export function AppShell() {
const contacts = useChatList();
const bootstrap = useBootstrap();
// Once per app session, regardless of which page is open -- an incoming call must surface even
// while browsing contacts/settings/etc, not only while a conversation happens to be open.
useEffect(() => {
initCallSignaling();
}, []);
// "sys" has its own authoritative unread source in entry_badges (bootstrap.ts) -- excluded here
// in case it ever also shows up as a regular Tinode subscription in `contacts`, which would
// otherwise double-count the same unread messages into this badge. "slf" gets no such exclusion:
// 文件助手 (which used to own its own entry_badges count, file_assistant_unread_count) has been
// removed as a UI entry point entirely -- see ChatListColumn.tsx's PINNED_ENTRIES doc comment --
// so "slf" is now just an ordinary contact row like any other, and should count normally here too.
const unreadMessages =
contacts.reduce((sum, c) => (c.topic === 'sys' ? sum : sum + (c.unread || 0)), 0) +
(bootstrap.data?.entry_badges?.system_unread_count || 0);
return (
<div className="flex h-dvh w-full overflow-hidden bg-surface-page">
<div className="hidden sm:flex sm:h-full">
<NavRail unreadMessages={unreadMessages} />
</div>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
<div className="min-h-0 flex-1 overflow-hidden">
<Outlet />
</div>
<div className="sm:hidden">
<BottomTabBar unreadMessages={unreadMessages} />
</div>
</div>
<CallRoot />
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl } from '@/lib/hooks';
interface AvatarProps {
/** Stable id (user_id/topic) used ONLY for the deterministic fallback color -- never the display name. */
id: string;
name: string | undefined;
avatarFileId?: string;
/** Sizing/rounding/font-size only (e.g. "h-20 w-20 rounded-[20px] text-2xl") -- the fallback tile's
* own color/centering/weight are applied internally, not expected from the caller. */
className?: string;
}
/**
* A real uploaded avatar (`avatar_file_id`) was, until now, stored and successfully round-tripped
* through every profile-update API in this app but never actually RENDERED anywhere -- every avatar
* call site across the whole codebase drew only the letter-tile fallback, so uploading a custom
* avatar produced zero visible change anywhere, including on the very page the upload happened.
* `avatar_file_id` is a bare file id (e.g. "abc123"), not a ref path -- reconstruct the
* `/v0/file/s/{id}` shape `useAuthedBlobUrl` (same authenticated-fetch-to-blob-URL mechanism already
* used for chat attachments) expects.
*/
export function Avatar({ id, name, avatarFileId, className = '' }: AvatarProps) {
const remote = useAuthedBlobUrl(avatarFileId ? `/v0/file/s/${avatarFileId}` : undefined);
if (remote.url) {
return <img src={remote.url} alt="" className={`object-cover ${className}`} />;
}
return (
<span className={`grid place-items-center font-medium text-white ${avatarBgClass(id)} ${className}`}>{avatarLetter(name)}</span>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { NavLink } from 'react-router-dom';
import { Icon } from './Icon';
interface TabItem {
to: string;
icon: string;
label: string;
badge?: number;
}
/**
* Replaces the old app's message-center-view.jsx-embedded 2-tab (消息/工作台) bar: this one is
* top-level (visible regardless of which section is open, not just inside the message list) and
* has 3 tabs per the new design, since 工作台 no longer exists.
*/
export function BottomTabBar({ unreadMessages = 0 }: { unreadMessages?: number }) {
const tabs: TabItem[] = [
{ to: '/messages', icon: 'chat_bubble', label: '消息', badge: unreadMessages },
{ to: '/contacts', icon: 'contacts', label: '通讯录' },
{ to: '/settings', icon: 'person', label: '我的' },
];
return (
<nav className="grid h-[82px] flex-none grid-cols-3 border-t border-border-panel bg-white pb-4.5">
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
className={({ isActive }) =>
`relative flex flex-col items-center justify-center gap-0.5 text-[11px] no-underline ${
isActive ? 'text-accent' : 'text-text-tertiary-3'
}`
}
>
<span className="relative">
<Icon name={tab.icon} className="!text-2xl" />
{tab.badge ? (
<b className="absolute -right-2 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-unread px-1 text-[10px] leading-none font-medium text-white">
{tab.badge}
</b>
) : null}
</span>
{tab.label}
</NavLink>
))}
</nav>
);
}
+11
View File
@@ -0,0 +1,11 @@
/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html).
* Always `aria-hidden` -- the glyph's DOM text is the icon's internal ligature name (e.g.
* "arrow_back_ios_new"), never something a screen reader should announce. An icon-only
* interactive element (button/link with no visible text) must set its own `aria-label`. */
export function Icon({ name, className = '', style }: { name: string; className?: string; style?: React.CSSProperties }) {
return (
<i aria-hidden="true" className={`material-icons select-none ${className}`} style={style}>
{name}
</i>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { Link, useLocation } from 'react-router-dom';
import { Icon } from './Icon';
interface NavRailItem {
to: string;
icon: string;
label: string;
badge?: number;
}
const TOP_ITEMS: NavRailItem[] = [
{ to: '/messages', icon: 'chat_bubble', label: '消息' },
{ to: '/contacts', icon: 'contacts', label: '通讯录' },
{ to: '/favorites', icon: 'inventory_2', label: '收藏' },
];
const BOTTOM_ITEMS: NavRailItem[] = [
{ to: '/calls', icon: 'phone', label: '通话记录' },
{ to: '/settings', icon: 'settings', label: '设置' },
];
function isNavItemActive(to: string, pathname: string): boolean {
if (to === '/messages') return pathname.startsWith('/messages');
return pathname === to || pathname.startsWith(`${to}/`);
}
function NavRailButton({ item }: { item: NavRailItem }) {
const location = useLocation();
const isActive = isNavItemActive(item.to, location.pathname);
return (
// Plain Link, not NavLink -- NavLink computes its OWN `aria-current` from ITS OWN prefix-match
// logic unconditionally (confirmed by reading react-router's source: passing an `aria-current`
// prop only changes the STRING used, not whether it's applied at all, which stays gated on
// NavLink's internal isActive), so a second, independent aria-current computation could in
// principle disagree with the `className` highlight computed by `isActive` below. Driving both
// off the one shared `isActive` value keeps them impossible to desync.
<Link
to={item.to}
title={item.label}
aria-label={item.label}
aria-current={isActive ? 'page' : undefined}
className={`relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${
isActive ? 'bg-accent-soft-bg text-accent' : 'text-text-secondary'
}`}
>
<Icon name={item.icon} className="!text-2xl" />
{item.badge ? (
<b className="absolute top-1 right-0.5 grid h-4 min-w-4 place-items-center rounded-full bg-unread px-1 text-[10px] leading-none font-medium text-white">
{item.badge}
</b>
) : null}
</Link>
);
}
/** Persistent 64px icon rail, visible at >=640px (see App.tsx for the <640 bottom-tab fallback). */
export function NavRail({ selfInitial = '我', unreadMessages = 0 }: { selfInitial?: string; unreadMessages?: number }) {
return (
<nav className="flex h-full w-16 flex-none flex-col items-center border-r border-[#e6e9ee] bg-surface-rail pb-3 pt-3.5">
<div className="mb-[22px] grid h-[38px] w-[38px] place-items-center rounded-[10px] bg-accent text-base font-semibold text-white">
{selfInitial}
</div>
<div className="flex flex-1 flex-col items-center gap-1.5">
{TOP_ITEMS.map((item) => (
<NavRailButton key={item.to} item={item.to === '/messages' ? { ...item, badge: unreadMessages } : item} />
))}
</div>
<div className="flex flex-col items-center gap-1.5">
{BOTTOM_ITEMS.map((item) => (
<NavRailButton key={item.to} item={item} />
))}
</div>
</nav>
);
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { Toast } from '@/app/Toast';
import { restoreSession } from '@/lib/tinode/connection';
import { isTenantMismatch } from '@/lib/tinode/errors';
import { subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
/** No connect-timeout exists anywhere in the SDK (its WS promise only settles on the underlying
* socket's own onopen/onerror) -- past this, assume the host is unreachable in a way that won't
* self-resolve (stalled handshake, silently-dropped middlebox) rather than hang the restore screen
* forever with no escape route. */
const RESTORE_TIMEOUT_MS = 10_000;
/**
* Gate for everything inside AppShell: needs a resolved tenant and an authenticated user.
*
* `tenant` survives a same-tab reload (persisted, see stores/session.ts) but `myUserId` doesn't --
* it used to always come back `null` on reload, bouncing straight to /login even though every
* login/register already writes a perfectly good reusable token to `sessionStorage['im-session-
* token']` for exactly this case. This re-derives `myUserId` from that token via connection.ts's
* restoreSession() (deduped against React 19 StrictMode's double-invoke in dev -- see its own
* comment) before giving up and redirecting, instead of ignoring the token entirely.
*/
export function RequireAuth() {
const tenant = useSessionStore((s) => s.tenant);
const myUserId = useSessionStore((s) => s.myUserId);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const clearTenant = useSessionStore((s) => s.clearTenant);
const [hasToken] = useState(() => !!sessionStorage.getItem('im-session-token'));
const [restoreFailed, setRestoreFailed] = useState(false);
const [restoreTimedOut, setRestoreTimedOut] = useState(false);
const [contactsError, setContactsError] = useState('');
const meSubscribedRef = useRef(false);
useEffect(() => {
if (myUserId || !tenant || !hasToken) return undefined;
const token = sessionStorage.getItem('im-session-token');
if (!token) return undefined;
let cancelled = false;
const timeout = window.setTimeout(() => {
if (!cancelled) setRestoreTimedOut(true);
}, RESTORE_TIMEOUT_MS);
restoreSession(token)
.then((uid) => {
if (cancelled) return;
setMyUserId(uid);
})
.catch((err) => {
if (!cancelled) {
sessionStorage.removeItem('im-session-token');
// docs/IM-前后端对接文档.md: a stale token from a DIFFERENT tenant than the one currently
// resolved fails with this specific reason, whose prescribed recovery is "清 Token 并回
// 企业码页" -- clearing the tenant here lets the fallback `<Navigate to="/login"/>` below
// do that for free, since LoginPage's own `if (!tenant)` guard bounces straight to
// `/tenant`, rather than adding a second, parallel navigation path.
if (isTenantMismatch(err)) clearTenant();
setRestoreFailed(true);
}
})
.finally(() => {
window.clearTimeout(timeout);
});
return () => {
cancelled = true;
window.clearTimeout(timeout);
};
}, [tenant, myUserId, hasToken, setMyUserId, clearTenant]);
// Single place that ensures the `me` topic gets subscribed once `myUserId` is known, regardless
// of HOW it became known (session restore above, or a fresh login that already called
// setMyUserId before this component ever mounted) -- LoginPage used to fire its own
// subscribeMeTopic() with a swallowed .catch(() => {}), silently leaving a freshly logged-in user
// with an empty conversation list and no error on a transient failure. Centralizing here means it
// happens exactly once per authenticated mount and surfaces the same Toast either way.
useEffect(() => {
if (!myUserId || meSubscribedRef.current) return;
meSubscribedRef.current = true;
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
}, [myUserId]);
if (!tenant) return <Navigate to="/tenant" replace />;
if (myUserId) {
return (
<>
<Outlet />
<Toast message={contactsError} variant="error" onDone={() => setContactsError('')} />
</>
);
}
// `hasToken` is a mount-time snapshot, not live state -- re-checking sessionStorage directly here
// (rather than trusting that stale flag alone) catches the token having been cleared elsewhere in
// the meantime (connection.ts's reconnect-triggered restoreSession failure, or client.ts's global
// 401 handler both clear it without this component's own effect ever running). Without this,
// `hasToken` would still read true and the branch below would show "正在恢复登录状态…" forever --
// there's no token left to restore and nothing would ever resolve that screen.
if (hasToken && !restoreFailed && sessionStorage.getItem('im-session-token')) {
// restoreTimedOut only switches the MESSAGE, it must never force a navigate here -- doing so
// would unmount this component, which sets the effect's `cancelled` flag and makes the still-
// in-flight restoreSession() promise discard its own result via `if (cancelled) return`. A
// merely-slow connection (10s+ round trip on cellular/VPN, not necessarily a dead host) would
// otherwise get bounced to a forced re-login even when the restore was about to succeed a moment
// later. Only an actual rejection (restoreFailed) or the user's own click below should leave.
return (
<div className="flex h-dvh flex-col items-center justify-center gap-3 bg-surface-page text-sm text-text-tertiary-3">
{restoreTimedOut ? '连接时间较长,你可以继续等待,或改为重新登录' : '正在恢复登录状态…'}
<a href="/login" className="text-accent no-underline">
改为重新登录
</a>
</div>
);
}
return <Navigate to="/login" replace />;
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useRef } from 'react';
import { Icon } from '@/app/Icon';
import { TOAST_DURATION } from '@/lib/config';
interface ToastProps {
message: string;
variant?: 'success' | 'error';
onDone: () => void;
}
export function Toast({ message, variant = 'success', onDone }: ToastProps) {
// Call sites pass an inline `onDone` arrow, so a plain `[message, onDone]` dependency array
// restarts the auto-dismiss timer on every unrelated parent re-render (e.g. typing in an
// unrelated field after the toast appears) instead of only when a NEW message is shown.
const onDoneRef = useRef(onDone);
useEffect(() => {
onDoneRef.current = onDone;
});
useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(() => onDoneRef.current(), TOAST_DURATION);
return () => window.clearTimeout(timer);
}, [message]);
if (!message) return null;
return (
<div
role="status"
// `position:fixed` resolves against the viewport, not AppShell's own Outlet box -- below 640px
// that viewport bottom is where the persistent BottomTabBar (82px tall) already sits, so the
// unconditional `bottom-12` this used to be sat squarely on top of it on every phone-width
// screen, for every Toast call site in the app. `sm:bottom-12` restores the original 48px gap
// once the tab bar is gone (>=640px, NavRail shown instead).
className="fixed bottom-[130px] left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-text-heading/94 px-4 py-2.5 text-sm text-white sm:bottom-12"
>
<Icon name={variant === 'error' ? 'error' : 'check_circle'} className={`text-[18px] ${variant === 'error' ? 'text-danger-text' : 'text-online'}`} />
{message}
</div>
);
}
@@ -0,0 +1,147 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { ImApiError } from '@/lib/api/client';
import { submitTenantApplication, uploadPublicFile } from '@/lib/api/tenant';
const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
/** README §10.5 / old app's EnterpriseRegister -- a company registering itself as a new tenant. */
export function EnterpriseRegisterPage() {
const tenantCodeHint = (useLocation().state as { tenantCodeHint?: string } | null)?.tenantCodeHint || undefined;
const [creditCode, setCreditCode] = useState('');
const [email, setEmail] = useState('');
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [reviewDays, setReviewDays] = useState(1);
const [toast, setToast] = useState('');
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!CREDIT_CODE_RE.test(creditCode)) return setError('请输入 18 位统一社会信用代码');
if (email && !EMAIL_RE.test(email)) return setError('请输入有效的企业邮箱');
setSubmitting(true);
setError('');
try {
let licenseFileId: string | undefined;
if (file) {
licenseFileId = (await uploadPublicFile(file, 'enterprise_license')).file_id;
}
const data = await submitTenantApplication({
credit_code: creditCode,
enterprise_email: email || undefined,
license_file_id: licenseFileId,
tenant_code_hint: tenantCodeHint,
});
setReviewDays(Math.max(1, Math.round((data.estimated_review_hours || 24) / 24)));
setSubmitted(true);
setToast('提交成功,我们会尽快联系你');
} catch (err) {
setError(err instanceof ImApiError ? err.message : '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
};
return (
<div className="flex h-dvh w-full flex-col items-center overflow-y-auto bg-surface-page px-6 py-10">
<div className="w-full max-w-[420px]">
<Link to="/tenant" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
<Icon name="arrow_back_ios_new" className="text-[16px]" />
返回
</Link>
<h1 className="mb-6 mt-4 text-[22px] font-bold text-text-title">企业注册</h1>
<ol className="mb-6 flex items-center text-xs text-text-tertiary-3">
{(['填写企业信息', '确定信息', '提交审核'] as const).map((label, i) => (
<li key={label} className="flex flex-1 items-center last:flex-none">
<span
className={`grid h-[22px] w-[22px] flex-none place-items-center rounded-full text-[11px] ${
i === 0 || submitted ? 'bg-accent text-white' : 'border border-[#d5d9df] text-text-tertiary-3'
}`}
>
{i + 1}
</span>
<span className={`ml-1.5 whitespace-nowrap ${i === 0 || submitted ? 'text-text-body' : ''}`}>{label}</span>
{i < 2 ? <span className={`mx-2 h-px flex-1 ${submitted ? 'bg-accent' : 'bg-[#d5d9df]'}`} /> : null}
</li>
))}
</ol>
<form onSubmit={submit} className="flex flex-col gap-4">
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<div className="flex flex-col gap-1.5">
<label className="text-sm text-text-body">统一社会信用代码</label>
<div className="flex h-[50px] items-center rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
<input
value={creditCode}
onChange={(e) => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
placeholder="请输入18位统一社会信用代码"
maxLength={18}
className="w-full bg-transparent text-[15px] outline-none"
/>
<Icon name="document_scanner" className="flex-none text-[20px] text-focus-border" />
</div>
<p className="m-0 text-xs text-text-tertiary-3">请填写营业执照上的统一社会信用代码</p>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm text-text-body">
营业执照 <span className="text-text-tertiary-3">选填</span>
</label>
<label className="flex h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border-[1.5px] border-dashed border-[#c5cad2]">
<input
type="file"
accept="image/jpeg,image/png,image/webp,application/pdf"
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="hidden"
/>
<Icon name="upload_file" className="text-[22px] text-accent" />
<span className="text-sm text-text-body">{file ? file.name : '点击上传营业执照'}</span>
<span className="text-xs text-text-tertiary-3">支持 JPG、PNG、WebP、PDF,不超过 10MB</span>
</label>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm text-text-body">
企业邮箱 <span className="text-text-tertiary-3">选填</span>
</label>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
type="email"
placeholder="请输入企业邮箱"
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none"
/>
<p className="m-0 text-xs text-text-tertiary-3">用于接收审核结果及企业通知</p>
</div>
<button
type="submit"
disabled={submitted || submitting || creditCode.length !== 18}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitted ? '已提交' : submitting ? '提交中…' : '提交审核'}
</button>
<p className="m-0 flex items-center justify-center gap-1 text-xs text-text-tertiary-3">
<Icon name="schedule" className="text-[16px]" />
资料提交后,预计 {reviewDays} 个工作日完成审核
</p>
</form>
</div>
<Toast message={toast} onDone={() => setToast('')} />
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { PasswordInput } from '@/features/auth/components/PasswordInput';
import { BASE_APP_NAME } from '@/lib/config';
import { getTinode, loginBasic } from '@/lib/tinode/connection';
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
import { useSessionStore } from '@/stores/session';
export function LoginPage() {
const navigate = useNavigate();
const tenant = useSessionStore((s) => s.tenant);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const [login, setLogin] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
// Real login failures (wrong password, disabled account, ...) clear the password and keep the
// account, matching the product spec -- ported from the old app's own effect for this.
useEffect(() => {
if (error) setPassword('');
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [error]);
if (!tenant) {
// Read tenant before touching getTinode() -- getTinode() lazily creates and PERMANENTLY caches
// the singleton off whatever tenant is in the store at that moment (or a hardcoded fallback
// host if there's none yet); calling it here first would risk creating that singleton against
// the wrong tenant before this redirect even happens.
return <Navigate to="/tenant" replace />;
}
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!login.trim() || !password || submitting) return;
setSubmitting(true);
setError('');
try {
const tinode = getTinode();
const ctrl = await loginBasic(login.trim(), password);
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
setError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined));
return;
}
const authToken = tinode.getAuthToken();
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
setMyUserId(tinode.getCurrentUserID() || null);
navigate('/messages');
} catch (err) {
setError(loginErrorMessage(err));
} finally {
setSubmitting(false);
}
};
return (
<div className="flex h-dvh w-full">
<div className="relative hidden shrink-0 flex-col justify-center overflow-hidden bg-gradient-to-br from-[#1677ff] to-[#0f56c4] px-16 text-white min-[900px]:flex min-[900px]:w-[56%]">
<div className="pointer-events-none absolute inset-x-0 top-0 h-96 bg-[radial-gradient(circle_at_20%_-10%,rgba(255,255,255,0.25),transparent_60%)]" />
<div className="absolute left-16 top-14 flex items-center gap-2.5">
<div className="grid h-10 w-10 place-items-center rounded-[10px] bg-white text-[22px] font-bold text-accent">M</div>
<strong className="text-xl font-semibold">{BASE_APP_NAME}</strong>
</div>
<h2 className="m-0 max-w-md text-[40px] leading-tight font-semibold text-balance">
企业内部沟通,安全、即时、多端同步。
</h2>
<p className="mt-4 max-w-md text-base text-white/80">
单聊、群聊、通讯录、文件与收藏、音视频通话,消息与回执在桌面端和手机端保持一致。
</p>
<div className="absolute bottom-14 left-16 flex gap-7 text-[13px] text-white/75">
<span>下载桌面客户端</span>
<span>iOS / Android</span>
<span>帮助中心</span>
</div>
</div>
<div className="flex flex-1 flex-col bg-white px-6 py-8">
<Link to="/tenant" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
<Icon name="arrow_back_ios_new" className="text-[16px]" />
切换企业
</Link>
<div className="flex flex-1 flex-col items-center justify-center">
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
<div className="mb-1 flex flex-col items-center gap-3 min-[900px]:hidden">
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-[36px] font-bold text-white shadow-[0_10px_26px_rgba(35,74,125,0.18)]">
M
</div>
<strong className="text-[26px] font-bold text-text-title">{tenant.name}</strong>
</div>
<div className="hidden flex-col gap-1 min-[900px]:flex">
<span className="text-[13px] text-text-tertiary-2">{tenant.name}</span>
</div>
<h1 className="m-0 text-[28px] font-bold text-text-title">登录</h1>
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<input
value={login}
onChange={(e) => setLogin(e.target.value)}
placeholder="请输入用户名/手机号"
autoComplete="username"
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring"
/>
<PasswordInput value={password} onChange={setPassword} placeholder="请输入密码" autoComplete="current-password" />
<Link to="/reset-password" className="-mt-2 self-end text-sm text-accent no-underline">
忘记密码
</Link>
<button
type="submit"
disabled={!login.trim() || !password || submitting}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '登录中…' : '登录'}
</button>
</form>
</div>
</div>
</div>
);
}
+174
View File
@@ -0,0 +1,174 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { PasswordInput } from '@/features/auth/components/PasswordInput';
import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton';
import { confirmPasswordReset, verifyPasswordResetCode } from '@/lib/api/auth';
import { ImApiError } from '@/lib/api/client';
import { useSessionStore } from '@/stores/session';
const PHONE_RE = /^\d{11}$/;
/** Pasting a number copied as "+86138..."/"86138..." (very common from a contacts app) strips to a
* 13-digit all-digit string; naively taking the first 11 keeps "86"+9 real digits and silently
* drops the real last 2. Strip a leading 86 first when the digits-only string is longer than 11. */
function normalizePhoneInput(raw: string): string {
const digits = raw.replace(/\D/g, '');
const withoutCountryCode = digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits;
return withoutCountryCode.slice(0, 11);
}
export function ResetPasswordPage() {
const navigate = useNavigate();
const tenant = useSessionStore((s) => s.tenant);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [challengeId, setChallengeId] = useState('');
const [error, setError] = useState('');
// README §10.4's own error example ("密码至少 8 位,需包含字母和数字") is specifically an INLINE
// field error under the password input, not just the shared top banner every other validation
// failure here uses -- this mirrors that without touching the other (phone/code) failure paths.
const [passwordFieldError, setPasswordFieldError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState('');
// A challengeId/code pair is only valid for the phone number it was issued for -- without this,
// editing the phone after requesting a code (typo fix) leaves a stale challengeId in place that
// verifyPasswordResetCode() has no way to cross-check against the NEW phone in the field.
useEffect(() => {
setChallengeId('');
setCode('');
}, [phone]);
if (!tenant) return <Navigate to="/tenant" replace />;
// Real per-tenant policy (docs/IM-前后端对接文档.md:534) -- was hardcoded to 8 here, not because
// anything ever read the real value; SecuritySettingsPage's in-place change had drifted to a
// DIFFERENT hardcoded 6.
const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordFieldError('');
setError('');
if (!PHONE_RE.test(phone)) return setError('请输入 11 位手机号');
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请先获取并输入 6 位验证码');
// Matches Register's rule (no upper bound enforced client-side -- the backend's checkPasswordPolicy
// only enforces a minimum length, so the message says exactly that and nothing more). Inline-only
// (no setError) -- these two are the password fields' OWN validation, already shown right under
// the relevant input; also raising the shared top banner with the identical text duplicated the
// same message on screen twice for no reason phone/code's genuinely-separate failures don't share.
if (password.length < minPasswordLength) {
return setPasswordFieldError(`密码至少 ${minPasswordLength} 位`);
}
if (password !== confirm) {
return setPasswordFieldError('两次输入的密码不一致');
}
setSubmitting(true);
setError('');
try {
const verified = await verifyPasswordResetCode(challengeId, code);
await confirmPasswordReset(verified.reset_token, password);
setToast('密码修改成功,请重新登录');
window.setTimeout(() => navigate('/login'), 1200);
} catch (err) {
setError(err instanceof ImApiError ? err.message : '验证码错误或已过期');
setSubmitting(false);
}
};
return (
<div className="flex h-dvh w-full flex-col overflow-y-auto bg-surface-page">
{/* README §10.7: mobile gets its own 48px icon+centered-title bar, not the desktop text link
scaled down. */}
<div className="flex h-12 flex-none items-center px-2 min-[900px]:hidden">
<Link to="/login" className="grid h-9 w-9 place-items-center text-text-heading no-underline">
<Icon name="arrow_back_ios_new" className="text-[20px]" />
</Link>
<strong className="flex-1 -ml-9 text-center text-[17px] font-semibold text-text-heading">重置密码</strong>
</div>
<Link to="/login" className="hidden items-center gap-1 px-6 pt-8 text-sm text-text-secondary no-underline min-[900px]:flex">
<Icon name="arrow_back_ios_new" className="text-[16px]" />
返回登录
</Link>
<div className="flex flex-1 items-center justify-center px-6 py-8">
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
<div className="flex flex-col gap-1">
<h1 className="m-0 text-[22px] font-bold text-text-title">重置密码</h1>
<p className="m-0 text-sm text-text-tertiary-1">
通过已绑定的手机号验证身份。未绑定手机号的账号请联系企业管理员重置。
</p>
</div>
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
<span className="text-[15px] text-text-tertiary-2">+86</span>
<input
value={phone}
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
placeholder="请输入手机号"
type="tel"
inputMode="numeric"
autoComplete="tel"
className="w-full bg-transparent text-[15px] outline-none"
/>
</label>
<div className="flex items-center gap-2">
<input
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="短信验证码"
inputMode="numeric"
className="h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none"
/>
<VerificationCodeButton phone={phone} scene="reset_password" onChallenge={setChallengeId} onError={setError} />
</div>
<PasswordInput
value={password}
onChange={(v) => {
setPassword(v);
setPasswordFieldError('');
}}
placeholder="新密码"
autoComplete="new-password"
error={passwordFieldError && password.length < minPasswordLength ? passwordFieldError : undefined}
maxLength={tenant.passwordPolicy?.maxLength}
/>
<PasswordInput
value={confirm}
onChange={(v) => {
setConfirm(v);
setPasswordFieldError('');
}}
placeholder="再次输入新密码"
autoComplete="new-password"
error={passwordFieldError && password.length >= minPasswordLength ? passwordFieldError : undefined}
maxLength={tenant.passwordPolicy?.maxLength}
/>
<button
type="submit"
disabled={!phone || !code || !password || !confirm || submitting}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '提交中…' : '确认重置'}
</button>
</form>
</div>
<Toast message={toast} onDone={() => setToast('')} />
</div>
);
}
+106
View File
@@ -0,0 +1,106 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { ImApiError } from '@/lib/api/client';
import { resolveTenant } from '@/lib/api/tenant';
import { resetTinode } from '@/lib/tinode/connection';
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
export function TenantEntryPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const setTenant = useSessionStore((s) => s.setTenant);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const myUserId = useSessionStore((s) => s.myUserId);
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!code.trim() || submitting) return;
// Reachable by navigating back here while already logged in (no route guard keeps an
// authenticated user out of /tenant) -- resetTinode() below would otherwise silently tear down
// that live session with no warning at all.
if (myUserId && !window.confirm('切换企业将退出当前登录,确定继续吗?')) return;
setSubmitting(true);
setError('');
try {
const res = await resolveTenant(code.trim());
const { host, secure } = parseWebsocketEndpoint(res.endpoints.websocket);
// Without this, switching tenants mid-session silently keeps talking to the OLD tenant's
// host: getTinode() lazily creates one singleton and reuses it forever once created, and
// createTinode() only ever reads useSessionStore's tenant at that one construction moment.
resetTinode();
// The confirm() dialog above literally promises "切换企业将退出当前登录" -- without these,
// that was a false promise: `myUserId` is a separate module-level store that switching
// tenant never touched, so RequireAuth's `if (myUserId) return <Outlet/>` would let a
// subsequent in-app navigation straight through with the OLD tenant's uid and the OLD
// tenant's sessionStorage token, against a freshly-reset Tinode connection that has never
// authenticated with the NEW tenant at all. Same teardown SettingsLayout's own logout and
// SecuritySettingsPage's account deletion already do.
sessionStorage.removeItem('im-session-token');
setMyUserId(null);
queryClient.clear();
setTenant({
code: res.tenant.code,
name: res.tenant.name,
endpoints: res.endpoints,
host,
secure,
passwordPolicy: res.password_policy
? { minLength: res.password_policy.min_length ?? 8, maxLength: res.password_policy.max_length ?? 64 }
: undefined,
});
navigate('/login');
} catch (err) {
setError(err instanceof ImApiError ? err.message : '企业码不存在或已停用,请核实后重试');
} finally {
setSubmitting(false);
}
};
return (
<div className="relative flex h-dvh w-full items-center justify-center bg-surface-page px-6">
<Link to="/enterprise-register" state={{ tenantCodeHint: code.trim() }} className="absolute right-6 top-6 text-sm text-accent no-underline">
企业注册
</Link>
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-5">
<div className="mb-2 flex flex-col items-center gap-3">
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-[36px] font-bold text-white shadow-[0_10px_26px_rgba(35,74,125,0.18)]">
M
</div>
<strong className="text-[26px] font-bold text-text-title">MeetingIM</strong>
</div>
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
<Icon name="business" className="text-[20px] text-focus-border" />
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="请输入企业码"
className="w-full bg-transparent text-[15px] outline-none"
/>
</label>
<button
type="submit"
disabled={!code.trim() || submitting}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '进入中…' : '进入企业'}
</button>
</form>
<Link to="/feedback" state={{ tenantCodeHint: code.trim() }} className="absolute bottom-8 text-sm text-accent no-underline">
遇到问题?
</Link>
</div>
);
}
@@ -0,0 +1,137 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { ImApiError } from '@/lib/api/client';
import { submitTenantEntryFeedback, uploadPublicFile } from '@/lib/api/tenant';
interface PendingImage {
file: File;
url: string;
}
/** README §10.6 / `01/1j`'s 问题反馈, reached from the tenant-code screen's "遇到问题?" link. */
export function TenantFeedbackPage() {
const tenantCodeHint = (useLocation().state as { tenantCodeHint?: string } | null)?.tenantCodeHint || undefined;
const [description, setDescription] = useState('');
const [contact, setContact] = useState('');
const [images, setImages] = useState<PendingImage[]>([]);
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState('');
const addImages = (files: FileList | null) => {
const selected = Array.from(files || []).slice(0, 9 - images.length);
const valid = selected.filter((f) => f.size <= 10 * 1024 * 1024 && ['image/jpeg', 'image/png', 'image/webp'].includes(f.type));
if (valid.length < selected.length) setError('部分图片未添加:仅支持 JPG/PNG/WebP,且不超过 10MB');
setImages((prev) => prev.concat(valid.map((file) => ({ file, url: URL.createObjectURL(file) }))));
};
const removeImage = (i: number) => {
setImages((prev) => {
URL.revokeObjectURL(prev[i].url);
return prev.filter((_, idx) => idx !== i);
});
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
const length = description.trim().length;
if (length < 10 || length > 500) return setError('问题描述需为 10 至 500 个字符');
setSubmitting(true);
setError('');
try {
const screenshotFileIds: string[] = [];
for (const item of images) {
screenshotFileIds.push((await uploadPublicFile(item.file, 'tenant_entry_feedback')).file_id);
}
await submitTenantEntryFeedback({
description: description.trim(),
tenant_code_hint: tenantCodeHint,
screenshot_file_ids: screenshotFileIds,
contact: contact.trim() || undefined,
});
setToast('提交成功,我们会尽快联系你');
images.forEach((item) => URL.revokeObjectURL(item.url));
setDescription('');
setImages([]);
setContact('');
} catch (err) {
setError(err instanceof ImApiError ? err.message : '提交失败,请稍后重试');
} finally {
setSubmitting(false);
}
};
return (
<div className="flex h-dvh w-full flex-col items-center overflow-y-auto bg-surface-page px-6 py-10">
<div className="w-full max-w-[420px]">
<Link to="/tenant" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
<Icon name="arrow_back_ios_new" className="text-[16px]" />
返回
</Link>
<h1 className="mb-6 mt-4 text-[22px] font-bold text-text-title">问题反馈</h1>
<form onSubmit={submit} className="flex flex-col gap-4">
{error ? (
<div className="flex items-center gap-1.5 rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">
<Icon name="error_outline" className="text-[18px]" />
{error}
</div>
) : null}
<div className="flex flex-col gap-1">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, 500))}
placeholder='企业码输入正确但提示"企业码不存在或已停用"'
rows={5}
className="resize-none rounded-lg bg-surface-input p-3.5 text-[15px] outline-none placeholder:text-text-placeholder-1"
/>
<span className="self-end text-xs text-text-tertiary-3">{description.length}/500</span>
</div>
<div className="grid grid-cols-4 gap-2.5">
{images.map((img, i) => (
<div key={img.url} className="relative aspect-square rounded-lg bg-surface-rail">
<img src={img.url} alt="" className="h-full w-full rounded-lg object-cover" />
<button
type="button"
onClick={() => removeImage(i)}
className="absolute -right-1.5 -top-1.5 grid h-[18px] w-[18px] place-items-center rounded-full bg-white text-text-tertiary-1"
>
<Icon name="cancel" className="text-[18px]" />
</button>
</div>
))}
{images.length < 9 ? (
<label className="flex aspect-square cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border-[1.5px] border-dashed border-[#c5cad2] text-text-tertiary-3">
<input type="file" accept="image/jpeg,image/png,image/webp" multiple onChange={(e) => addImages(e.target.files)} className="hidden" />
<Icon name="add_photo_alternate" className="text-[20px]" />
<span className="text-xs">{images.length}/9</span>
</label>
) : null}
</div>
<input
value={contact}
onChange={(e) => setContact(e.target.value)}
placeholder="联系方式(选填,手机号或邮箱)"
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none placeholder:text-text-placeholder-1"
/>
<button
type="submit"
disabled={description.trim().length < 10 || submitting}
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '提交中…' : '提交'}
</button>
</form>
</div>
<Toast message={toast} onDone={() => setToast('')} />
</div>
);
}
@@ -0,0 +1,45 @@
import { useState } from 'react';
import { Icon } from '@/app/Icon';
interface PasswordInputProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
autoComplete?: string;
error?: string;
maxLength?: number;
}
export function PasswordInput({ value, onChange, placeholder, autoComplete, error, maxLength }: PasswordInputProps) {
const [visible, setVisible] = useState(false);
return (
<div className="flex flex-col gap-1.5">
<div
className={`flex h-[50px] items-center rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:shadow-focus-ring ${
error ? 'border border-danger-text' : 'has-focus:border-focus-border'
}`}
>
<input
type={visible ? 'text' : 'password'}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoComplete={autoComplete}
maxLength={maxLength}
className="w-full bg-transparent text-[15px] outline-none"
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
aria-label={visible ? '隐藏密码' : '显示密码'}
className="text-text-placeholder-2"
>
<Icon name={visible ? 'visibility' : 'visibility_off'} className="text-[20px]" />
</button>
</div>
{error ? <div className="text-xs text-danger-border">{error}</div> : null}
</div>
);
}
@@ -0,0 +1,76 @@
import { useEffect, useRef, useState } from 'react';
import { requestVerificationCode } from '@/lib/api/auth';
import { ImApiError } from '@/lib/api/client';
const PHONE_RE = /^\d{11}$/;
interface VerificationCodeButtonProps {
phone: string;
scene: 'reset_password';
onChallenge: (challengeId: string) => void;
onError: (message: string) => void;
}
/** "获取验证码" button with resend countdown. Ported from the old app's VerificationButton. */
export function VerificationCodeButton({ phone, scene, onChallenge, onError }: VerificationCodeButtonProps) {
const [seconds, setSeconds] = useState(0);
const [busy, setBusy] = useState(false);
const timerRef = useRef<number | undefined>(undefined);
// Without this, navigating away mid-countdown (e.g. switching from register back to login)
// leaves the interval running against an unmounted component indefinitely until it naturally
// reaches 0 -- a real resource leak, not just a React warning, since nothing else ever clears it.
useEffect(() => () => window.clearInterval(timerRef.current), []);
// A countdown started for one phone number must not block sending a code to a DIFFERENT one --
// otherwise correcting a mistyped number mid-cooldown leaves the button stuck disabled (still
// counting down for the old number) until the old timer happens to run out on its own.
useEffect(() => {
window.clearInterval(timerRef.current);
setSeconds(0);
}, [phone]);
const tick = (initial: number) => {
window.clearInterval(timerRef.current);
setSeconds(initial);
timerRef.current = window.setInterval(() => {
setSeconds((s) => {
if (s <= 1) {
window.clearInterval(timerRef.current);
return 0;
}
return s - 1;
});
}, 1000);
};
const click = async () => {
if (busy || seconds > 0 || !PHONE_RE.test(phone)) return;
setBusy(true);
try {
const data = await requestVerificationCode(scene, phone);
onChallenge(data.challenge_id);
tick(data.resend_after_seconds || 60);
} catch (err) {
onError(err instanceof ImApiError ? err.message : '获取验证码失败');
// Rate-limited but a still-valid challenge already exists for this phone/scene -- reuse it.
const details = err instanceof ImApiError ? (err.details as Record<string, unknown> | undefined) : undefined;
if (typeof details?.retry_after_seconds === 'number') tick(details.retry_after_seconds);
if (typeof details?.challenge_id === 'string') onChallenge(details.challenge_id);
} finally {
setBusy(false);
}
};
return (
<button
type="button"
onClick={click}
disabled={busy || seconds > 0 || !PHONE_RE.test(phone)}
className="h-9 w-32 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
>
{seconds > 0 ? `重新获取 (${seconds}s)` : busy ? '发送中…' : '获取验证码'}
</button>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { getCallHistory, type CallHistoryItem } from '@/lib/api/calls';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useChatList } from '@/lib/tinode/hooks';
import { useCallStore } from '@/stores/calls';
function formatDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/** Direction-dependent, same reasoning as dispatch.ts's callResultDisplay: a `missed` result reads
* as "未接听" from the callee's own history and "已取消" from the caller's. */
function statusLabel(item: CallHistoryItem): string {
const result = item.result || item.status;
const incoming = item.direction === 'incoming';
if (result === 'finished') return formatDuration(item.duration_ms);
if (result === 'missed') return incoming ? '未接听' : '已取消';
if (result === 'declined') return '已拒绝';
if (result === 'disconnected') return '异常结束';
return result;
}
/** README §7 doesn't spec a dedicated call-history screen beyond the "最近通话" idea implicit in
* NavRail's own 通话记录 entry -- this is a plain list off the confirmed real `GET /v1/im/calls`
* endpoint, whose response shape (see calls.ts's own comment) turned out completely different
* from the doc's own minimal example once checked live: `peer_user_id` (a real `usr...` id, unlike
* `topic`, which comes back `p2p...`-hashed and unusable for navigation/re-dialing) is resolved to
* a display name via the chat list, same lookup ChatListColumn itself uses.
*/
export function CallHistoryView() {
const navigate = useNavigate();
const contacts = useChatList();
const query = useInfiniteQuery({
queryKey: ['call-history'],
queryFn: ({ pageParam }) => getCallHistory(pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
<header className="flex h-15 flex-none items-center border-b border-border-panel bg-white px-6">
<strong className="text-base font-semibold text-text-heading">通话记录</strong>
</header>
<div className="flex flex-col gap-2 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无通话记录</div>
) : (
items.map((item) => {
const result = item.result || item.status;
const incoming = item.direction === 'incoming';
// Mock only reds out a genuine "未接听" (a call that rang and was never answered) --
// "已取消"(I cancelled before pickup)/"对方忙线中"/"异常结束" are neutral outcomes, not
// something the viewer missed and should feel warned about. This used to redden EVERY
// non-'finished' result alike.
const isMissed = result === 'missed' && incoming;
const name = contacts.find((c) => c.topic === item.peer_user_id)?.public?.fn || item.peer_user_id;
const ts = item.started_at || item.created_at;
return (
<div key={`${item.call_id}`} className="flex items-center gap-3 rounded-xl bg-white px-4 py-3">
<button
type="button"
onClick={() => navigate(`/messages/${item.peer_user_id}`)}
className="flex min-w-0 flex-1 items-center gap-3 text-left"
>
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(item.peer_user_id)}`}>
{avatarLetter(name)}
</span>
<span className="flex min-w-0 flex-col gap-0.5">
<strong className="truncate text-[15px] font-medium text-text-body">{name}</strong>
<span className={`flex items-center gap-1 truncate text-xs ${isMissed ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
<Icon name={incoming ? 'call_received' : 'call_made'} className="text-sm" />
{statusLabel(item)}
{ts ? ` · ${formatEventTimestamp(new Date(ts))}` : ''}
</span>
</span>
</button>
<button
type="button"
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(item.peer_user_id, name, item.call_type);
}}
className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-surface-input text-accent"
>
<Icon name={item.call_type === 'video' ? 'videocam' : 'call'} className="text-lg" />
</button>
</div>
);
})
)}
{query.hasNextPage ? (
<button
type="button"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
className="py-2 text-center text-sm text-accent disabled:opacity-60"
>
{query.isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
) : null}
</div>
</div>
);
}
+422
View File
@@ -0,0 +1,422 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { playLocalVideo, playRemoteVideo } from '@/lib/calls/agora-client';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatCallDuration } from '@/lib/messages/dispatch';
import { useCallStore } from '@/stores/calls';
function useElapsedSeconds(connectedAt: number | null): number {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (!connectedAt) {
setElapsed(0);
return undefined;
}
const tick = () => setElapsed(Math.floor((Date.now() - connectedAt) / 1000));
tick();
const timer = window.setInterval(tick, 1000);
return () => window.clearInterval(timer);
}, [connectedAt]);
return elapsed;
}
function formatElapsed(totalSeconds: number): string {
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
function CircleButton({
icon,
active,
danger,
label,
onClick,
size = 'h-13 w-13',
iconSize = 'text-2xl',
}: {
icon: string;
active?: boolean;
danger?: boolean;
label: string;
onClick: () => void;
size?: string;
iconSize?: string;
}) {
return (
<button type="button" onClick={onClick} aria-label={label} className="flex flex-col items-center gap-1.5">
<span
className={`grid ${size} place-items-center rounded-full ${iconSize} text-white ${
danger ? 'bg-[#f34b46]' : active ? 'bg-white !text-[#22262d]' : 'bg-white/[0.16]'
}`}
>
<Icon name={icon} />
</span>
<span className="text-xs text-white">{label}</span>
</button>
);
}
/** README §7's incoming-call card (top-right floating, 360px). `w-[360px]` alone overflows a phone-
* width viewport (confirmed live: 360px + the 24px right offset already exceeds a 375px screen with
* no room for a left margin at all) -- `max-w-[calc(100vw-32px)]` caps it to the viewport with a
* 16px gutter on each side below the point where the fixed width alone would fit. */
function IncomingCallCard() {
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
return (
<div className="fixed right-4 top-4 z-[60] w-[360px] max-w-[calc(100vw-32px)] rounded-[14px] bg-white p-5 shadow-[0_16px_48px_rgba(0,0,0,0.2)] sm:right-6 sm:top-6">
<div className="flex items-center gap-3">
<span className={`grid h-13 w-13 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
{avatarLetter(peerName)}
</span>
<div className="flex min-w-0 flex-col">
<strong className="truncate text-[15px] font-semibold text-text-heading">{peerName}</strong>
<span className="text-xs text-text-tertiary-2">邀请你{callType === 'video' ? '视频' : '语音'}通话</span>
</div>
</div>
<div className="mt-4 flex justify-end gap-3">
<button type="button" onClick={rejectCall} aria-label="拒绝" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#f34b46] text-xl text-white">
<Icon name="call_end" />
</button>
<button type="button" onClick={acceptCall} aria-label="接听" className="grid h-[42px] w-[42px] place-items-center rounded-full bg-[#1fb36a] text-xl text-white">
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
</button>
</div>
</div>
);
}
/** Mobile-only full-screen ringing view (06-calls.dc.html `#1g`, "移动·语音来电" -- the same layout
* serves a video invite too, just swapping the subtitle/accept icon). Desktop keeps the small
* top-right floating `IncomingCallCard` above, unchanged; `CallRoot` renders exactly one of the two
* per breakpoint. Built as its own component rather than a responsive fork of `IncomingCallCard`
* because the two share no DOM structure at all (a corner card vs. a full-bleed ringing screen). */
function MobileIncomingCall() {
const { topic, peerName, callType, acceptCall, rejectCall } = useCallStore();
return (
<div className="fixed inset-0 z-[60] flex flex-col items-center bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
<span
className={`mt-32 grid h-[120px] w-[120px] place-items-center rounded-[28px] text-[46px] font-medium text-white ${avatarBgClass(topic || peerName)}`}
style={{ boxShadow: '0 0 0 14px rgba(43,182,115,0.18), 0 0 0 32px rgba(43,182,115,0.08)' }}
>
{avatarLetter(peerName)}
</span>
<strong className="mt-8 text-2xl font-semibold">{peerName}</strong>
<span className="mt-2 text-[15px] text-white/70">邀请你{callType === 'video' ? '视频' : '语音'}通话…</span>
<div className="mb-16 mt-auto flex gap-24">
<button type="button" onClick={rejectCall} aria-label="拒绝" className="flex flex-col items-center gap-2.5 text-[13px]">
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#f34b46] text-[32px]">
<Icon name="call_end" />
</span>
拒绝
</button>
<button type="button" onClick={acceptCall} aria-label="接听" className="flex flex-col items-center gap-2.5 text-[13px]">
<span className="grid h-18 w-18 place-items-center rounded-full bg-[#1fb36a] text-[32px]">
<Icon name={callType === 'video' ? 'videocam' : 'call'} />
</span>
接听
</button>
</div>
</div>
);
}
/** README §7's minimized floating bar (280px). Call stays live (Agora channel untouched) --
* minimizing is pure local UI state, matching the old app's own real behavior (CallPanel is never
* unmounted between full-screen and minimized). */
function MinimizedCallBar() {
const { topic, peerName, connectedAt, muted, toggleMinimize, toggleMute, hangUp } = useCallStore();
const elapsed = useElapsedSeconds(connectedAt);
return (
// `bottom-[106px] sm:bottom-6`: same viewport-vs-Outlet-box fixed-positioning issue as Toast.tsx
// and FriendProfileView's mobile action bar -- below 640px this would otherwise sit on top of
// AppShell's persistent 82px BottomTabBar (a minimized call can be reached at any viewport width,
// there's no `sm:hidden` gate on this component to make the overlap moot).
<div className="fixed bottom-[106px] right-6 z-[60] flex w-[280px] items-center gap-3 rounded-xl bg-[#1b2231] px-4 py-3 text-white sm:bottom-6">
<button type="button" onClick={toggleMinimize} aria-label="展开通话窗口" className="flex flex-1 items-center gap-3 text-left">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topic || peerName)}`}>
{avatarLetter(peerName)}
</span>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm font-medium">{peerName}</strong>
<span className="text-xs text-success">{connectedAt ? `通话中 · ${formatElapsed(elapsed)}` : '正在连接…'}</span>
</span>
</button>
<button type="button" onClick={toggleMute} aria-label={muted ? '取消静音' : '静音'} className="grid h-8 w-8 flex-none place-items-center rounded-full bg-white/10">
<Icon name={muted ? 'mic_off' : 'mic'} className="text-base" />
</button>
<button type="button" onClick={hangUp} aria-label="挂断" className="grid h-8 w-8 flex-none place-items-center rounded-full bg-[#f34b46]">
<Icon name="call_end" className="text-base" />
</button>
</div>
);
}
/** README §7's in-call window. Deliberately an in-app overlay, not a true separate OS window (the
* design calls for one) -- the old app's own real CallPanel is also just an overlay, never a
* separate window/component instance, and keeping the Agora client/tracks in one JS realm avoids a
* cross-window signaling bus this rewrite has no other need for. */
function CallWindow() {
const {
topic,
callType,
phase,
peerName,
connectedAt,
muted,
cameraOff,
hasRemoteVideo,
hasLocalVideo,
minimized,
mediaError,
toggleMinimize,
toggleMute,
toggleCamera,
hangUp,
} = useCallStore();
const remoteRef = useRef<HTMLDivElement>(null);
const localRef = useRef<HTMLDivElement>(null);
const elapsed = useElapsedSeconds(connectedAt);
useEffect(() => {
if (remoteRef.current) playRemoteVideo(remoteRef.current);
}, [hasRemoteVideo]);
useEffect(() => {
if (localRef.current && hasLocalVideo && !cameraOff) playLocalVideo(localRef.current);
}, [hasLocalVideo, cameraOff]);
if (minimized) return <MinimizedCallBar />;
const statusLabel = phase === 'connected' ? `${formatElapsed(elapsed)} · 网络良好` : '正在等待对方接听…';
// 06-calls.dc.html #1b (呼出等待中) labels this same button "取消", matching the protocol doc's own
// "发起方取消发送 hang-up:cancelled" wording for the pre-connect case -- it was hardcoded to "挂断"
// at all 3 call sites below regardless of phase, which is only actually correct once connected.
const hangUpLabel = phase === 'connected' ? '挂断' : '取消';
if (callType === 'audio') {
return (
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-between bg-gradient-to-b from-[#1f2a3d] to-[#111826] py-16 text-white">
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="absolute left-5 top-5 text-white/70">
<Icon name="expand_more" className="text-2xl" />
</button>
<div className="flex flex-col items-center gap-4">
<span
// Mock `06-calls.dc.html #1b`: `border-radius:24px` (a squircle), not a perfect circle --
// the mobile equivalent of this same avatar (MobileIncomingCall) already gets this right.
className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
style={{ boxShadow: '0 0 0 12px rgba(43,182,115,0.18), 0 0 0 28px rgba(43,182,115,0.08)' }}
>
{avatarLetter(peerName)}
</span>
<strong className="text-center text-xl font-semibold">{peerName}</strong>
<span className="text-sm text-white/60">{statusLabel}</span>
</div>
<div className="flex gap-8">
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
</div>
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
</div>
);
}
return (
<div
className="fixed inset-0 z-[60] text-white"
style={{ background: 'radial-gradient(ellipse at 50% 40%, #3b4a63, #1b2231)' }}
>
{/* Desktop header (06-calls.dc.html #1a): name + live duration/network status inline. `z-10`
(here and on every other overlay piece below) is deliberate, not decorative -- without it,
this and the real `remoteRef` video div right below are BOTH `position:absolute` with no
z-index, so plain DOM order would decide stacking and the video (which comes second) would
paint over this header the moment real remote video is actually playing. That case never
got exercised live (this sandbox has no real camera, see the calls verification note in the
plan file), so it's a latent bug worth closing here rather than leaving for whoever first
tests a real 2-camera call to rediscover. */}
<div className="absolute inset-x-0 top-0 z-10 hidden items-center justify-between bg-gradient-to-b from-black/40 to-transparent px-6 py-4 md:flex">
<div className="flex flex-col">
<strong className="text-base font-semibold">{peerName}</strong>
<span className="flex items-center gap-1 text-xs text-white/70">
{phase === 'connected' ? (
<>
{formatElapsed(elapsed)} · <Icon name="network_wifi" className="text-sm text-[#1fb36a]" /> 网络良好
</>
) : (
'正在等待对方接听…'
)}
</span>
</div>
<div className="flex items-center gap-3 text-white/80">
<button type="button" onClick={toggleMinimize} aria-label="最小化">
<Icon name="picture_in_picture_alt" className="text-xl" />
</button>
</div>
</div>
{/* Mobile header (06-calls.dc.html #1f): icon-only row, no room for the name/duration text --
those move into the centered overlay below (no remote video yet) or the small pill just
under this row (once real video is playing). 翻转摄像头 lives here instead of the bottom
control grid, matching the mock exactly. */}
<div className="absolute inset-x-0 top-0 z-10 flex items-center justify-between px-4 py-1.5 md:hidden">
<button type="button" onClick={toggleMinimize} aria-label="最小化" className="grid h-10 w-10 place-items-center">
<Icon name="picture_in_picture_alt" className="text-xl" />
</button>
<button
type="button"
onClick={() => void useCallStore.getState().switchCamera()}
aria-label="翻转摄像头"
className="grid h-10 w-10 place-items-center"
>
<Icon name="flip_camera_ios" className="text-xl" />
</button>
</div>
{hasRemoteVideo ? (
<div className="absolute left-1/2 top-16 z-10 -translate-x-1/2 whitespace-nowrap rounded-full bg-black/40 px-3 py-1 text-xs md:hidden">
{peerName}
{phase === 'connected' ? ` · ${formatElapsed(elapsed)}` : ''}
</div>
) : null}
<div className="absolute inset-0">
{hasRemoteVideo ? (
<div ref={remoteRef} className="h-full w-full [&>video]:h-full [&>video]:w-full [&>video]:object-cover" />
) : (
<>
{/* Desktop fallback (unchanged): dead-centered avatar, name/status stay in the header above. */}
<div className="hidden h-full w-full place-items-center md:grid">
<div className="flex flex-col items-center gap-3">
{/* Mock `06-calls.dc.html #1a`: `border-radius:28px`, not a perfect circle. */}
<span
className={`grid h-[120px] w-[120px] place-items-center rounded-[28px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
>
{avatarLetter(peerName)}
</span>
<span className="text-sm text-white/70">{phase === 'connected' ? '对方已关闭摄像头' : ''}</span>
</div>
</div>
{/* Mobile fallback (06-calls.dc.html #1f): avatar + name + duration together, since mobile
has no header info bar to carry the name instead. */}
<div className="flex h-full w-full flex-col items-center pt-[150px] md:hidden">
<span
className={`grid h-[110px] w-[110px] place-items-center rounded-[26px] text-4xl font-medium text-white ${avatarBgClass(topic || peerName)}`}
>
{avatarLetter(peerName)}
</span>
<strong className="mt-2 text-xl font-semibold">{peerName}</strong>
<span className="mt-1 text-sm text-white/70">
{phase === 'connected' ? formatElapsed(elapsed) : '正在等待对方接听…'}
</span>
{/* Desktop's version of this same fallback state shows this explanation (line 285
above); mobile silently dropped it, showing only the elapsed-time line with no
indication of WHY there's no video. */}
{phase === 'connected' ? <span className="text-sm text-white/70">对方已关闭摄像头</span> : null}
</div>
</>
)}
</div>
{/* Local self-preview: one shared element (not a desktop/mobile fork) so the live Agora track
stays attached to a single `ref` -- only its position/size differs by breakpoint, via plain
responsive classes, restoring the desktop values under `md:`. */}
{!cameraOff ? (
<div
ref={localRef}
className="absolute right-4 top-[110px] z-10 h-40 w-27 overflow-hidden rounded-[14px] border-2 border-white/30 bg-black/40 [&>video]:h-full [&>video]:w-full [&>video]:object-cover md:right-5 md:top-[72px] md:h-[132px] md:w-[200px] md:rounded-xl"
/>
) : null}
{/* Desktop bottom bar (unchanged): 5 controls incl. 共享屏幕. */}
<div className="absolute inset-x-0 bottom-8 z-10 hidden justify-center gap-6 md:flex">
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} />
<CircleButton icon={cameraOff ? 'videocam_off' : 'videocam'} active={cameraOff} label="摄像头" onClick={toggleCamera} />
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} />
<CircleButton icon="screen_share" label="共享屏幕" onClick={() => {}} />
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} />
</div>
{/* Mobile bottom bar (06-calls.dc.html #1f): 3-over-1 grid, no 共享屏幕 (not in this mock) and
bigger touch targets (60px / 68px for hang-up) matching the mock's exact button sizes. */}
<div className="absolute inset-x-0 bottom-12 z-10 grid grid-cols-3 gap-y-6 px-8 md:hidden">
<CircleButton icon={muted ? 'mic_off' : 'mic'} active={muted} label="静音" onClick={toggleMute} size="h-15 w-15" iconSize="text-[26px]" />
<CircleButton
icon={cameraOff ? 'videocam_off' : 'videocam'}
active={cameraOff}
label="摄像头"
onClick={toggleCamera}
size="h-15 w-15"
iconSize="text-[26px]"
/>
<CircleButton icon="volume_up" label="扬声器" onClick={() => {}} size="h-15 w-15" iconSize="text-[26px]" />
<span />
<CircleButton icon="call_end" danger label={hangUpLabel} onClick={hangUp} size="h-17 w-17" iconSize="text-[30px]" />
<span />
</div>
<Toast message={mediaError || ''} variant="error" onDone={() => useCallStore.setState({ mediaError: null })} />
</div>
);
}
/** Brief (3s, see stores/calls.ts's `_endWithReason`) full-screen result card shown right after any
* call ends -- same icon/text vocabulary as the permanent chat-bubble record (dispatch.ts's
* callResultDisplay) so the live moment and the message history agree on how a call is described. */
function EndedCallOverlay() {
const { topic, peerName, callType, endReason, connectedAt, wasCaller } = useCallStore();
// Computed once (not on every re-render) -- this overlay is only ever on screen for a fixed 3s
// window, so a live-ticking duration isn't needed and re-deriving it from Date.now() on each
// render would make the displayed value drift depending on when React happens to re-render.
const [durationMs] = useState(() => (connectedAt ? Date.now() - connectedAt : 0));
const icon = callType === 'video' ? 'videocam' : 'call';
const { label, danger } = (() => {
switch (endReason) {
case 'finished':
return { label: durationMs > 0 ? `通话时长 ${formatCallDuration(durationMs)}` : '通话已结束', danger: false };
case 'missed':
return { label: wasCaller ? '对方未接听,已取消' : '未接听', danger: true };
case 'declined':
return { label: '对方已拒绝', danger: true };
case 'disconnected':
return { label: '通话连接异常,已结束', danger: true };
case 'busy':
return { label: '对方忙线中', danger: true };
default:
return { label: '通话已结束', danger: false };
}
})();
return (
<div className="fixed inset-0 z-[60] flex flex-col items-center justify-center gap-4 bg-gradient-to-b from-[#1f2a3d] to-[#111826] text-white">
{/* Same 24px squircle as the audio in-call avatar (same 100x100 size, same visual family --
no mock draws this exact result-overlay state, so this follows the nearest one that does). */}
<span className={`grid h-[100px] w-[100px] place-items-center rounded-[24px] text-3xl font-medium text-white ${avatarBgClass(topic || peerName)}`}>
{avatarLetter(peerName)}
</span>
<strong className="text-xl font-semibold">{peerName}</strong>
<span className={`flex items-center gap-1.5 text-sm ${danger ? 'text-[#f34b46]' : 'text-white/70'}`}>
<Icon name={danger ? 'phone_missed' : icon} className="text-base" />
{label}
</span>
</div>
);
}
/** Mounted once in AppShell. */
export function CallRoot() {
const { phase, minimized } = useCallStore();
if (phase === 'incoming')
return (
<>
<div className="hidden md:contents">
<IncomingCallCard />
</div>
<div className="contents md:hidden">
<MobileIncomingCall />
</div>
</>
);
if (phase === 'outgoing' || phase === 'connected') return minimized ? <MinimizedCallBar /> : <CallWindow />;
if (phase === 'ended') return <EndedCallOverlay />;
return null;
}
@@ -0,0 +1,302 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
import { QRScannerModal } from '@/features/contacts/QRScannerModal';
import { searchUsers, sendFriendRequest, type UserSearchResult } from '@/lib/api/friends';
import { submitGroupJoinRequest } from '@/lib/api/groups';
import type { QRResolveGroup, QRResolveUser } from '@/lib/api/qrcodes';
import { useEscapeKey } from '@/lib/hooks';
const MAX_MESSAGE_LENGTH = 50;
function notImplemented() {
window.alert('该功能暂未开放,如需联系客服。');
}
interface AddFriendModalProps {
onClose: () => void;
onSent?: () => void;
/** FriendProfileView's "添加好友" button already knows exactly who -- skips straight past the
* search step to the verification-message step, rather than making the user re-search someone
* whose profile is already open. */
initialTarget?: { user_id: string; display_name: string; public_account?: string; avatar_file_id?: string };
}
/**
* README §5.4 (520px). 扫一扫/我的二维码 are real, confirmed-live features now (see
* lib/api/qrcodes.ts's header comment for the live-verification writeup) -- only "分享好友" stays
* the same "该功能暂未开放" placeholder GroupDetailColumn's own 投诉举报 button already uses, since
* sharing a friend's card into a conversation has no backend support anywhere. Search result rows
* drive their state from
* `relationship`/`can_add` (doc line 1423's confirmed response has no separate `action` field to
* read a label off of): already a friend or already pending are shown but not selectable, `can_add:
* false` shows a generic "无法添加" per the doc's own "不泄露具体关闭项" privacy principle, and only
* a genuinely addable result can be picked before writing the verification message.
*/
export function AddFriendModal({ onClose, onSent, initialTarget }: AddFriendModalProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [query, setQuery] = useState('');
const [searchedFor, setSearchedFor] = useState('');
const [selected, setSelected] = useState<UserSearchResult | null>(
initialTarget ? { ...initialTarget, relationship: 'none', can_add: true } : null,
);
// Tracks HOW `selected` was populated so the eventual sendFriendRequest can report the real
// provenance instead of always claiming "search" -- NewFriendsView's own SOURCE_LABEL map has a
// dedicated "扫码添加" entry for the qr case that was otherwise dead/unreachable.
const [selectedSource, setSelectedSource] = useState<'search' | 'qr'>('search');
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const [sent, setSent] = useState(false);
const [showMyQrCode, setShowMyQrCode] = useState(false);
const [scanning, setScanning] = useState(false);
// A scanned `purpose:"join_group"` code -- a completely different target shape from `selected`
// (a person), so it gets its own state rather than being forced into UserSearchResult's fields.
const [groupJoinTarget, setGroupJoinTarget] = useState<(QRResolveGroup & { qrToken: string }) | null>(null);
useEscapeKey(onClose);
const handleScanResolved = (user: QRResolveUser) => {
setScanning(false);
setSelectedSource('qr');
setSelected({ user_id: user.user_id, display_name: user.display_name, public_account: user.public_account, relationship: 'none', can_add: true });
};
const handleScanAlreadyFriend = (user: QRResolveUser) => {
setScanning(false);
onClose();
navigate(`/contacts/${user.user_id}`);
};
const handleScanGroupResolved = (group: QRResolveGroup, qrToken: string) => {
setScanning(false);
setGroupJoinTarget({ ...group, qrToken });
};
const handleScanAlreadyMember = (group: QRResolveGroup) => {
setScanning(false);
onClose();
navigate(`/messages/${group.topic}`);
};
// docs/IM-前后端对接文档.md:1308: phone-number search only actually happens under `type=phone` --
// the default `type=account` never matches a bare phone number, so a search box that always sends
// `account` can never satisfy README §5.4's own placeholder implying phone-number search works.
const searchType = /^\d{11}$/.test(searchedFor) ? 'phone' : 'account';
const searchQuery = useQuery({
queryKey: ['user-search', searchedFor, searchType],
queryFn: () => searchUsers(searchedFor, searchType),
enabled: !!searchedFor,
});
const runSearch = () => {
setSelected(null);
setSearchedFor(query.trim());
};
const sendMutation = useMutation({
mutationFn: () => sendFriendRequest(selected?.user_id || '', message.trim() || undefined, selectedSource),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
setSent(true);
onSent?.();
},
onError: (err) => setError(err instanceof Error ? err.message : '发送申请失败'),
});
const groupJoinMutation = useMutation({
mutationFn: () =>
submitGroupJoinRequest(groupJoinTarget?.topic || '', { message: message.trim() || undefined, source: 'qr', qr_token: groupJoinTarget?.qrToken }),
onSuccess: () => setSent(true),
onError: (err) => setError(err instanceof Error ? err.message : '发送入群申请失败'),
});
const rowState = (item: UserSearchResult): { label: string; selectable: boolean } => {
if (item.relationship === 'friend') return { label: '已是好友', selectable: false };
// docs/IM-前后端对接文档.md:1289 -- the real enum is direction-specific, never a bare "pending"
// (comparing against that literal never matched either real value, so a search result with an
// outstanding request either direction fell through to the `can_add` check below and could look
// fully selectable, risking a duplicate friend-requests submit).
if (item.relationship === 'pending_outbound') return { label: '已发送申请', selectable: false };
if (item.relationship === 'pending_inbound') return { label: '对方已申请加你', selectable: false };
if (!item.can_add) return { label: '无法添加', selectable: false };
return { label: '', selectable: true };
};
return (
<>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex max-h-[80vh] w-[520px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">添加好友</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
{sent ? (
<div className="flex flex-col items-center gap-3 px-5 py-16">
<Icon name="check_circle" className="text-[40px] text-success" />
<span className="text-sm text-text-body">申请已发送</span>
<button type="button" onClick={onClose} className="h-9 rounded-lg bg-btn px-5 text-sm font-medium text-white">
完成
</button>
</div>
) : (
<div className="flex-1 overflow-y-auto px-5 py-4">
{groupJoinTarget ? (
<div className="flex items-center gap-3 rounded-lg bg-surface-input px-3 py-2.5">
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg bg-accent text-sm font-medium text-white">
{groupJoinTarget.name.slice(0, 1)}
</span>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm font-medium text-text-body">{groupJoinTarget.name}</strong>
<span className="truncate text-xs text-text-tertiary-2">{groupJoinTarget.member_count} 人</span>
</span>
</div>
) : initialTarget ? (
<div className="flex items-center gap-3 rounded-lg bg-surface-input px-3 py-2.5">
<Avatar
id={initialTarget.user_id}
name={initialTarget.display_name}
avatarFileId={initialTarget.avatar_file_id}
className="h-10 w-10 flex-none rounded-lg text-sm"
/>
<span className="flex min-w-0 flex-col">
<strong className="truncate text-sm font-medium text-text-body">{initialTarget.display_name}</strong>
{initialTarget.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {initialTarget.public_account}</span> : null}
</span>
</div>
) : (
<>
<div className="flex items-center gap-2">
<div className="flex h-10 flex-1 items-center rounded-lg border border-[#d5d9df] px-3 focus-within:border-accent focus-within:shadow-[0_0_0_3px_rgba(22,119,255,0.1)]">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && runSearch()}
placeholder="手机号/账号/昵称"
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
<button type="button" onClick={runSearch} disabled={!query.trim()} className="flex-none text-sm text-accent disabled:opacity-40">
搜索
</button>
</div>
<div className="mt-3 grid grid-cols-3 gap-2">
{[
{ icon: 'qr_code_scanner', label: '扫一扫', onClick: () => setScanning(true) },
{ icon: 'qr_code_2', label: '我的二维码', onClick: () => setShowMyQrCode(true) },
{ icon: 'share', label: '分享好友', onClick: notImplemented },
].map((action) => (
<button
key={action.label}
type="button"
onClick={action.onClick}
className="flex flex-col items-center gap-1.5 rounded-lg bg-surface-input py-3 text-xs text-text-secondary"
>
<Icon name={action.icon} className="text-xl text-text-tertiary-1" />
{action.label}
</button>
))}
</div>
</>
)}
{!initialTarget && !groupJoinTarget && searchedFor ? (
<div className="mt-4 flex flex-col gap-2">
{searchQuery.isLoading ? (
<div className="py-6 text-center text-sm text-text-tertiary-4">搜索中…</div>
) : !searchQuery.data?.items.length ? (
<div className="py-6 text-center text-sm text-text-tertiary-4">未找到相关用户</div>
) : (
searchQuery.data.items.map((item) => {
const { label, selectable } = rowState(item);
const isSelected = selected?.user_id === item.user_id;
return (
<button
key={item.user_id}
type="button"
disabled={!selectable}
onClick={() => {
setSelectedSource('search');
setSelected(item);
}}
className="flex items-center gap-3 rounded-lg px-2 py-2 text-left disabled:opacity-70"
>
<Avatar id={item.user_id} name={item.display_name} avatarFileId={item.avatar_file_id} className="h-10 w-10 flex-none rounded-lg text-sm" />
<span className="flex min-w-0 flex-1 flex-col">
<strong className="truncate text-sm font-medium text-text-body">{item.display_name}</strong>
{item.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {item.public_account}</span> : null}
</span>
{selectable ? (
<span className={`flex-none rounded-full px-2.5 py-1 text-xs ${isSelected ? 'bg-accent-soft-bg text-accent' : 'bg-surface-input text-text-tertiary-2'}`}>
{isSelected ? '已选择' : '选择'}
</span>
) : (
<span className="flex-none text-xs text-text-tertiary-3">{label}</span>
)}
</button>
);
})
)}
</div>
) : null}
{selected || groupJoinTarget ? (
<div className="mt-4">
<div className="mb-1 text-sm text-text-body">{groupJoinTarget ? '申请留言' : '验证留言'}</div>
<div className="relative">
<textarea
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, MAX_MESSAGE_LENGTH))}
rows={3}
placeholder={groupJoinTarget ? '申请入群' : `我是${selected?.display_name}的朋友`}
className="w-full resize-none rounded-lg bg-surface-input p-3 text-sm outline-none"
/>
<span className="absolute bottom-2 right-3 text-xs text-text-tertiary-3">
{message.length}/{MAX_MESSAGE_LENGTH}
</span>
</div>
</div>
) : null}
</div>
)}
{sent ? null : (
<footer className="flex flex-none items-center justify-end gap-2 border-t border-border-list-item px-5 py-3">
<button type="button" onClick={onClose} className="h-9 rounded-lg border border-[#d5d9df] px-4 text-sm text-text-secondary">
取消
</button>
<button
type="button"
onClick={() => (groupJoinTarget ? groupJoinMutation.mutate() : sendMutation.mutate())}
disabled={(!selected && !groupJoinTarget) || sendMutation.isPending || groupJoinMutation.isPending}
className="h-9 rounded-lg bg-btn px-4 text-sm font-medium text-white disabled:bg-btn-disabled"
>
{sendMutation.isPending || groupJoinMutation.isPending ? '发送中…' : groupJoinTarget ? '申请加入' : '发送申请'}
</button>
</footer>
)}
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
</div>
{/* Siblings of the backdrop above, not nested inside it -- their own backdrop-click-to-close
would otherwise bubble up and close THIS modal too, since only the 520px card (not the
backdrop) stops propagation. */}
{showMyQrCode ? <QRCodeModal onClose={() => setShowMyQrCode(false)} /> : null}
{scanning ? (
<QRScannerModal
onClose={() => setScanning(false)}
onResolved={handleScanResolved}
onAlreadyFriend={handleScanAlreadyFriend}
onGroupResolved={handleScanGroupResolved}
onAlreadyMember={handleScanAlreadyMember}
/>
) : null}
</>
);
}
+158
View File
@@ -0,0 +1,158 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { batchUnblockUsers, getBlocks, unblockUser } from '@/lib/api/friends';
import { formatEventTimestamp } from '@/lib/messages/time';
/** README §5.6. `getBlocks` nests the user under `.user` (see friends.ts's own comment on that
* endpoint's real shape, a doc-drift bug caught during Phase 1-2's review).
*
* Multi-select + batch-unblock has no dedicated mock anywhere in this design handoff (04-contacts.dc.html's
* own #1g only ever shows a single row, one 移出 button each) -- rather than invent a new interaction
* pattern from nothing, this reuses the SAME 多选/已选择N项/取消 header convention FavoritesLayout.tsx
* already established for its own multi-select, so it's consistent with something this app already does,
* not a one-off design.
*/
export function BlocklistView() {
const queryClient = useQueryClient();
const [error, setError] = useState('');
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const query = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const unblockMutation = useMutation({
mutationFn: (userId: string) => unblockUser(userId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
onError: (err) => setError(err instanceof Error ? err.message : '移出黑名单失败'),
});
const exitSelectMode = () => {
setSelectMode(false);
setSelected(new Set());
};
const bulkUnblockMutation = useMutation({
mutationFn: (userIds: string[]) => batchUnblockUsers(userIds),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['blocks'] });
// A 200 here only means the REQUEST was accepted -- the doc's own worked example returns a
// per-item `failed[]` array alongside `items[]`, so some of the selected users can fail to
// unblock while the rest succeed. Silently treating this as full success (the same pattern
// already fixed for favorites/GroupAdminModal elsewhere in this app) would leave a user still
// on the blocklist with no indication anything went wrong.
if (data.failed?.length) setError(`${data.failed.length} 人移出失败,请重试`);
exitSelectMode();
},
onError: (err) => setError(err instanceof Error ? err.message : '批量移出失败'),
});
const toggleSelected = (userId: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(userId)) next.delete(userId);
else next.add(userId);
return next;
});
const items = query.data || [];
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
{selectMode ? (
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<span className="text-sm text-text-secondary">
已选择 <strong className="font-semibold text-accent">{selected.size}</strong> 项
</span>
<span className="flex items-center gap-2">
<button
type="button"
onClick={() => bulkUnblockMutation.mutate(Array.from(selected))}
disabled={selected.size === 0 || bulkUnblockMutation.isPending}
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-tertiary-1 disabled:opacity-40"
>
<Icon name="remove_circle_outline" className="text-[18px]" />
移出
</button>
<button type="button" onClick={exitSelectMode} className="grid h-8.5 place-items-center px-3.5 text-[13px] text-text-tertiary-1">
取消
</button>
</span>
</header>
) : (
<header className="flex h-15 flex-none items-center gap-1 border-b border-border-panel bg-white px-6">
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<strong className="flex-1 text-base font-semibold text-text-heading">
黑名单{items.length ? ` ${items.length}` : ''}
</strong>
{items.length > 1 ? (
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
多选
</button>
) : null}
</header>
)}
<div className="flex flex-col gap-2.5 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">黑名单是空的</div>
) : (
items.map((b) => (
<div
key={b.user.user_id}
role={selectMode ? 'button' : undefined}
tabIndex={selectMode ? 0 : undefined}
onClick={selectMode ? () => toggleSelected(b.user.user_id) : undefined}
onKeyDown={selectMode ? (e) => e.key === 'Enter' && toggleSelected(b.user.user_id) : undefined}
className={`flex items-center gap-3 rounded-xl bg-white px-4 py-3 shadow-bubble ${selectMode ? 'cursor-pointer' : ''}`}
>
{selectMode ? (
<span
className={`grid h-5 w-5 flex-none place-items-center rounded-full border text-xs text-white ${
selected.has(b.user.user_id) ? 'border-accent bg-accent' : 'border-[#d5d9df] bg-white'
}`}
>
{selected.has(b.user.user_id) ? <Icon name="check" className="text-[14px]" /> : null}
</span>
) : null}
<Avatar
id={b.user.user_id}
name={b.user.display_name}
avatarFileId={b.user.avatar_file_id}
className="h-11 w-11 flex-none rounded-lg text-base grayscale"
/>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<strong className="truncate text-[15px] font-medium text-text-body">{b.user.display_name || b.user.user_id}</strong>
<span className="truncate text-xs text-text-tertiary-2">
拉黑于 {formatEventTimestamp(new Date(b.blocked_at))} · 对方无法向你发送消息或发起通话
</span>
</span>
{selectMode ? null : (
<button
type="button"
onClick={() => unblockMutation.mutate(b.user.user_id)}
disabled={unblockMutation.isPending}
className="h-8 flex-none rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
>
移出
</button>
)}
</div>
))
)}
{items.length ? (
<p className="m-0 mt-2 text-xs text-text-tertiary-3">移出黑名单后恢复通信权限,但不会自动恢复已删除的好友关系。</p>
) : null}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
@@ -0,0 +1,29 @@
import { Outlet, useLocation } from 'react-router-dom';
import { ContactsListColumn } from '@/features/contacts/ContactsListColumn';
/**
* README §5.1's 3-column split (NavRail is AppShell's own, rendered above this): list column always
* docked (unlike messages, contacts has no detail-panel breakpoint to collapse), main area is
* whatever `/contacts/*` sub-route is active. Mirrors MessagesLayout's ChatListColumn+Outlet shape,
* minus the detail-panel/history-modal machinery messages needs and contacts doesn't.
*
* `showListOnNarrow` can't key off a route param the way MessagesLayout keys off `topicId` --
* `/contacts/requests|groups|blocklist` are sibling routes with no param at all, not variants of
* `:userId`, so this checks the exact index path instead.
*/
export function ContactsLayout() {
const location = useLocation();
const showListOnNarrow = location.pathname === '/contacts';
return (
<div className="flex h-full w-full">
<div className={`h-full w-full md:w-auto md:shrink-0 ${showListOnNarrow ? '' : 'hidden md:flex'}`}>
<ContactsListColumn />
</div>
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
<Outlet />
</div>
</div>
);
}
@@ -0,0 +1,173 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { fetchAllPages } from '@/lib/api/client';
import { getBlocks, getFriendRequests, getFriends } from '@/lib/api/friends';
const FIXED_ENTRIES = [
{ key: 'requests', route: '/contacts/requests', icon: 'person_add', label: '新朋友', colorClass: 'bg-warn-bg text-warn' },
{ key: 'groups', route: '/contacts/groups', icon: 'groups', label: '群聊', colorClass: 'bg-accent-soft-bg text-accent' },
{ key: 'blocklist', route: '/contacts/blocklist', icon: 'block', label: '黑名单', colorClass: 'bg-[#f3efff] text-[#7c5cf6]' },
// README §5.7: mobile-only -- desktop already reaches this via ChatList's own pinned row (§2.1),
// §5.1's desktop entry list doesn't include it.
{ key: 'system', route: '/messages/system', icon: 'notifications', label: '系统消息', colorClass: 'bg-warn-bg text-warn', mobileOnly: true },
] as const;
/** Server-computed `initial` (doc line 1417) sorts CJK names correctly without a client-side pinyin
* library; falls back to the display name's own first character for the rare item missing one. */
function groupInitial(name: string, initial: string | undefined): string {
const raw = (initial || name || '').trim().charAt(0).toUpperCase();
return /[A-Z]/.test(raw) ? raw : '#';
}
/**
* README §5.1 (320px): search + fixed entries (新朋友/群聊/黑名单, 文件助手 removed -- see
* ChatListColumn.tsx's PINNED_ENTRIES doc comment for why) + letter-grouped
* friend list + a right-side letter index. Friend list is fetched once (fetchAllPages) and both
* grouped and searched client-side, same convention ChatListColumn already uses for conversations
* -- friend lists are the same small-to-medium scale, no reason to hit the server per keystroke.
*/
export function ContactsListColumn() {
const { userId } = useParams();
const [query, setQuery] = useState('');
const [addingFriend, setAddingFriend] = useState(false);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
// Unlike friends/blocks (both already fetchAllPages), this used to read only page 1 -- the request
// COUNT BADGE would silently undercount for any account with more than one page of pending requests.
const requestsQuery = useQuery({
queryKey: ['friend-requests', 'inbound', 'pending'],
queryFn: () => fetchAllPages((cursor) => getFriendRequests('inbound', 'pending', cursor)),
});
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const trimmedQuery = query.trim().toLowerCase();
const friends = friendsQuery.data || [];
const filtered = trimmedQuery ? friends.filter((f) => (f.remark || f.display_name || f.user_id).toLowerCase().includes(trimmedQuery)) : friends;
const groups = useMemo(() => {
const byLetter = new Map<string, typeof filtered>();
for (const f of [...filtered].sort((a, b) => (a.remark || a.display_name || '').localeCompare(b.remark || b.display_name || '', 'zh'))) {
const letter = groupInitial(f.remark || f.display_name || f.user_id, f.initial);
byLetter.set(letter, [...(byLetter.get(letter) || []), f]);
}
return [...byLetter.entries()].sort(([a], [b]) => (a === '#' ? 1 : b === '#' ? -1 : a.localeCompare(b)));
}, [filtered]);
const pendingRequestCount = requestsQuery.data?.length ?? 0;
return (
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">通讯录</h1>
<button type="button" onClick={() => setAddingFriend(true)} className="grid h-8 w-8 place-items-center text-text-heading">
<Icon name="person_add_alt" />
</button>
</header>
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索联系人"
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
/>
</div>
<div className="relative flex flex-1 overflow-hidden">
<div className="flex flex-1 flex-col overflow-y-auto">
{trimmedQuery ? null : (
<>
{FIXED_ENTRIES.map((entry) => {
const badge =
entry.key === 'requests'
? pendingRequestCount
: entry.key === 'blocklist'
? (blocksQuery.data?.length ?? 0)
: 0;
return (
<Link
key={entry.key}
to={entry.route}
className={`flex h-13 flex-none items-center gap-3 border-b border-border-list-item px-4 no-underline hover:bg-[#f6f8fb] ${
'mobileOnly' in entry && entry.mobileOnly ? 'md:hidden' : ''
}`}
>
{/* README §5.7: mobile fixed-entry icons/text are one size up from desktop's
36px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg md:h-9 md:w-9 ${entry.colorClass}`}>
<Icon name={entry.icon} className="text-xl" />
</span>
<strong className="flex-1 truncate text-base font-medium text-[#272b31] md:text-[15px]">{entry.label}</strong>
{entry.key === 'requests' && badge ? (
<b className="grid h-[18px] min-w-[18px] flex-none place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{badge}
</b>
) : entry.key === 'groups' ? (
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
) : entry.key === 'blocklist' && badge ? (
<span className="flex-none text-xs text-text-tertiary-3">{badge}</span>
) : null}
</Link>
);
})}
</>
)}
{friendsQuery.isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-text-tertiary-4">加载中…</div>
) : filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
<Icon name={trimmedQuery ? 'search_off' : 'people_outline'} className="!text-4xl" />
<span className="text-sm">{trimmedQuery ? '未找到相关联系人' : '暂无联系人'}</span>
</div>
) : (
groups.map(([letter, items]) => (
<div key={letter} id={`contact-letter-${letter}`}>
<div className="bg-[#fafbfc] px-4 pb-1 pt-3.5 text-xs text-text-tertiary-2">{letter}</div>
{items.map((f) => {
const name = f.remark || f.display_name || f.user_id;
const active = userId === f.user_id;
return (
<Link
key={f.user_id}
to={`/contacts/${f.user_id}`}
className={`flex h-13.5 flex-none items-center gap-3 px-4 no-underline ${active ? 'bg-accent-selected-bg' : 'hover:bg-[#f6f8fb]'}`}
>
{/* README §5.7: mobile friend-row avatar/text are 44px/16px, one size up from
desktop's 40px/15px (§5.1) -- unconditional desktop sizing before this fix. */}
<Avatar id={f.user_id} name={name} avatarFileId={f.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base md:h-10 md:w-10 md:text-sm" />
<span className="truncate text-base text-[#272b31] md:text-[15px]">{name}</span>
</Link>
);
})}
</div>
))
)}
</div>
{trimmedQuery || groups.length === 0 ? null : (
<div className="flex w-4 flex-none flex-col items-center justify-center gap-0.5 py-2">
{groups.map(([letter]) => (
<a
key={letter}
href={`#contact-letter-${letter}`}
onClick={(e) => {
e.preventDefault();
document.getElementById(`contact-letter-${letter}`)?.scrollIntoView({ block: 'start' });
}}
className="text-[10px] leading-[14px] text-text-tertiary-2 hover:font-semibold hover:text-accent"
>
{letter}
</a>
))}
</div>
)}
</div>
{addingFriend ? <AddFriendModal onClose={() => setAddingFriend(false)} /> : null}
</aside>
);
}
@@ -0,0 +1,10 @@
import { Icon } from '@/app/Icon';
export function EmptyContactsMain() {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-text-tertiary-4">
<Icon name="person_search" className="!text-5xl" />
<span className="text-sm">选择一位联系人查看资料</span>
</div>
);
}
@@ -0,0 +1,249 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
import { ImApiError } from '@/lib/api/client';
import { getUserProfile, updateFriendRemark } from '@/lib/api/friends';
import { useCallStore } from '@/stores/calls';
const GENDER_LABEL: Record<string, string> = { male: '男', female: '女' };
/**
* README §5.2 (440px centered card). `getUserProfile` (doc line 1328/1425) has no field describing
* HOW two people became friends -- the design's "来源:通过群聊「项目协作群」添加" row has nothing
* to read it off of, so it's left out rather than fabricated; every other row maps to a real field.
*/
export function FriendProfileView() {
const { userId } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [editingRemark, setEditingRemark] = useState(false);
const [remarkDraft, setRemarkDraft] = useState('');
const [error, setError] = useState('');
const [addingFriend, setAddingFriend] = useState(false);
const [showQrCode, setShowQrCode] = useState(false);
const query = useQuery({
queryKey: ['user-profile', userId],
queryFn: () => getUserProfile(userId || ''),
enabled: !!userId,
});
const remarkMutation = useMutation({
mutationFn: (remark: string) => updateFriendRemark(userId || '', remark),
onSuccess: (_data, remark) => {
queryClient.setQueryData(['user-profile', userId], (prev: typeof query.data) => (prev ? { ...prev, remark } : prev));
queryClient.invalidateQueries({ queryKey: ['friends'] });
setEditingRemark(false);
},
onError: (err) => setError(err instanceof Error ? err.message : '备注保存失败'),
});
// `/contacts/:userId` is a route only reachable via a route change (list item, search result),
// never a param variant of the index -- so ContactsLayout's showListOnNarrow hides the list here
// just like every other narrow-viewport sub-route, and this page needs the same back affordance
// GroupListView/NewFriendsView/BlocklistView have in their own header. This one has no header bar
// at any state (loading/not-found/loaded), so the link is repeated across all three returns.
const backLink = (
<Link to="/contacts" className="absolute left-4 top-4 z-10 grid h-9.5 w-9.5 place-items-center rounded-full bg-white/70 text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
);
if (!userId) return null;
if (query.isLoading) {
return (
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
{backLink}
加载中…
</div>
);
}
const profile = query.data;
if (!profile) {
// Confirmed live 2026-09-09: a 403 here comes back with reason PERMISSION_DENIED and message
// "profile is private" -- a real privacy setting on the other side, not a missing/deleted
// account. Collapsing it into "未找到该用户" would misrepresent an intentional privacy choice
// as if the user doesn't exist; every other failure (404, network error) keeps that fallback.
const isPrivate = query.error instanceof ImApiError && query.error.status === 403;
return (
<div className="relative flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
{backLink}
{isPrivate ? '对方设置了资料保护,暂时无法查看' : '未找到该用户'}
</div>
);
}
const isFriend = profile.relationship === 'friend';
// docs/IM-前后端对接文档.md:1289's real enum is direction-specific -- `isFriend` alone used to
// collapse BOTH pending states into "not a friend yet", which fell through to the generic "添加
// 好友" button below and let the user re-open AddFriendModal and resubmit a request that's already
// outstanding either direction.
const isPendingOutbound = profile.relationship === 'pending_outbound';
const isPendingInbound = profile.relationship === 'pending_inbound';
const name = profile.remark || profile.display_name;
const metaParts = [profile.display_name ? `昵称:${profile.display_name}` : null, profile.gender ? GENDER_LABEL[profile.gender] || profile.gender : null, profile.region]
.filter(Boolean)
.join(' · ');
// README §5.2 (desktop, 44px) vs §5.7 (mobile, 48px) want the SAME three actions at two different
// sizes in two different places (inline in the card vs a fixed bottom bar, see below) -- one
// function instead of duplicating the branching/handlers so there's exactly one behavior to get
// right, not two copies that can drift.
const actionButtons = (squareBtnClass: string, iconSizeClass: string) =>
isFriend ? (
<>
<button
type="button"
// `profile.user_id`, not `profile.topic` -- confirmed live (see favorites.ts's own
// finding) that this API's `topic` field for a P2P relationship is the server's
// internal `p2p...`-hashed form, which this app's chat list/router never uses;
// every P2P conversation elsewhere in this app is addressed by the peer's own
// `usr...` id, which Tinode resolves to the same real topic on the wire.
onClick={() => navigate(`/messages/${profile.user_id}`)}
className={`flex flex-1 items-center justify-center gap-1.5 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm ${squareBtnClass.split(' ')[0]}`}
>
<Icon name="chat_bubble" className={iconSizeClass} />
发消息
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(profile.user_id, name, 'audio');
}}
className={`grid flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50 ${squareBtnClass}`}
>
<Icon name="call" />
</button>
<button
type="button"
disabled={profile.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(profile.user_id, name, 'video');
}}
className={`grid flex-none place-items-center rounded-lg bg-surface-input text-accent disabled:opacity-50 ${squareBtnClass}`}
>
<Icon name="videocam" />
</button>
</>
) : isPendingOutbound ? (
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
等待验证
</button>
) : isPendingInbound ? (
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
对方已申请加你
</button>
) : profile.capabilities?.can_add_friend === false ? (
<button type="button" disabled className={`flex-1 rounded-lg bg-btn-disabled text-sm font-medium text-white ${squareBtnClass.split(' ')[0]}`}>
对方暂不可添加
</button>
) : (
<button
type="button"
onClick={() => setAddingFriend(true)}
className={`flex-1 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm ${squareBtnClass.split(' ')[0]}`}
>
添加好友
</button>
);
return (
<div className="relative flex h-full w-full items-center-safe justify-center overflow-y-auto bg-surface-content md:py-12">
{backLink}
<div className="w-full flex-none overflow-hidden bg-white shadow-[0_8px_32px_rgba(35,74,125,0.08)] md:w-[440px] md:rounded-2xl">
<div className="h-24 bg-gradient-to-br from-[#e8f1fe] to-[#f3efff]" />
<div className="flex flex-col items-center px-6 pb-24 md:pb-6">
<Avatar
id={profile.user_id}
name={name}
avatarFileId={profile.avatar_file_id}
className="-mt-11 h-22 w-22 flex-none rounded-[20px] border-4 border-white text-2xl"
/>
<strong className="mt-3 text-xl font-semibold text-text-heading">{name}</strong>
{metaParts ? <span className="mt-1 text-[13px] text-text-tertiary-2">{metaParts}</span> : null}
{profile.public_account ? <span className="text-[13px] text-text-tertiary-2">ID: {profile.public_account}</span> : null}
{profile.signature ? <p className="m-0 mt-2 text-sm text-text-secondary">{profile.signature}</p> : null}
{/* README §5.7: mobile pins this row OUTSIDE the scroll area (bottom-fixed), so it stays
reachable regardless of profile length -- this hidden copy keeps the original inline
desktop placement exactly as before; the real mobile copy is the fixed bar rendered
after the card below. Both call the same handlers, so there is exactly one behavior. */}
<div className="mt-5 hidden w-full items-center gap-2 md:flex">{actionButtons('h-11 w-11', 'text-[18px]')}</div>
{isFriend ? (
<div className="mt-4 flex w-full flex-col divide-y divide-border-list-item border-t border-border-list-item text-sm">
{editingRemark ? (
<div className="flex items-center gap-2 py-3">
<span className="flex-none text-text-body">备注</span>
<input
autoFocus
value={remarkDraft}
onChange={(e) => setRemarkDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && remarkMutation.mutate(remarkDraft.trim())}
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
/>
<button type="button" onClick={() => remarkMutation.mutate(remarkDraft.trim())} className="flex-none text-accent">
保存
</button>
</div>
) : (
<button
type="button"
onClick={() => {
setRemarkDraft(profile.remark || '');
setEditingRemark(true);
}}
className="flex justify-between py-3 text-left"
>
<span className="text-text-body">备注</span>
<span className="flex items-center gap-0.5 text-text-tertiary-2">
{profile.remark || '未设置'}
<Icon name="chevron_right" className="text-[18px]" />
</span>
</button>
)}
<button type="button" onClick={() => setShowQrCode(true)} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">二维码名片</span>
<Icon name="qr_code_2" className="text-[20px] text-text-tertiary-2" />
</button>
</div>
) : null}
</div>
</div>
{/* Mobile-only fixed bottom bar (mock `04/1e`: `padding:12px 16px 34px`, 48px buttons) -- the
real mobile counterpart to the hidden desktop copy above, pinned to the viewport bottom
(not the card) so it stays reachable no matter how far the profile content scrolls.
`bottom-[82px] sm:bottom-0`: `position:fixed` resolves against the viewport, not this
route's own Outlet box, and below 640px the viewport bottom is where AppShell's persistent
BottomTabBar (82px) already lives -- an unqualified `bottom-0` sat directly on top of it,
hiding the tab bar entirely while this page was open. `sm:bottom-0` restores flush-bottom
once the tab bar is gone (>=640px, NavRail shown on the side instead). */}
<div className="fixed inset-x-0 bottom-[82px] z-10 flex gap-3 bg-white px-4 pb-[34px] pt-3 sm:bottom-0 md:hidden">
{actionButtons('h-12 w-12', 'text-xl')}
</div>
{showQrCode && userId ? <QRCodeModal identifier={userId} onClose={() => setShowQrCode(false)} /> : null}
{addingFriend ? (
<AddFriendModal
onClose={() => setAddingFriend(false)}
initialTarget={{
user_id: profile.user_id,
display_name: profile.display_name,
public_account: profile.public_account,
avatar_file_id: profile.avatar_file_id,
}}
/>
) : null}
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
import { fetchAllPages } from '@/lib/api/client';
import { getGroups, type GroupRole } from '@/lib/api/groups';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
type Tab = 'all' | 'owner' | 'admin' | 'member';
const TABS: { key: Tab; label: string }[] = [
{ key: 'all', label: '全部' },
{ key: 'owner', label: '我创建的' },
{ key: 'admin', label: '我管理的' },
{ key: 'member', label: '我加入的' },
];
/** README §5.5. Fetched once (category='', i.e. every group) and tabbed client-side by `my_role`
* rather than hitting `getGroups`'s own owned/managed/joined category filters three more times --
* same total data, one request instead of four. */
export function GroupListView() {
const navigate = useNavigate();
const [tab, setTab] = useState<Tab>('all');
const [creatingGroup, setCreatingGroup] = useState(false);
// Only page 1 used to be fetched -- an account with more than one page of groups would silently
// undercount every tab (including "全部") and never list the rest.
const query = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
const groups = query.data || [];
const counts: Record<Tab, number> = {
all: groups.length,
owner: groups.filter((g) => g.my_role === 'owner').length,
admin: groups.filter((g) => g.my_role === 'admin').length,
member: groups.filter((g) => g.my_role === 'member').length,
};
const filtered = tab === 'all' ? groups : groups.filter((g) => g.my_role === tab);
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<span className="flex items-center gap-1">
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<strong className="text-base font-semibold text-text-heading">群聊</strong>
</span>
<button type="button" onClick={() => setCreatingGroup(true)} className="flex items-center gap-1 text-sm text-accent">
<Icon name="add" className="text-[18px]" />
创建群聊
</button>
</header>
<div className="flex flex-none items-center gap-6 border-b border-border-panel bg-white px-6">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`h-11 border-b-2 text-sm ${tab === t.key ? 'border-accent font-medium text-accent' : 'border-transparent text-text-secondary'}`}
>
{t.label} {counts[t.key]}
</button>
))}
</div>
<div className="flex flex-col gap-2.5 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : filtered.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群聊</div>
) : (
filtered.map((g) => (
<button
key={g.topic}
type="button"
onClick={() => navigate(`/messages/${g.topic}`)}
className="flex items-center gap-3 rounded-xl bg-white px-4 py-3 text-left shadow-bubble"
>
<Avatar id={g.topic} name={g.name} avatarFileId={g.avatar_file_id} className="h-11 w-11 flex-none rounded-[10px] text-base" />
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<span className="flex items-center gap-1.5">
<strong className="truncate text-[15px] font-medium text-text-body">{g.name}</strong>
{g.my_role !== 'member' ? (
<span
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${g.my_role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'}`}
>
{ROLE_LABEL[g.my_role]}
</span>
) : null}
</span>
<span className="truncate text-xs text-text-tertiary-2">
{g.member_count} 人 · 群号 {g.topic}
</span>
</span>
<Icon name="chevron_right" className="flex-none text-[18px] text-icon-arrow" />
</button>
))
)}
</div>
{creatingGroup ? (
<CreateGroupModal
onClose={() => setCreatingGroup(false)}
onCreated={(topic) => {
setCreatingGroup(false);
navigate(`/messages/${topic}`);
}}
/>
) : null}
</div>
);
}
@@ -0,0 +1,122 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { AddFriendModal } from '@/features/contacts/AddFriendModal';
import { fetchAllPages } from '@/lib/api/client';
import { acceptFriendRequest, getFriendRequests, rejectFriendRequest, type FriendRequest } from '@/lib/api/friends';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
const SOURCE_LABEL: Record<string, string> = { search: '通过账号搜索', qr: '扫码添加', group: '通过群聊添加', phone: '通过手机号搜索' };
/** README §5.3: both pending (拒绝/通过验证 buttons) and already-handled (灰字"已通过/已拒绝")
* inbound requests show together in one list -- `getFriendRequests('inbound', 'all')` matches
* that, not a pending-only fetch. */
export function NewFriendsView() {
const queryClient = useQueryClient();
const [addingFriend, setAddingFriend] = useState(false);
const [error, setError] = useState('');
const [handled, setHandled] = useState<Record<string, 'accepted' | 'rejected'>>({});
// Only page 1 used to be fetched -- an account with more than one page of historical requests
// would silently never show its older ones.
const query = useQuery({
queryKey: ['friend-requests', 'all'],
queryFn: () => fetchAllPages((cursor) => getFriendRequests('inbound', 'all', cursor)),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['friend-requests'] });
queryClient.invalidateQueries({ queryKey: ['friends'] });
};
const acceptMutation = useMutation({
mutationFn: (req: FriendRequest) => acceptFriendRequest(req.request_id),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'accepted' }));
invalidate();
},
onError: (err) => setError(err instanceof Error ? err.message : '操作失败'),
});
const rejectMutation = useMutation({
mutationFn: (req: FriendRequest) => rejectFriendRequest(req.request_id),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'rejected' }));
invalidate();
},
onError: (err) => setError(err instanceof Error ? err.message : '操作失败'),
});
const items = query.data || [];
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-surface-content">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<span className="flex items-center gap-1">
<Link to="/contacts" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<strong className="text-base font-semibold text-text-heading">新朋友</strong>
</span>
<button type="button" onClick={() => setAddingFriend(true)} className="text-sm text-accent">
添加好友
</button>
</header>
<div className="flex flex-col gap-3 p-6">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : items.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无新朋友申请</div>
) : (
items.map((req) => {
const status = handled[req.request_id] || (req.status !== 'pending' ? (req.status === 'accepted' ? 'accepted' : 'rejected') : null);
const name = req.peer?.display_name || req.peer?.user_id || req.applicant_id;
return (
<div key={req.request_id} className="flex items-center gap-3 rounded-xl bg-white px-4.5 py-3.5 shadow-bubble">
<span className={`grid h-12 w-12 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(req.applicant_id)}`}>
{avatarLetter(name)}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-0.5">
<strong className="truncate text-[15px] font-medium text-text-body">{name}</strong>
{req.message ? <span className="truncate text-[13px] text-text-secondary">{req.message}</span> : null}
<span className="truncate text-xs text-text-tertiary-3">
{formatEventTimestamp(new Date(req.created_at))}
{req.source ? ` · ${SOURCE_LABEL[req.source] || req.source}` : ''}
</span>
</span>
{status ? (
<span className="flex-none text-xs text-text-tertiary-3">{status === 'accepted' ? '已通过' : '已拒绝'}</span>
) : (
<span className="flex flex-none items-center gap-2">
<button
type="button"
onClick={() => rejectMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
>
拒绝
</button>
<button
type="button"
onClick={() => acceptMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
>
通过验证
</button>
</span>
)}
</div>
);
})
)}
</div>
{addingFriend ? <AddFriendModal onClose={() => setAddingFriend(false)} /> : null}
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { createGroupQRCode, createMyQRCode, createUserQRCode, type GroupQRCode, type MyQRCode } from '@/lib/api/qrcodes';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
interface QRCodeModalProps {
/** Omit both to show the CURRENT user's own code; pass a friend's user_id/account to show theirs
* (README's "二维码名片" row on a friend's profile). */
identifier?: string;
/** 03-group.dc.html #1a's `qr_code_2` icon on the group-info card (admin/owner-visible group-join
* code) -- mutually exclusive with `identifier`, a group is never also a user. */
groupTopic?: string;
onClose: () => void;
}
/**
* README §5.4/§8.2's "我的二维码"/"二维码名片", plus the group-join code (03-group.dc.html #1a). The
* QR image encodes `qr_payload` (`im://qr?token=...&purpose=add_friend|join_group`), never a raw
* user/group id -- confirmed live for the personal-code path that this is a real, working backend
* feature (`POST /me/qrcodes` / `POST /users/{id}/qrcodes`), not a documented-but-unbuilt one; the
* group-code path (`POST /groups/{topic}/qrcodes`) is the same documented shape (docs/IM-前后端对接
* 文档.md:1590,1737) but not yet live-verified against a real deployment. Re-mints a fresh token on
* every mount (POST semantics, not cached) since each one is short-lived (~24h) and single-purpose.
*/
export function QRCodeModal({ identifier, groupTopic, onClose }: QRCodeModalProps) {
useEscapeKey(onClose);
const canvasRef = useRef<HTMLCanvasElement>(null);
const [drawError, setDrawError] = useState('');
const query = useQuery<MyQRCode | GroupQRCode>({
queryKey: ['qrcode', groupTopic ? `group:${groupTopic}` : identifier || 'me'],
queryFn: (): Promise<MyQRCode | GroupQRCode> => (groupTopic ? createGroupQRCode(groupTopic) : identifier ? createUserQRCode(identifier) : createMyQRCode()),
});
useEffect(() => {
if (!query.data || !canvasRef.current) return;
const canvas = canvasRef.current;
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-generating library
// isn't in every visitor's main bundle when most sessions never open this modal.
import('qrcode')
.then(({ default: QRCode }) => QRCode.toCanvas(canvas, query.data.qr_payload, { width: 200, margin: 1, color: { dark: '#22262d', light: '#ffffff' } }))
.catch(() => setDrawError('二维码生成失败'));
}, [query.data]);
const profile = query.data && 'display_profile' in query.data ? query.data.display_profile : undefined;
const group = query.data && 'group_snapshot' in query.data ? query.data.group_snapshot : undefined;
const title = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
const fileBaseName = groupTopic ? '群二维码' : identifier ? '二维码名片' : '我的二维码';
const caption = groupTopic ? '扫一扫上面的二维码,申请加入群聊' : '扫一扫上面的二维码,加我为好友';
const canvasToBlob = (): Promise<Blob | null> => new Promise((resolve) => canvasRef.current?.toBlob((b) => resolve(b), 'image/png'));
const saveToAlbum = async () => {
const blob = await canvasToBlob();
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${fileBaseName}.png`;
link.click();
URL.revokeObjectURL(url);
};
const shareToFriend = async () => {
const blob = await canvasToBlob();
if (!blob) return;
const file = new File([blob], 'qrcode.png', { type: 'image/png' });
// Real capability check, not just feature-detecting `navigator.share` itself -- some browsers
// implement `share()` for URLs/text only and reject a `files` payload at call time, so
// `canShare({ files })` is the one call that actually answers "can this share an image".
if (navigator.canShare?.({ files: [file] })) {
try {
await navigator.share({ files: [file], title: fileBaseName });
} catch {
// AbortError (user cancelled the native share sheet) or any other failure -- either way
// there's nothing useful to surface, the share sheet's own UI already communicated the outcome.
}
} else {
await saveToAlbum();
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex w-[360px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-13 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex flex-col items-center gap-3 px-6 py-7">
{profile ? (
<>
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(profile.user_id)}`}>
{avatarLetter(profile.display_name)}
</span>
<strong className="text-base font-semibold text-text-heading">{profile.display_name}</strong>
{profile.public_account ? <span className="text-xs text-text-tertiary-2">ID:{profile.public_account}</span> : null}
</>
) : group ? (
<>
<span className={`grid h-14 w-14 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(group.topic)}`}>
{avatarLetter(group.name)}
</span>
<strong className="text-base font-semibold text-text-heading">{group.name}</strong>
<span className="text-xs text-text-tertiary-2">{group.member_count} 人</span>
</>
) : null}
<div className="mt-2 grid h-[220px] w-[220px] place-items-center rounded-lg border border-border-panel">
{query.isLoading ? (
<span className="text-sm text-text-tertiary-4">生成中…</span>
) : query.isError || drawError ? (
<div className="flex flex-col items-center gap-2 px-4 text-center">
<Icon name="error_outline" className="text-2xl text-danger-text" />
<span className="text-xs text-text-tertiary-4">{drawError || '二维码生成失败,请重试'}</span>
</div>
) : (
<canvas ref={canvasRef} />
)}
</div>
<span className="text-xs text-text-tertiary-3">{caption}</span>
{/* No mock anywhere in this design handoff draws these two buttons (grepped every
screens/*.dc.html) -- added on the strength of a near-universal QR-display convention
(save/share), not a documented spec. Reuses the `share` icon the add-friend chip
(04-contacts.dc.html:101) already uses for the same verb elsewhere in this app. */}
{query.data && !drawError ? (
<div className="mt-1 flex gap-2">
<button
type="button"
onClick={saveToAlbum}
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
>
<Icon name="download" className="text-[16px]" />
保存到相册
</button>
<button
type="button"
onClick={shareToFriend}
className="flex h-8.5 items-center gap-1.5 rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-secondary"
>
<Icon name="share" className="text-[16px]" />
分享好友
</button>
</div>
) : null}
</div>
</div>
</div>
);
}
@@ -0,0 +1,229 @@
import { useEffect, useRef, useState } from 'react';
import { Icon } from '@/app/Icon';
import { resolveQRCode, type QRResolveGroup, type QRResolveUser } from '@/lib/api/qrcodes';
import { useEscapeKey } from '@/lib/hooks';
interface QRScannerModalProps {
onClose: () => void;
/** A resolved, genuinely-addable ('none' relationship, can_add) scanned user. */
onResolved: (user: QRResolveUser) => void;
/** Scanned code belongs to someone already a friend -- caller decides what "already added"
* should do (e.g. navigate to their profile) instead of re-showing an add-friend form. */
onAlreadyFriend: (user: QRResolveUser) => void;
/** A `purpose:"join_group"` code resolved successfully and the scanning account is NOT already a
* member -- optional so a caller that only ever scans for the add-friend flow (there is none
* currently, but the type shouldn't force every caller to handle a case it can't reach) doesn't
* have to wire a no-op. `qrToken` is threaded through because the join-request submission itself
* needs to carry it (docs/IM-前后端对接文档.md:1591). */
onGroupResolved?: (group: QRResolveGroup, qrToken: string) => void;
/** Mirrors `onAlreadyFriend` for the group case -- confirmed live that resolving a code for a
* group you're already in comes back `status:"joined"` rather than something requiring a join
* request at all (see qrcodes.ts's QRResolveResult comment). */
onAlreadyMember?: (group: QRResolveGroup) => void;
}
const SCAN_INTERVAL_MS = 250;
/** Old app's own real, working scanner (im_webapp/src/widgets/qr-scanner.jsx) downscales to this
* max edge before decoding -- jsQR's cost scales with pixel count, and a raw 1080p+ camera frame
* decoded every 250ms would be needlessly expensive when a QR code only needs a few hundred px
* per side to read reliably. */
const MAX_FRAME_EDGE = 480;
type ScanState = 'requesting' | 'scanning' | 'resolving' | 'camera-error' | 'resolve-error';
/**
* README §5.4's "扫一扫" -- full-screen camera viewfinder. Ported cognition (not code) from the old
* app's real, working `qr-scanner.jsx` (getUserMedia -> draw each frame to an offscreen canvas ->
* jsQR) and `tinode-web.jsx`'s `handleQrDetected` (reject anything not matching the app's own
* `im://qr?token=...&purpose=...` scheme client-side before ever sending it to `resolve`, so a
* generic URL/WiFi/whatever QR code someone points this at is never even round-tripped).
*/
export function QRScannerModal({ onClose, onResolved, onAlreadyFriend, onGroupResolved, onAlreadyMember }: QRScannerModalProps) {
useEscapeKey(onClose);
const videoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamRef = useRef<MediaStream | null>(null);
const timerRef = useRef<number | null>(null);
const resolvingRef = useRef(false);
const mountedRef = useRef(true);
const [state, setState] = useState<ScanState>('requesting');
const [errorMessage, setErrorMessage] = useState('');
const stopCamera = () => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
};
useEffect(
() => () => {
mountedRef.current = false;
},
[],
);
const handleDetected = async (text: string) => {
if (resolvingRef.current) return;
// Only this app's own generated codes resolve to anything -- a generic URL/WiFi/contact QR is
// rejected client-side rather than round-tripped to the server for a guaranteed-useless lookup.
if (!text.startsWith('im://qr?')) return;
const params = new URLSearchParams(text.slice('im://qr?'.length));
const token = params.get('token');
const purpose = params.get('purpose') || 'add_friend';
if (!token) return;
resolvingRef.current = true;
setState('resolving');
try {
const result = await resolveQRCode(token, purpose);
// The modal may have been closed (or the user's intent otherwise moved on) while this request
// was in flight -- applying its result now would redirect/populate state the user never asked
// for after they believed they'd already cancelled.
if (!mountedRef.current) return;
if (purpose === 'join_group') {
// See qrcodes.ts's own QRResolveGroup comment -- confirmed live this nests under `group`
// (group_snapshot checked too as a defensive fallback, not because it's expected to fire).
const group = result.group || result.group_snapshot;
if (!group || !onGroupResolved) {
// Same leak as the two other resolve-error branches below: without this, the scan loop and
// camera stream both kept running underneath the full-screen error card, so the SAME
// unsupported code re-triggered `resolveQRCode` every ~250ms indefinitely, and clicking
// "重试" then started a second stream/interval on top without ever stopping this one.
stopCamera();
setErrorMessage('暂不支持此类二维码');
setState('resolve-error');
resolvingRef.current = false;
return;
}
stopCamera();
if (result.status === 'joined' && onAlreadyMember) onAlreadyMember(group);
else onGroupResolved(group, token);
return;
}
if (purpose !== 'add_friend' || !result.user) {
stopCamera();
setErrorMessage('暂不支持此类二维码');
setState('resolve-error');
resolvingRef.current = false;
return;
}
stopCamera();
if (result.user.relationship === 'friend') {
onAlreadyFriend(result.user);
} else if (!result.user.can_add) {
setErrorMessage('无法添加该用户为好友');
setState('resolve-error');
resolvingRef.current = false;
} else {
onResolved(result.user);
}
} catch (err) {
if (!mountedRef.current) return;
stopCamera();
setErrorMessage(err instanceof Error ? err.message : '二维码已失效');
setState('resolve-error');
resolvingRef.current = false;
}
};
// Dynamically imported (like agora-rtc-sdk-ng in agora-client.ts) so a QR-decoding library isn't
// in every visitor's main bundle when most sessions never open the scanner.
const startScanLoop = async () => {
const canvas = canvasRef.current;
const video = videoRef.current;
if (!canvas || !video) return;
const { default: jsQR } = await import('jsqr');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
timerRef.current = window.setInterval(() => {
if (!ctx || video.readyState !== video.HAVE_ENOUGH_DATA || resolvingRef.current) return;
const scale = Math.min(1, MAX_FRAME_EDGE / Math.max(video.videoWidth, video.videoHeight));
const w = Math.max(1, Math.round(video.videoWidth * scale));
const h = Math.max(1, Math.round(video.videoHeight * scale));
canvas.width = w;
canvas.height = h;
ctx.drawImage(video, 0, 0, w, h);
const frame = ctx.getImageData(0, 0, w, h);
const code = jsQR(frame.data, w, h, { inversionAttempts: 'dontInvert' });
if (code?.data) handleDetected(code.data);
}, SCAN_INTERVAL_MS);
};
// React 18 StrictMode (dev only) runs mount -> cleanup -> mount synchronously, which starts TWO
// overlapping getUserMedia() calls; without this generation token, whichever resolves last wins
// and clobbers the other's streamRef/timerRef, leaking the loser's MediaStream (never .stop()'d)
// and its setInterval. Matches the same-class guard already used in tinode/connection.ts/topics.ts.
const requestGenerationRef = useRef(0);
const requestCamera = async () => {
const myGeneration = ++requestGenerationRef.current;
setState('requesting');
setErrorMessage('');
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' }, audio: false });
if (myGeneration !== requestGenerationRef.current) {
stream.getTracks().forEach((t) => t.stop());
return;
}
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setState('scanning');
startScanLoop();
} catch (err) {
if (myGeneration !== requestGenerationRef.current) return;
setErrorMessage(err instanceof Error ? err.message : '无法访问摄像头');
setState('camera-error');
}
};
useEffect(() => {
requestCamera();
return stopCamera;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const retry = () => {
resolvingRef.current = false;
requestCamera();
};
return (
<div className="fixed inset-0 z-50 flex flex-col bg-black">
<header className="flex h-13 flex-none items-center justify-between px-4 text-white">
<strong className="text-[15px] font-semibold">扫一扫</strong>
<button type="button" onClick={onClose} className="grid h-9 w-9 place-items-center">
<Icon name="close" />
</button>
</header>
<div className="relative flex-1">
<video ref={videoRef} playsInline muted className="h-full w-full object-cover" />
<canvas ref={canvasRef} className="hidden" />
{state === 'scanning' ? (
<div className="absolute inset-0 grid place-items-center">
<div className="h-56 w-56 rounded-2xl border-2 border-white/80" />
</div>
) : null}
{state === 'requesting' || state === 'resolving' ? (
<div className="absolute inset-0 grid place-items-center bg-black/40 text-sm text-white">
{state === 'requesting' ? '正在请求摄像头权限…' : '正在识别…'}
</div>
) : null}
{state === 'camera-error' || state === 'resolve-error' ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-black/70 px-8 text-center text-white">
<Icon name={state === 'camera-error' ? 'videocam_off' : 'error_outline'} className="text-4xl" />
<span className="text-sm">{state === 'camera-error' ? `无法访问摄像头:${errorMessage}` : errorMessage}</span>
<button type="button" onClick={retry} className="h-9 rounded-lg bg-btn px-5 text-sm font-medium text-white">
重试
</button>
</div>
) : null}
</div>
<p className="flex-none py-4 text-center text-sm text-white/70">将二维码放入框内,即可自动扫描</p>
</div>
);
}
@@ -0,0 +1,517 @@
import { useState } from 'react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { AudioAttachment } from '@/features/messages/AttachmentPreview';
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
import {
batchDeleteFavorites,
deleteFavorite,
favoriteEntityData,
forwardFavorites,
getFavorites,
type Favorite,
type FavoriteType,
} from '@/lib/api/favorites';
import { useAuthedBlobUrl, useDebouncedValue } from '@/lib/hooks';
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useChatList } from '@/lib/tinode/hooks';
const CATEGORIES: { key: FavoriteType | 'all'; label: string; icon: string }[] = [
{ key: 'all', label: '全部', icon: 'all_inbox' },
{ key: 'text', label: '文本', icon: 'notes' },
{ key: 'image', label: '图片', icon: 'image' },
{ key: 'video', label: '视频', icon: 'videocam' },
{ key: 'voice', label: '语音', icon: 'mic' },
{ key: 'file', label: '文件', icon: 'description' },
{ key: 'link', label: '链接', icon: 'link' },
];
/**
* README §6.1's footer "陈明远 · 项目协作群" names both the sender AND the conversation -- the
* confirmed real `source` shape (doc line 1933) only carries `{topic, seq}`, no sender field, so
* this can only resolve the CONVERSATION side (via the chat list's own contact cache, same lookup
* ChatListColumn/ConversationView already do) and shows just that, not a fabricated sender name.
*
* Confirmed live: a P2P favorite's `source.topic` comes back in the server's internal `p2p...`-
* hashed form (e.g. "p2pH5dvZ3ckWxq4XhSKX6MIow"), not the peer's `usr...` id the chat list and
* every route in this app actually key P2P conversations by -- the SDK's own `Topic.topicType()`
* doesn't even recognize a "p2p" prefix (its table only has usr/grp/new/nch/chn/sys/slf), so this
* is a REST-API-only naming quirk, disconnected from the WS/SDK convention. There's no client-side
* way to reverse that hash back to the peer's user id, so a chat-list lookup by topic can never
* match it -- showing the raw hash string would be confusing, so this falls back to a plain label
* for that case instead of guessing.
*/
function SourceLine({ source }: { source: Favorite['source'] }) {
const contacts = useChatList();
if (!source) return null;
const known = contacts.find((c) => c.topic === source.topic)?.public?.fn;
// Same reasoning as the p2p fallback below -- a group this account has never actually opened as a
// real conversation (e.g. joined via an admin action, never chatted in) has no entry in the local
// chat-list cache either, and showing the raw internal topic id (`grpXXXXXXXX`) to the user reads
// as a broken/leaked implementation detail, not a "no data" state -- fall back to a generic label.
const fallback = source.topic.startsWith('p2p') ? '单聊消息' : source.topic.startsWith('grp') ? '群聊' : source.topic;
return <span className="truncate">{known || fallback}</span>;
}
function FavoriteCard({ item, selectMode, selected, onToggle }: { item: Favorite; selectMode: boolean; selected: boolean; onToggle: () => void }) {
const removed = item.availability !== 'available';
const body = (() => {
if (removed) return <div className="text-sm text-text-tertiary-4">该内容已失效或被删除</div>;
switch (item.type) {
case 'text':
return (
<p className="m-0 line-clamp-[5] max-h-[88px] overflow-hidden text-sm leading-[1.6] text-text-body">
{item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : '')}
</p>
);
case 'image':
case 'video': {
const data = favoriteEntityData(item.snapshot.content, item.type === 'video' ? 'VD' : 'IM');
// Video's own `val` is the actual video bytes (webm/mp4) -- not paintable as an <img> --
// the paintable poster frame is the separate inline `data.preview` (confirmed live: base64
// JPEG signature); image has no separate poster since the image itself IS the thumbnail.
const inlineVal = typeof (item.type === 'video' ? data?.preview : data?.val) === 'string' ? ((item.type === 'video' ? data?.preview : data?.val) as string) : undefined;
const inlineMime = item.type === 'video' ? 'image/jpeg' : typeof data?.mime === 'string' ? data.mime : undefined;
return (
<MediaPreview
inlineVal={inlineVal}
inlineMime={inlineMime}
fileId={item.snapshot.thumbnail_file_id || item.snapshot.file_id}
isVideo={item.type === 'video'}
/>
);
}
case 'voice': {
// Reuses the same real, working audio player chat bubbles use (AttachmentPreview.tsx) --
// this card used to be static (a bare play_circle icon with no click handler at all), so a
// favorited voice message could never actually be played from this view.
const data = favoriteEntityData(item.snapshot.content, 'AU');
return (
// Stops the play/pause click from also bubbling up to the card's own onClick -- in
// selectMode that outer handler toggles selection, and without this, tapping play would
// simultaneously start/stop audio AND select/deselect the card.
<div className="rounded-lg bg-surface-rail px-3 py-2.5" onClick={(e) => e.stopPropagation()}>
<AudioAttachment
data={{
val: typeof data?.val === 'string' ? data.val : undefined,
ref: typeof data?.ref === 'string' ? data.ref : item.snapshot.file_id ? `/v0/file/s/${item.snapshot.file_id}` : undefined,
mime: typeof data?.mime === 'string' ? data.mime : undefined,
duration: typeof data?.duration === 'number' ? data.duration : item.snapshot.duration_ms,
}}
/>
</div>
);
}
case 'file': {
const data = favoriteEntityData(item.snapshot.content, 'EX');
const name = (typeof data?.name === 'string' ? data.name : undefined) || item.snapshot.file?.name || item.snapshot.name || '';
const size = (typeof data?.size === 'number' ? data.size : undefined) ?? item.snapshot.file?.size ?? item.snapshot.size ?? 0;
const mime = (typeof data?.mime === 'string' ? data.mime : undefined) || item.snapshot.file?.mime || item.snapshot.mime;
const style = fileTypeStyle(name, mime);
return (
<div className="flex items-center gap-3">
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{name}</span>
<span className="text-xs text-text-tertiary-2">{formatFileSize(size)}</span>
</span>
</div>
);
}
case 'link': {
// Unlike image/video/voice/file, a Drafty `LN` entity annotates a RANGE of the message's own
// text with a hyperlink (see drafty.tsx's own LN case) -- it has no separate title/thumbnail
// data of its own the way a standalone attachment entity does. So a "link" favorite's real
// snapshot is most likely just the SAME whole-message wrapper every other type turned out to
// be (Part D's finding), meaning its own top-level `text` field (identical source the 'text'
// case above already reads) is the one thing actually guaranteed to be there -- the flat
// `title`/`url`/`thumbnail_file_id` fields below have never been confirmed against real data
// and are kept only as an optional enhancement layer, not the sole source. Without this
// fallback, a real link favorite whose backend response doesn't happen to populate those flat
// fields rendered a card with a completely empty title line.
const text = item.snapshot.text || (typeof item.snapshot.content === 'string' ? item.snapshot.content : '');
const title = item.snapshot.title || item.snapshot.url || text;
// Only show a second line when it says something the title line doesn't already -- a
// hostname alongside a real title/url is useful, but repeating `text` under itself when
// that's ALSO what the title line fell back to would just be the same line twice.
const subtitle = item.snapshot.url ? new URL(item.snapshot.url).hostname : title === text ? '' : text;
return (
<div className="flex items-start gap-3">
{item.snapshot.thumbnail_file_id ? (
<span className="h-14 w-14 flex-none overflow-hidden rounded-lg bg-surface-rail">
<MediaPreview fileId={item.snapshot.thumbnail_file_id} isVideo={false} square />
</span>
) : (
<span className="grid h-14 w-14 flex-none place-items-center rounded-lg bg-surface-rail">
<Icon name="link" className="text-[20px] text-text-tertiary-3" />
</span>
)}
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{title}</span>
{subtitle ? <span className="truncate text-xs text-text-tertiary-2">{subtitle}</span> : null}
</span>
</div>
);
}
default:
return null;
}
})();
// A plain <div> (not <button>) -- the voice card nests AudioAttachment's own real <button> inside
// this, and a <button> can't legally contain another <button> (React logs a hydration-nesting
// error for it, confirmed live). role/tabIndex/onKeyDown restore button semantics only while this
// is actually clickable (selectMode); outside selectMode it's inert, same as before.
return (
<div
role={selectMode ? 'button' : undefined}
tabIndex={selectMode ? 0 : undefined}
onClick={selectMode ? onToggle : undefined}
onKeyDown={
selectMode
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
}
}
: undefined
}
className={`flex flex-col gap-3 rounded-xl border-2 bg-white p-4 text-left ${selected ? 'border-accent' : 'border-transparent'}`}
>
<div className="relative">
{body}
{selectMode ? (
<span
className={`absolute -right-1 -top-1 grid h-5 w-5 place-items-center rounded-full text-[13px] text-white ${
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2] bg-white'
}`}
>
{selected ? <Icon name="check" className="text-[14px]" /> : null}
</span>
) : null}
</div>
<div className="flex items-center justify-between border-t border-border-list-item pt-2.5 text-xs text-text-tertiary-3">
<SourceLine source={item.source} />
<span className="flex-none">{formatEventTimestamp(new Date(item.collected_at))}</span>
</div>
</div>
);
}
function MediaPreview({
fileId,
inlineVal,
inlineMime,
isVideo,
square,
}: {
fileId?: string;
inlineVal?: string;
inlineMime?: string;
isVideo: boolean;
square?: boolean;
}) {
// Real favorites carry the thumbnail inline as base64 (see favorites.ts's `Favorite` doc comment);
// `fileId` is kept as a fallback for a hypothetical external-ref-only variant, never observed live.
const inline = inlineVal ? `data:${inlineMime || 'image/jpeg'};base64,${inlineVal}` : undefined;
const remote = useAuthedBlobUrl(inline ? undefined : fileId ? `/v0/file/s/${fileId}` : undefined);
const src = inline || remote.url;
return (
<div className={`relative overflow-hidden rounded-lg bg-surface-rail ${square ? 'h-14 w-14' : 'h-[120px] w-full'}`}>
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center">
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
</div>
)}
{isVideo && src ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
</div>
);
}
/**
* README §6.1 (3-column: Nav / 分类 260px / 内容). `favoriteMessages` (the create side, called from
* a message's context menu) lives in messages.ts; this is the read/manage side, new in this pass.
*/
export function FavoritesLayout() {
const queryClient = useQueryClient();
const [category, setCategory] = useState<FavoriteType | 'all'>('all');
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
const [selectMode, setSelectMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [forwarding, setForwarding] = useState(false);
const [error, setError] = useState('');
const favoritesQuery = useInfiniteQuery({
queryKey: ['favorites', category, debouncedQuery],
queryFn: ({ pageParam }) => getFavorites(category, debouncedQuery, pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const items = favoritesQuery.data?.pages.flatMap((p) => p.items) ?? [];
const invalidateAll = () => queryClient.invalidateQueries({ queryKey: ['favorites'] });
const deleteMutation = useMutation({
// Both API shapes are declared with an optional `failed[]` (favorites.ts:92) -- a 200 here only
// means the request was accepted, not that every selected item was actually deleted. Returning
// the real response (was previously discarded entirely) so onSuccess can check it, same pattern
// already fixed for BlocklistView's batch-unblock/GroupAdminModal elsewhere in this app.
mutationFn: async (ids: string[]) => {
if (ids.length === 1) {
await deleteFavorite(ids[0]);
return null;
}
return batchDeleteFavorites(ids);
},
onSuccess: (data) => {
invalidateAll();
exitSelectMode();
if (data?.failed?.length) setError(`${data.failed.length} 项删除失败,请重试`);
},
onError: (err) => setError(err instanceof Error ? err.message : '删除失败'),
});
const forwardMutation = useMutation({
// forwardFavorites already accepts multiple target_topics + a comment natively -- this used to
// wrap a single topic in a 1-element array, silently discarding the picker's ability to select
// several recipients or attach a comment.
mutationFn: ({ targetTopics, comment }: { targetTopics: string[]; comment: string }) =>
forwardFavorites([...selected], targetTopics, comment),
onSuccess: (data) => {
setForwarding(false);
exitSelectMode();
// Same `failed[]` gap as deleteMutation above (favorites.ts:101) -- forwarding to 3 targets can
// succeed for 2 and fail for 1, and silently reporting "success" either way hides that.
if (data.failed?.length) setError(`${data.failed.length} 项转发失败,请重试`);
},
onError: (err) => setError(err instanceof Error ? err.message : '转发失败'),
});
// Switching category mid-multi-select used to leave the OLD category's selections in `selected`
// silently intact (nothing in this state ever cleared them) -- the header's "已选择 N 项" count and
// any 转发/删除 action would then silently include items the user can no longer even see. Category
// switches always start a fresh selection instead.
const switchCategory = (key: FavoriteType | 'all') => {
setCategory(key);
setSelected(new Set());
};
// Same reasoning as switchCategory above, for the OTHER thing that changes which items are even
// visible -- typing a search query re-filters the list just like picking a category does, and had
// the identical gap (nothing here ever cleared `selected` when the query changed).
const handleQueryChange = (value: string) => {
setQuery(value);
setSelected(new Set());
};
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const exitSelectMode = () => {
setSelectMode(false);
setSelected(new Set());
};
const bulkDelete = () => {
if (selected.size === 0) return;
if (!window.confirm(`删除已选择的 ${selected.size} 项收藏?`)) return;
deleteMutation.mutate([...selected]);
};
const categoryPicker = (
<div className="flex gap-2 overflow-x-auto px-4 pb-3">
{CATEGORIES.map((c) => (
<button
key={c.key}
type="button"
onClick={() => switchCategory(c.key)}
className={`flex-none whitespace-nowrap rounded-2xl px-3.5 py-1.5 text-[13px] ${
category === c.key ? 'bg-accent text-white' : 'bg-[#f1f2f4] text-text-body'
}`}
>
{c.label}
</button>
))}
</div>
);
const searchBox = (
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
placeholder="搜索收藏内容"
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
/>
</div>
);
return (
<div className="flex h-full w-full">
{/* README §6.1's 3-column desktop layout (nav / 260px category rail / content). Below `md`
there's no room to dock a 260px rail next to content at all -- screens/05-favorites-
history.dc.html's own mobile mock (#1d) doesn't try to: it's a SINGLE screen with a
horizontal pill category-switcher inline above the list, not a separate "pick a category"
screen you navigate away from. This aside is desktop-only; its mobile equivalent is the
pill row rendered inline in the content column below. */}
<aside className="hidden h-full w-[260px] flex-none flex-col border-r border-border-panel bg-white md:flex">
<header className="flex h-15 flex-none items-center px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">我的收藏</h1>
</header>
<div className="mx-3 mb-2.5">{searchBox}</div>
<div className="flex flex-col gap-0.5 px-3">
{CATEGORIES.map((c) => (
<button
key={c.key}
type="button"
onClick={() => switchCategory(c.key)}
className={`flex items-center gap-2.5 rounded-lg px-3 py-2.5 text-sm ${
category === c.key ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
}`}
>
<Icon name={c.icon} className="text-[18px]" />
<span className="flex-1 text-left">{c.label}</span>
</button>
))}
</div>
</aside>
<div className="flex h-full min-w-0 flex-1 flex-col bg-surface-content">
{selectMode ? (
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-4 md:px-6">
<span className="text-sm text-text-secondary">
已选择 <strong className="font-semibold text-accent">{selected.size}</strong> 项
</span>
<span className="flex items-center gap-2">
<button
type="button"
onClick={() => setForwarding(true)}
disabled={selected.size === 0}
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-[#d5d9df] px-3.5 text-[13px] text-text-tertiary-1 disabled:opacity-40"
>
<Icon name="reply" className="text-[18px]" />
转发
</button>
<button
type="button"
onClick={bulkDelete}
disabled={selected.size === 0}
className="flex h-8.5 items-center gap-1.5 whitespace-nowrap rounded-md border border-danger-border-soft px-3.5 text-[13px] text-danger-text disabled:opacity-40"
>
<Icon name="delete_outline" className="text-[18px]" />
删除
</button>
<button type="button" onClick={exitSelectMode} className="grid h-8.5 place-items-center px-3.5 text-[13px] text-text-tertiary-1">
取消
</button>
</span>
</header>
) : (
<>
{/* Mobile-only header -- `/favorites` is only reachable on a narrow viewport via
设置(我的)>我的收藏 (SettingsLayout.tsx), so "back" returns there, matching every
other narrow-only-reachable-via-parent-list page's own back-arrow convention. */}
<header className="grid h-15 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-panel bg-white px-1 md:hidden">
<Link to="/settings" className="grid h-9 w-9 place-items-center text-text-heading">
<Icon name="arrow_back_ios_new" className="text-[18px]" />
</Link>
<strong className="truncate text-center text-base font-semibold text-text-heading">我的收藏</strong>
{items.length ? (
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
多选
</button>
) : (
<span />
)}
</header>
<div className="flex-none border-b border-border-panel pt-3 md:hidden">
<div className="px-4">{searchBox}</div>
{categoryPicker}
</div>
<header className="hidden h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6 md:flex">
<strong className="text-base font-semibold text-text-heading">{CATEGORIES.find((c) => c.key === category)?.label}</strong>
{items.length ? (
<button type="button" onClick={() => setSelectMode(true)} className="text-sm text-accent">
多选
</button>
) : null}
</header>
</>
)}
<div className="flex-1 overflow-y-auto p-4 md:p-6">
{favoritesQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : favoritesQuery.isError ? (
<div className="flex flex-col items-center justify-center gap-2 py-20 text-danger-text">
<Icon name="error_outline" className="!text-4xl" />
<span className="text-sm">加载失败,请重试</span>
<button type="button" onClick={() => favoritesQuery.refetch()} className="text-sm text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-20 text-text-tertiary-4">
<Icon name="inventory_2" className="!text-4xl" />
<span className="text-sm">暂无收藏内容</span>
</div>
) : (
<>
{/* 1 column under 640px (matches the mobile mock's plain vertical card list exactly),
3 at md+ (matches the desktop mock's 3-column grid); 2 in between is this app's own
judgment call, not in either mock, for the range where 1 column would waste width
and 3 would be as cramped as the bug this replaces. */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 md:gap-4">
{items.map((item) => (
<FavoriteCard
key={item.favorite_id}
item={item}
selectMode={selectMode}
selected={selected.has(item.favorite_id)}
onToggle={() => toggle(item.favorite_id)}
/>
))}
</div>
{favoritesQuery.hasNextPage ? (
<div className="pt-4 text-center">
<button
type="button"
onClick={() => favoritesQuery.fetchNextPage()}
disabled={favoritesQuery.isFetchingNextPage}
className="text-sm text-accent disabled:opacity-60"
>
{favoritesQuery.isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
</div>
) : null}
</>
)}
</div>
</div>
{forwarding ? (
<ForwardPickerModal
onClose={() => setForwarding(false)}
onPick={(targetTopics, comment) => forwardMutation.mutate({ targetTopics, comment })}
/>
) : null}
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
@@ -0,0 +1,198 @@
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends';
import { createGroup, getGroupGovernance, updateGroupGovernance } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { MAX_TITLE_LENGTH } from '@/lib/config';
import { useEscapeKey } from '@/lib/hooks';
interface CreateGroupModalProps {
onClose: () => void;
onCreated: (topic: string) => void;
}
/**
* README §4.7 (680x520): letter-grouped friend picker on the left, selected chips + group-name
* input on the right. Grouped by the first character of the display name -- a true pinyin-initial
* grouping (matching the reference's A/B/C-style index) would need a pinyin library this codebase
* doesn't have anywhere else; first-character buckets are a reasonable stand-in and already correct
* for the plain-ASCII usernames this backend's test accounts actually have.
*/
export function CreateGroupModal({ onClose, onCreated }: CreateGroupModalProps) {
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<Map<string, string>>(new Map());
const [name, setName] = useState('');
const [joinApproval, setJoinApproval] = useState(false);
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
useEscapeKey(onClose);
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
const friends = friendsQuery.data || [];
const groups = useMemo(() => {
const trimmed = query.trim().toLowerCase();
const filtered = friends.filter((f) => !trimmed || (f.remark || f.display_name || f.user_id).toLowerCase().includes(trimmed));
const byLetter = new Map<string, typeof friends>();
for (const f of [...filtered].sort((a, b) => (a.remark || a.display_name || a.user_id).localeCompare(b.remark || b.display_name || b.user_id))) {
const label = f.remark || f.display_name || f.user_id;
const letter = /[A-Za-z]/.test(label[0] || '') ? label[0].toUpperCase() : '#';
if (!byLetter.has(letter)) byLetter.set(letter, []);
byLetter.get(letter)!.push(f);
}
return [...byLetter.entries()].sort(([a], [b]) => a.localeCompare(b));
}, [friends, query]);
const toggle = (userId: string, label: string) => {
setSelected((prev) => {
const next = new Map(prev);
if (next.has(userId)) next.delete(userId);
else next.set(userId, label);
return next;
});
};
const submit = async () => {
if (selected.size === 0 || submitting) return;
setSubmitting(true);
setError('');
let result: Awaited<ReturnType<typeof createGroup>>;
try {
result = await createGroup({
name: name.trim(),
member_user_ids: [...selected.keys()],
});
} catch (err) {
setError(err instanceof Error ? err.message : '创建群聊失败');
setSubmitting(false);
return;
}
// The group now genuinely exists -- from here on, a failure is NOT "创建群聊失败" (which would
// read as "nothing happened, try again" and could prompt a duplicate re-submit). Kept in its own
// try/catch so the 进群验证 follow-up call failing can't get blamed on group creation itself.
if (joinApproval) {
try {
// README §4.5's 进群验证 governance toggle defaults off; a group created with "开启进群验证"
// checked needs it turned on right after creation since createGroup's own body has no field
// for it (docs/IM-前后端对接文档.md:1445's create body is name/description/member_user_ids/
// avatar_file_id only -- governance is a separate resource).
const gov = await getGroupGovernance(result.topic);
await updateGroupGovernance(result.topic, { join_approval: true, version: gov.version });
} catch {
// Non-fatal: the group is real and usable, just without 进群验证 turned on yet -- that's a
// setting the owner can still flip from 群管理 afterward, not a reason to hide a real group.
}
}
setSubmitting(false);
onCreated(result.topic);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[85vh] w-full flex-col rounded-xl bg-white shadow-modal md:h-[520px] md:w-[680px]" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">创建群聊</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex flex-1 flex-col overflow-hidden md:flex-row">
<div className="flex w-full flex-1 flex-col border-b border-border-list-item md:w-[340px] md:flex-none md:border-b-0 md:border-r">
<div className="flex-none p-3">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索联系人"
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 pb-2">
{friendsQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : groups.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">未找到联系人</div>
) : (
groups.map(([letter, items]) => (
<div key={letter}>
<div className="px-2 pb-1 pt-2 text-xs text-text-tertiary-3">{letter}</div>
{items.map((f) => {
const label = f.remark || f.display_name || f.user_id;
const checked = selected.has(f.user_id);
return (
<button
key={f.user_id}
type="button"
onClick={() => toggle(f.user_id, label)}
className="flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-surface-rail"
>
<span
className={`grid h-5 w-5 flex-none place-items-center rounded-full ${
checked ? 'bg-accent text-white' : 'border-[1.5px] border-[#c5cad2]'
}`}
>
{checked ? <Icon name="check" className="text-[13px]" /> : null}
</span>
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}
>
{avatarLetter(label)}
</span>
<span className="truncate text-sm text-text-body">{label}</span>
</button>
);
})}
</div>
))
)}
</div>
</div>
<div className="flex flex-1 flex-col p-4">
<div className="flex-none text-sm text-text-secondary">已选 {selected.size} 人</div>
<div className="mt-2 flex flex-wrap content-start gap-2 overflow-y-auto" style={{ maxHeight: 220 }}>
{[...selected.entries()].map(([userId, label]) => (
<span key={userId} className="flex items-center gap-1 rounded-2xl bg-surface-rail py-1 pl-1 pr-2">
<span className={`grid h-[22px] w-[22px] place-items-center rounded-full text-[11px] text-white ${avatarBgClass(userId)}`}>
{avatarLetter(label)}
</span>
<span className="max-w-[100px] truncate text-xs text-text-body">{label}</span>
<button type="button" onClick={() => toggle(userId, label)} className="grid h-4 w-4 place-items-center text-text-tertiary-3">
<Icon name="close" className="text-[14px]" />
</button>
</span>
))}
</div>
<div className="mt-auto flex flex-col gap-3">
{error ? <div className="text-xs text-danger-text">{error}</div> : null}
<input
value={name}
onChange={(e) => setName(e.target.value.slice(0, MAX_TITLE_LENGTH))}
placeholder="未填写时默认为成员昵称组合"
className="h-10 rounded-lg bg-surface-input px-3 text-sm outline-none placeholder:text-text-placeholder-1"
/>
<label className="flex items-center gap-2 text-sm text-text-secondary">
<input type="checkbox" checked={joinApproval} onChange={(e) => setJoinApproval(e.target.checked)} className="accent-accent" />
开启进群验证
</label>
<button
type="button"
onClick={submit}
disabled={selected.size === 0 || submitting}
className="h-10 rounded-lg bg-btn text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
>
{submitting ? '创建中…' : `创建群聊 (${selected.size})`}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
+490
View File
@@ -0,0 +1,490 @@
import { useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
import {
addGroupAdmins,
getAllGroupMembers,
getGroup,
getGroupGovernance,
getGroupMutes,
getGroupRoleLimits,
getGroupSummary,
muteGroupMembers,
removeGroupAdmin,
transferGroupOwner,
unmuteGroupMember,
updateGroupGovernance,
type GroupMember,
type GroupMute,
} from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { useSessionStore } from '@/stores/session';
interface GroupAdminModalProps {
topicId: string;
onClose: () => void;
}
function SegmentedControl<T extends string>({
value,
options,
onChange,
disabled,
}: {
value: T;
options: { value: T; label: string }[];
onChange: (v: T) => void;
disabled?: boolean;
}) {
return (
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
{options.map((opt) => (
<button
key={opt.value}
type="button"
disabled={disabled}
onClick={() => onChange(opt.value)}
className={`px-3 py-1 disabled:opacity-50 ${value === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
>
{opt.label}
</button>
))}
</div>
);
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
className={`relative h-[22px] w-10 flex-none rounded-full transition-colors disabled:opacity-50 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
>
<span className={`absolute left-0.5 top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[18px]' : ''}`} />
</button>
);
}
type SubView = 'main' | 'admins' | 'mutes' | 'join-requests' | 'transfer';
/** README §4.5 (560px): governance toggles/segments + admins/mutes/join-requests/owner-transfer link
* rows. `addGroupAdmins`/`removeGroupAdmin`/`transferGroupOwner` all share ONE optimistic-lock
* counter with the group profile itself -- confirmed by the doc's own worked example (§6.29):
* admins/batch takes version:1 and returns version:2, then owner-transfer takes version:2 and
* returns version:3. So the current value always comes from `getGroup`, re-read (via query
* invalidation) after every such mutation -- a hardcoded constant would only be correct once. */
export function GroupAdminModal({ topicId, onClose }: GroupAdminModalProps) {
const queryClient = useQueryClient();
const myUserId = useSessionStore((s) => s.myUserId);
const [view, setView] = useState<SubView>('main');
const [error, setError] = useState('');
useEscapeKey(onClose);
const groupQuery = useQuery({ queryKey: ['group', topicId], queryFn: () => getGroup(topicId) });
// README §4.5: 转让群主仅群主可见 -- `GroupPermissions` has no dedicated `can_transfer_ownership`
// field, but `GroupProfile.owner` (the real owner's user id) is already fetched here for the
// shared version counter, so comparing it against the session's own id is free, no extra request.
const isOwner = !!myUserId && groupQuery.data?.owner === myUserId;
const govQuery = useQuery({ queryKey: ['group-governance', topicId], queryFn: () => getGroupGovernance(topicId) });
const summaryQuery = useQuery({ queryKey: ['group-summary', topicId], queryFn: () => getGroupSummary(topicId) });
const roleLimitsQuery = useQuery({ queryKey: ['group-role-limits', topicId], queryFn: () => getGroupRoleLimits(topicId) });
// Confirmed against the real backend (im_be/server/im_http.go's serveIMGroupAdmins): the entire
// `/admins*` surface -- list included, not just add/remove -- requires OWNER, a plain admin gets
// 403 even just listing. `enabled: isOwner` stops a non-owner admin from firing a request that can
// only ever fail, and pairs with the `isOwner` gate on the "群管理员" row itself below.
const adminsQuery = useQuery({
queryKey: ['group-members', topicId, 'admin'],
queryFn: () => getAllGroupMembers(topicId, { role: 'admin' }),
enabled: isOwner,
});
const mutesQuery = useQuery({ queryKey: ['group-mutes', topicId], queryFn: () => getGroupMutes(topicId) });
const govMutation = useMutation({
mutationFn: (patch: Parameters<typeof updateGroupGovernance>[1]) => updateGroupGovernance(topicId, patch),
onSuccess: (data) => queryClient.setQueryData(['group-governance', topicId], data),
onError: (err) => setError(err instanceof Error ? err.message : '设置失败'),
});
const setGov = (
patch: Partial<{ join_approval: boolean; mute_all: boolean; member_upload_files: boolean; member_private_chat: boolean; member_invite: boolean }>,
) => {
if (!govQuery.data) return;
govMutation.mutate({ ...patch, version: govQuery.data.version });
};
const maxAdminCount = roleLimitsQuery.data?.max_admin_count;
if (view === 'join-requests') return <JoinRequestsModal topicId={topicId} onClose={() => setView('main')} />;
if (view === 'admins')
return (
<AdminsSubView
topicId={topicId}
admins={adminsQuery.data || []}
maxAdminCount={maxAdminCount}
groupVersion={groupQuery.data?.version}
onClose={() => setView('main')}
/>
);
if (view === 'mutes') return <MutesSubView topicId={topicId} muted={mutesQuery.data?.items || []} onClose={() => setView('main')} />;
if (view === 'transfer') return <TransferOwnerSubView topicId={topicId} groupVersion={groupQuery.data?.version} onClose={() => setView('main')} />;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex max-h-[80vh] w-[560px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">群管理</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-6 py-2 text-sm">
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
<div className="flex flex-col">
<span className="text-text-body">进群验证</span>
<span className="text-xs text-text-tertiary-3">开启后新成员需管理员审核</span>
</div>
<Toggle
checked={!!govQuery.data?.join_approval}
disabled={!govQuery.data || govMutation.isPending}
onChange={() => setGov({ join_approval: !govQuery.data?.join_approval })}
/>
</div>
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
<div className="flex flex-col">
<span className="text-text-body">全员禁言</span>
<span className="text-xs text-text-tertiary-3">仅群主和管理员可以发言</span>
</div>
<Toggle
checked={!!govQuery.data?.mute_all}
disabled={!govQuery.data || govMutation.isPending}
onChange={() => setGov({ mute_all: !govQuery.data?.mute_all })}
/>
</div>
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
<span className="text-text-body">群文件上传权限</span>
<SegmentedControl
value={govQuery.data?.member_upload_files ? 'all' : 'admin'}
options={[
{ value: 'all', label: '所有成员' },
{ value: 'admin', label: '仅管理员' },
]}
onChange={(v) => setGov({ member_upload_files: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending}
/>
</div>
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
<span className="text-text-body">成员私聊权限</span>
<SegmentedControl
value={govQuery.data?.member_private_chat ? 'all' : 'friends'}
options={[
{ value: 'all', label: '允许' },
{ value: 'friends', label: '仅好友' },
]}
onChange={(v) => setGov({ member_private_chat: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending}
/>
</div>
{/* `governance.member_invite`/`member_invite_editable` (docs/IM-前后端对接文档.md:1600) is a
real, already-read/write-capable field with no row anywhere in 03-group.dc.html's own
群管理弹层 mock (#1b only draws 4 governance rows) -- placed here as a 5th row using the
SAME SegmentedControl treatment as 群文件上传权限/成员私聊权限 just above (same modal,
same "who is allowed to do X" semantics), rather than inventing a new visual pattern. */}
<div className="flex items-center justify-between border-b border-border-list-item py-3.5">
<span className="text-text-body">邀请入群权限</span>
<SegmentedControl
value={govQuery.data?.member_invite ? 'all' : 'admin'}
options={[
{ value: 'all', label: '所有成员' },
{ value: 'admin', label: '仅管理员' },
]}
onChange={(v) => setGov({ member_invite: v === 'all' })}
disabled={!govQuery.data || govMutation.isPending}
/>
</div>
{/* Owner-only, same as 转让群主 below -- the backend's own permission check for this
entire sub-resource (list included) requires ownership, not just admin. */}
{isOwner ? (
<button type="button" onClick={() => setView('admins')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
<span className="text-text-body">群管理员</span>
<span className="flex items-center gap-1 text-text-tertiary-2">
<span className="text-xs">
{adminsQuery.data?.length ?? 0}
{maxAdminCount ? `/${maxAdminCount}` : ''}
</span>
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
</span>
</button>
) : null}
<button type="button" onClick={() => setView('mutes')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
<span className="text-text-body">禁言成员</span>
<span className="flex items-center gap-1 text-text-tertiary-2">
<span className="text-xs">{mutesQuery.data?.items.length ?? 0} 人</span>
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
</span>
</button>
<button type="button" onClick={() => setView('join-requests')} className="flex w-full items-center justify-between border-b border-border-list-item py-3.5 text-left">
<span className="text-text-body">进群申请</span>
<span className="flex items-center gap-1">
{summaryQuery.data?.pending_join_request_count ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{summaryQuery.data.pending_join_request_count}
</b>
) : null}
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
</span>
</button>
{isOwner ? (
<button type="button" onClick={() => setView('transfer')} className="flex w-full items-center justify-between py-3.5 text-left">
<span className="text-text-body">转让群主</span>
<Icon name="chevron_right" className="text-[18px] text-icon-arrow" />
</button>
) : null}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
</div>
);
}
/** Shared candidate list for the admin/mute/owner-transfer pickers. All three server endpoints
* follow the same "候选" shape (doc §6.19/6.23/6.25): `GET .../members` with an `eligible_role=` or
* `mute_eligible=` filter, whose response items still carry `eligible` for entries the caller can't
* act on -- so this filters those out client-side rather than trusting the query param alone to
* have excluded them server-side. */
function MemberPickerList({
topicId,
cacheKey,
opts,
onPick,
emptyLabel,
disabled,
}: {
topicId: string;
cacheKey: string;
opts: Parameters<typeof getAllGroupMembers>[1];
onPick: (m: GroupMember) => void;
emptyLabel: string;
disabled?: boolean;
}) {
const membersQuery = useQuery({ queryKey: ['group-members', topicId, cacheKey], queryFn: () => getAllGroupMembers(topicId, opts) });
const candidates = (membersQuery.data || []).filter((m) => m.eligible !== false);
if (membersQuery.isLoading) return <div className="py-6 text-center text-sm text-text-tertiary-4">加载中…</div>;
if (candidates.length === 0) return <div className="py-6 text-center text-sm text-text-tertiary-4">{emptyLabel}</div>;
return (
<>
{candidates.map((m) => (
<button
key={m.user_id}
type="button"
onClick={() => onPick(m)}
disabled={disabled}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:opacity-50"
>
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
</span>
<span className="truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
</button>
))}
</>
);
}
function SubModalShell({ title, onClose, children }: { title: string; onClose: () => void; children: ReactNode }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[480px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center gap-2 border-b border-border-list-item px-3">
<button type="button" onClick={onClose} className="grid h-8 w-8 flex-none place-items-center text-text-tertiary-4">
<Icon name="arrow_back" />
</button>
<strong className="text-[15px] font-semibold text-text-heading">{title}</strong>
</header>
<div className="flex-1 overflow-y-auto px-2 py-2">{children}</div>
</div>
</div>
);
}
function AdminsSubView({
topicId,
admins,
maxAdminCount,
groupVersion,
onClose,
}: {
topicId: string;
admins: GroupMember[];
maxAdminCount: number | undefined;
groupVersion: number | undefined;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [adding, setAdding] = useState(false);
const [error, setError] = useState('');
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
// `group-members-preview` is a SEPARATE cache key (GroupDetailColumn.tsx's own 9-grid query) --
// missing it here meant promoting/demoting an admin never refreshed that grid's own "主"/"管"
// corner badges until something else happened to invalidate it.
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-role-limits', topicId] });
};
const addMutation = useMutation({
mutationFn: (userId: string) => addGroupAdmins(topicId, [userId], groupVersion ?? 1),
onSuccess: () => {
invalidate();
setAdding(false);
},
onError: (err) => setError(err instanceof Error ? err.message : '添加失败'),
});
const removeMutation = useMutation({
mutationFn: (userId: string) => removeGroupAdmin(topicId, userId, groupVersion ?? 1),
onSuccess: invalidate,
onError: (err) => setError(err instanceof Error ? err.message : '移除失败'),
});
const countLabel = maxAdminCount ? `${admins.length}/${maxAdminCount}` : `${admins.length}`;
useEscapeKey(onClose);
return (
<SubModalShell title={adding ? '添加管理员' : `群管理员 ${countLabel}`} onClose={adding ? () => setAdding(false) : onClose}>
{adding ? (
<MemberPickerList
topicId={topicId}
cacheKey="eligible-admin"
opts={{ eligibleRole: 'admin' }}
onPick={(m) => addMutation.mutate(m.user_id)}
emptyLabel="没有可添加的成员"
disabled={addMutation.isPending}
/>
) : (
<>
{admins.map((a) => (
<div key={a.user_id} className="flex items-center gap-3 px-3 py-2">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(a.user_id)}`}>
{avatarLetter(a.display_name)}
</span>
<span className="flex-1 truncate text-sm text-text-body">{a.display_name || a.user_id}</span>
<button
type="button"
onClick={() => removeMutation.mutate(a.user_id)}
disabled={removeMutation.isPending}
className="text-xs text-danger-text disabled:opacity-50"
>
移除
</button>
</div>
))}
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
<Icon name="add" className="text-[18px]" />
添加管理员
</button>
</>
)}
<Toast message={error} variant="error" onDone={() => setError('')} />
</SubModalShell>
);
}
function MutesSubView({ topicId, muted, onClose }: { topicId: string; muted: GroupMute[]; onClose: () => void }) {
const queryClient = useQueryClient();
const [adding, setAdding] = useState(false);
const [error, setError] = useState('');
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['group-mutes', topicId] });
const muteMutation = useMutation({
mutationFn: (userId: string) => muteGroupMembers(topicId, [{ user_id: userId, expires_at: null }]),
onSuccess: () => {
invalidate();
setAdding(false);
},
onError: (err) => setError(err instanceof Error ? err.message : '禁言失败'),
});
const unmuteMutation = useMutation({
mutationFn: (userId: string) => unmuteGroupMember(topicId, userId),
onSuccess: invalidate,
onError: (err) => setError(err instanceof Error ? err.message : '解除禁言失败'),
});
useEscapeKey(onClose);
return (
<SubModalShell title={adding ? '选择禁言成员' : `禁言成员 ${muted.length}`} onClose={adding ? () => setAdding(false) : onClose}>
{adding ? (
<MemberPickerList
topicId={topicId}
cacheKey="mute-eligible"
opts={{ muteEligible: true }}
onPick={(m) => muteMutation.mutate(m.user_id)}
emptyLabel="没有可禁言的成员"
disabled={muteMutation.isPending}
/>
) : (
<>
{muted.map((m) => (
<div key={m.user.user_id} className="flex items-center gap-3 px-3 py-2">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user.user_id)}`}>
{avatarLetter(m.user.display_name)}
</span>
<span className="flex-1 truncate text-sm text-text-body">{m.user.display_name || m.user.user_id}</span>
<button
type="button"
onClick={() => unmuteMutation.mutate(m.user.user_id)}
disabled={unmuteMutation.isPending}
className="text-xs text-accent disabled:opacity-50"
>
解除禁言
</button>
</div>
))}
<button type="button" onClick={() => setAdding(true)} className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent">
<Icon name="add" className="text-[18px]" />
禁言成员
</button>
</>
)}
<Toast message={error} variant="error" onDone={() => setError('')} />
</SubModalShell>
);
}
function TransferOwnerSubView({ topicId, groupVersion, onClose }: { topicId: string; groupVersion: number | undefined; onClose: () => void }) {
const queryClient = useQueryClient();
const [error, setError] = useState('');
const transferMutation = useMutation({
mutationFn: (userId: string) => transferGroupOwner(topicId, userId, groupVersion ?? 1),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-settings', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
// Same gap as AdminsSubView's own invalidate() -- the 9-grid "主" badge in GroupDetailColumn
// reads this separate cache key, not `group-members`.
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
onClose();
},
onError: (err) => setError(err instanceof Error ? err.message : '转让群主失败'),
});
useEscapeKey(onClose);
return (
<SubModalShell title="转让群主" onClose={onClose}>
<MemberPickerList
topicId={topicId}
cacheKey="eligible-owner"
opts={{ eligibleRole: 'owner' }}
onPick={(m) => {
if (window.confirm(`确定将群主转让给 ${m.display_name || m.user_id} 吗?此操作不可撤销。`)) transferMutation.mutate(m.user_id);
}}
emptyLabel="没有可转让的成员"
disabled={transferMutation.isPending}
/>
<Toast message={error} variant="error" onDone={() => setError('')} />
</SubModalShell>
);
}
@@ -0,0 +1,130 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { getAllGroupMembers, getLatestGroupAnnouncement, updateCurrentGroupAnnouncement, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useEscapeKey } from '@/lib/hooks';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
interface GroupAnnouncementModalProps {
topicId: string;
canEdit: boolean;
onClose: () => void;
}
/**
* README §4.8 (480px): publisher avatar/name/role tag + publish time, body text, read count, and an
* 编辑 affordance for owner/admin. `PUT .../announcements/current` is optimistic-locked by
* `version`, confirmed present in the GET response (doc line 1745, and re-confirmed live). Two
* corrections from real responses (2026-09-08, real group `grpvrdHqKqkCq0`): the publisher comes
* back as a flat `author: "usr..."` id, not the `published_by` object this file originally guessed
* -- resolved here against the group's member list, same as any other bare user id in this API.
* And `read_count`/`member_count` are confirmed ABSENT (not just unconfirmed), so dropped rather
* than kept as permanently-dead optional-chained code. When nothing has been published yet, the
* endpoint still returns `200` with every field zero-valued, never `404`/`null` -- checked below
* via `content` emptiness, not object existence (a real bug this file had before this fix: `!query.
* data` was never true, so it rendered a blank "announcement" -- empty name, empty date -- instead
* of the empty state).
*/
export function GroupAnnouncementModal({ topicId, canEdit, onClose }: GroupAnnouncementModalProps) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const [error, setError] = useState('');
useEscapeKey(onClose);
const query = useQuery({ queryKey: ['group-announcement', topicId], queryFn: () => getLatestGroupAnnouncement(topicId) });
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const author = membersQuery.data?.find((m) => m.user_id === query.data?.author);
const saveMutation = useMutation({
mutationFn: (content: string) => updateCurrentGroupAnnouncement(topicId, content, query.data?.version ?? 0),
onSuccess: (data) => {
queryClient.setQueryData(['group-announcement', topicId], data);
// `GET .../settings` genuinely has no `announcement` field (see GroupDetailColumn.tsx's own
// comment on this) -- the detail rail's own announcement preview actually reads
// `group-summary`, so invalidating `group-settings` here never refreshed it; the preview text
// was stuck at whatever it was before this save until something ELSE happened to touch summary.
queryClient.invalidateQueries({ queryKey: ['group-summary', topicId] });
setEditing(false);
},
onError: (err) => setError(err instanceof Error ? err.message : '发布失败'),
});
const startEdit = () => {
setDraft(query.data?.content || '');
setEditing(true);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex max-h-[70vh] w-[480px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">群公告</strong>
<div className="flex items-center gap-3">
{canEdit && !editing ? (
<button type="button" onClick={startEdit} className="text-sm text-accent">
{query.data?.content ? '编辑' : '发布'}
</button>
) : null}
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto px-5 py-4">
{editing ? (
<div className="flex flex-col gap-3">
{error ? <div className="text-xs text-danger-text">{error}</div> : null}
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
rows={8}
placeholder="发布群公告,所有成员都会收到通知"
className="resize-none rounded-lg bg-surface-input p-3 text-sm outline-none"
/>
<button
type="button"
onClick={() => saveMutation.mutate(draft.trim())}
disabled={!draft.trim() || saveMutation.isPending}
className="h-10 self-end rounded-lg bg-btn px-5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
>
{saveMutation.isPending ? '发布中…' : '发布'}
</button>
</div>
) : query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : !query.data?.content ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群公告</div>
) : (
<>
<div className="flex items-center gap-2">
<span className={`grid h-8 w-8 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(query.data.author)}`}>
{avatarLetter(author?.display_name || query.data.author)}
</span>
<span className="text-sm font-medium text-text-body">{author?.display_name || query.data.author}</span>
{/* README §4.3: border style, no fill (matches MessageBubble.tsx's MessageRoleTag) --
this used to hardcode warn/orange for BOTH owner and admin authors, so an admin's
own announcement showed the wrong (owner) color. */}
{author?.role === 'owner' ? (
<span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">{ROLE_LABEL.owner}</span>
) : author?.role === 'admin' ? (
<span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">{ROLE_LABEL.admin}</span>
) : null}
<span className="text-xs text-text-tertiary-3">发布于 {formatEventTimestamp(new Date(query.data.published_at))}</span>
</div>
<p className="m-0 mt-3 whitespace-pre-wrap text-sm leading-[1.7] text-text-body">{query.data.content}</p>
</>
)}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
</div>
);
}
@@ -0,0 +1,417 @@
import { useLayoutEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
import { GroupAdminModal } from '@/features/groups/GroupAdminModal';
import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
import { GroupFilesModal } from '@/features/groups/GroupFilesModal';
import { GroupMembersModal } from '@/features/groups/GroupMembersModal';
import {
disbandGroup,
getGroup,
getMyGroupProfile,
getGroupMembers,
getGroupSettings,
getGroupSummary,
leaveGroup,
updateGroupMemberSettings,
updateMyGroupNickname,
type GroupMember,
type GroupSettings,
} from '@/lib/api/groups';
import type { MediaType } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useTinode } from '@/lib/tinode/hooks';
import { useSessionStore } from '@/stores/session';
interface GroupDetailColumnProps {
topicId: string;
peerName: string;
onClose?: () => void;
onOpenHistory: (tab: 'text' | MediaType) => void;
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
// README §4.10/#1e: mobile switches are 44×26 (22×22 knob), one size up from desktop's 40×22
// (18×18 knob) -- this used to be the desktop size unconditionally. The travel distance
// happens to stay 18px at both sizes (both track and knob grow by the same 4px, so the gap
// between them is unchanged), so only the track/knob dimensions need a `md:` fork, not the
// translate value.
className={`relative h-[26px] w-11 flex-none rounded-full transition-colors disabled:opacity-50 md:h-[22px] md:w-10 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
>
<span
className={`absolute left-0.5 top-0.5 h-[22px] w-[22px] rounded-full bg-white transition-transform md:h-[18px] md:w-[18px] ${checked ? 'translate-x-[18px]' : ''}`}
/>
</button>
);
}
/** README §4.1's owner/admin corner tags on the 5-col member grid. */
function RoleCorner({ role }: { role: GroupMember['role'] }) {
if (role === 'owner') return <span className="absolute -left-0.5 -top-0.5 rounded bg-warn px-1 text-[9px] text-white">主</span>;
if (role === 'admin') return <span className="absolute -left-0.5 -top-0.5 rounded bg-accent px-1 text-[9px] text-white">管</span>;
return null;
}
/**
* README §4.4's 群聊设置 detail rail (group-owner/admin/member view). Sibling of DetailColumn.tsx
* (the P2P equivalent) -- same layout conventions, but group-specific data source (lib/api/groups.ts,
* almost entirely REST per Ch.6 of the protocol doc, not native Tinode `{set}` like a P2P topic's
* desc/private would use -- confirmed against the old app's actually-shipped group-management-view.jsx).
*/
export function GroupDetailColumn({ topicId, peerName, onClose, onOpenHistory }: GroupDetailColumnProps) {
const navigate = useNavigate();
const queryClient = useQueryClient();
const tinode = useTinode();
const myUserId = useSessionStore((s) => s.myUserId);
const [nicknameEditing, setNicknameEditing] = useState(false);
const [nicknameDraft, setNicknameDraft] = useState('');
const [errorToast, setErrorToast] = useState('');
const [openModal, setOpenModal] = useState<'members' | 'announcement' | 'files' | 'admin' | 'qrcode' | null>(null);
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
// Same long-lived-instance leak this exact pattern already caused twice in DetailColumn.tsx --
// GroupDetailColumn is reused across every group when docked, no `key={topicId}`.
useLayoutEffect(() => {
setNicknameEditing(false);
setNicknameDraft('');
setErrorToast('');
setOpenModal(null);
}, [topicId]);
const groupQuery = useQuery({ queryKey: ['group', topicId], queryFn: () => getGroup(topicId) });
const settingsQuery = useQuery({ queryKey: ['group-settings', topicId], queryFn: () => getGroupSettings(topicId) });
// `GET .../settings`'s real response has no `announcement` field at all (confirmed live,
// 2026-09-08) despite this file originally typing one on GroupSettings -- the summary endpoint
// is the one that actually carries it (also used by ConversationView's notification bar and
// GroupAdminModal's join-request badge, so this is a cache hit more often than not).
const summaryQuery = useQuery({ queryKey: ['group-summary', topicId], queryFn: () => getGroupSummary(topicId) });
const membersQuery = useQuery({ queryKey: ['group-members-preview', topicId], queryFn: () => getGroupMembers(topicId, {}) });
// `GET .../members` never returns `group_nickname` on any item (confirmed live by the old app) --
// only this per-user profile endpoint does, so the nickname row needs its own fetch. Uses the
// dedicated `members/me/profile` route, not getGroupMemberProfile(topic, myUserId) -- see that
// function's own warning for why the generic by-id path 400s on self-lookups.
const myProfileQuery = useQuery({
queryKey: ['group-my-profile', topicId, myUserId],
queryFn: () => getMyGroupProfile(topicId),
enabled: !!myUserId,
});
const myRole = settingsQuery.data?.my_role;
const isOwner = myRole === 'owner';
const isAdmin = myRole === 'admin' || isOwner;
// GroupAdminModal's "邀请入群权限" governance toggle (所有成员/仅管理员) was added without ever
// being read anywhere -- every real gate on the invite affordance stayed hardcoded to
// owner/admin-only, so flipping that toggle to "所有成员" had zero effect on what a plain member
// could actually see. `member_invite` lives on this same `settingsQuery` response already in scope
// (no extra request needed) -- live-verified this reflects the real value (flipped it via
// GroupAdminModal's own PATCH, re-fetched `/settings`, confirmed it changed), not a frozen default.
const canInvite = isAdmin || settingsQuery.data?.governance?.member_invite === true;
// No `version` in the request -- see updateGroupMemberSettings's own doc comment for why sending
// one 400s every single call (this was the actual root cause of "member-settings always 400",
// not an optimistic-lock issue -- confirmed live 2026-09-09 by reading the backend's request
// struct, which doesn't declare a version field at all).
const updateSettings = useMutation({
mutationFn: (patch: { pinned?: boolean; mute_notifications?: boolean }) => updateGroupMemberSettings(topicId, patch),
// The response is the small {pinned, mute_notifications, version, updated_at} shape, not a full
// GroupSettings -- merge instead of replacing, or every other cached field (name/owner/governance/
// member_count/...) would vanish from the settings panel after the first successful toggle.
onSuccess: (data) =>
queryClient.setQueryData(['group-settings', topicId], (prev: GroupSettings | undefined) =>
prev ? { ...prev, pinned: data.pinned, mute_notifications: data.mute_notifications } : prev,
),
onError: (err) => showError(err, '设置失败,请重试'),
});
const nicknameMutation = useMutation({
mutationFn: (nickname: string) => updateMyGroupNickname(topicId, nickname, myProfileQuery.data?.version ?? 1),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group-my-profile', topicId, myUserId] });
setNicknameEditing(false);
},
onError: (err) => showError(err, '昵称保存失败'),
});
const leaveMutation = useMutation({
mutationFn: () => leaveGroup(topicId),
onSuccess: () => navigate('/messages'),
onError: (err) => showError(err, '退出群聊失败'),
});
const disbandMutation = useMutation({
mutationFn: () => disbandGroup(topicId),
onSuccess: () => navigate('/messages'),
onError: (err) => showError(err, '解散群聊失败'),
});
const clearHistory = () => {
if (!window.confirm('确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。')) return;
tinode.getTopic(topicId).delMessagesAll(false);
};
const reportAbuse = () => {
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
};
const startLeaveOrDisband = () => {
if (isOwner) {
if (!window.confirm('解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?')) return;
disbandMutation.mutate();
} else {
if (!window.confirm('确定退出本群聊吗?')) return;
leaveMutation.mutate();
}
};
const members = membersQuery.data?.items || [];
const memberCount = groupQuery.data?.member_count ?? settingsQuery.data?.member_count ?? 0;
return (
<aside className="flex h-full w-full flex-none flex-col overflow-hidden border-l border-border-panel bg-white md:w-[320px] md:overflow-y-auto lg:w-[300px]">
{/* Desktop header (03-group.dc.html #1a): title left, close-X right -- unchanged. */}
<header className="hidden h-15 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
<strong className="text-[15px] font-semibold">群聊设置</strong>
{onClose ? (
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
) : null}
</header>
{/* Mobile header (#1e, "详情栏内容平铺为整页"): back arrow / centered title / empty. This panel
already renders full-screen below 960px (MessagesLayout's own `fixed inset-0 md:hidden`
wrapper around ConversationDetailPanel) -- only its OWN header/content never got restyled
to match until now. `onClose` doubles as "back": same prop the desktop close-X already
calls, which just closes this full-screen overlay -- no separate navigation needed. */}
<header className="grid h-12 flex-none grid-cols-[44px_1fr_44px] items-center border-b border-border-list-item bg-white px-2 md:hidden">
{onClose ? (
<button type="button" onClick={onClose} className="grid h-9 w-9 place-items-center text-text-heading">
<Icon name="arrow_back_ios_new" className="text-[22px]" />
</button>
) : (
<span />
)}
<strong className="text-center text-[17px] font-semibold">群聊设置</strong>
<span />
</header>
{/* Content. Desktop: `md:contents` makes this wrapper (and the "card" wrapper just below it)
disappear as boxes -- their children fall through to be direct flex children of `aside`
exactly as before this change, so desktop stays pixel-identical. Mobile: a real scroll
container (the `aside` above switches to `overflow-hidden`, handing scrolling to this
instead) with the mock's `#f6f7f9` page background and 10px gaps between white "cards".
The card GROUPING genuinely differs from desktop here, confirmed by reading both #1a and
#1e side by side rather than assumed: mobile puts 群管理 with 群公告/群文件/查找聊天记录
(not with 清空聊天记录/投诉举报 like desktop), and mobile's mock has no 我在本群的昵称 row
at all -- kept anyway (see below) since it's a real, working desktop feature with no other
entry point on mobile; silently dropping it would be a functional regression, not a
faithful mobile port. */}
<div className="flex flex-1 flex-col gap-2.5 overflow-y-auto bg-[#f6f7f9] py-2.5 md:contents">
<div className="bg-white md:contents">
<div className="flex flex-col gap-2 px-4 py-4 md:border-b md:border-border-list-item md:px-5">
<div className="flex items-center gap-3">
<span className={`grid h-13 w-13 flex-none place-items-center rounded-xl text-xl font-medium text-white ${avatarBgClass(topicId)}`}>
{avatarLetter(groupQuery.data?.name || peerName)}
</span>
<div className="flex min-w-0 flex-col">
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
<span className="text-xs text-text-tertiary-2">
群号 {topicId} · {memberCount} 人
</span>
</div>
{/* 03-group.dc.html #1a: `qr_code_2` icon, `margin-left:auto` -- a real, mock-confirmed
entry point this row never had. #1e's mobile avatar row instead ends in a generic
`chevron_right` (implying a deeper group-profile screen this app doesn't have), not
this icon -- but shipping a REAL, working QR entry point on mobile too (even if its
glyph doesn't literally match #1e's) beats a chevron that taps through to nowhere. */}
<button type="button" onClick={() => setOpenModal('qrcode')} aria-label="群二维码" className="flex-none text-text-tertiary-3">
<Icon name="qr_code_2" className="text-[20px]" />
</button>
</div>
{groupQuery.data?.description ? <p className="m-0 text-[13px] text-text-secondary">{groupQuery.data.description}</p> : null}
</div>
<div className="flex flex-col px-4 py-3 text-sm md:border-b md:border-border-list-item md:px-5">
{/* Desktop-only header row (#1a): "群成员 N · 查看全部" above the grid. Mobile (#1e) has
no such row -- it puts the same "查看全部成员" affordance as plain centered text
BELOW the grid instead (see the mobile-only button after the grid). */}
<button type="button" onClick={() => setOpenModal('members')} className="hidden w-full items-center justify-between py-1 md:flex">
<span className="text-text-body">群成员 {memberCount}</span>
<span className="flex items-center gap-0.5 text-[13px] text-accent">
查看全部
<Icon name="chevron_right" className="text-[18px]" />
</span>
</button>
<div className="grid grid-cols-5 gap-2 md:mt-2">
{members.slice(0, 9).map((m) => (
<div key={m.user_id} className="relative flex flex-col items-center gap-1">
{/* README §4.10/#1e: mobile member-grid avatars are 44px, one size up from
desktop's 40px (unconditional here before this fix). */}
<span
className={`grid h-11 w-11 place-items-center rounded-lg text-sm font-medium text-white md:h-10 md:w-10 ${avatarBgClass(m.user_id)}`}
>
{avatarLetter(m.display_name)}
</span>
<RoleCorner role={m.role} />
<span className="w-full truncate text-center text-[11px] text-text-secondary">{m.display_name || m.user_id}</span>
</div>
))}
{canInvite ? (
<button type="button" onClick={() => setOpenModal('members')} className="flex flex-col items-center gap-1">
<span className="grid h-11 w-11 place-items-center rounded-lg border border-dashed border-[#c5cad2] text-text-tertiary-3 md:h-10 md:w-10">
<Icon name="add" className="text-[18px]" />
</span>
<span className="text-[11px] text-text-secondary">邀请</span>
</button>
) : null}
</div>
<button type="button" onClick={() => setOpenModal('members')} className="mt-2 flex justify-center py-1 text-[13px] text-accent md:hidden">
查看全部成员 ({memberCount})
</button>
</div>
</div>
<div className="flex flex-col bg-white px-4 text-sm md:border-b md:border-border-list-item md:px-5">
<button type="button" onClick={() => setOpenModal('announcement')} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">群公告</span>
<span className="flex max-w-[65%] items-center gap-0.5 text-[13px] text-text-tertiary-2">
<span className="truncate">{summaryQuery.data?.announcement?.content || '未设置'}</span>
<Icon name="chevron_right" className="flex-none text-[20px] text-icon-arrow" />
</span>
</button>
<button type="button" onClick={() => setOpenModal('files')} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">群文件</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
<button type="button" onClick={() => onOpenHistory('text')} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">查找聊天记录</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
{isAdmin ? (
<button type="button" onClick={() => setOpenModal('admin')} className="flex items-center justify-between py-3 text-left md:hidden">
<span className="flex items-center gap-2 text-text-body">
<Icon name="admin_panel_settings" className="text-[18px] text-accent" />
群管理
</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
) : null}
{/* Not in #1e's mock at all (see the block comment above) -- kept for parity with desktop,
which does have this row (just grouped with 群管理/清空聊天记录 there instead). */}
{nicknameEditing ? (
<div className="flex items-center gap-2 py-3">
<span className="flex-none text-text-body">我在本群的昵称</span>
<input
autoFocus
value={nicknameDraft}
onChange={(e) => setNicknameDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && nicknameMutation.mutate(nicknameDraft)}
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
/>
<button type="button" onClick={() => nicknameMutation.mutate(nicknameDraft)} className="flex-none text-accent">
保存
</button>
</div>
) : (
<button
type="button"
onClick={() => {
setNicknameDraft(myProfileQuery.data?.nickname || '');
setNicknameEditing(true);
}}
className="flex items-center justify-between py-3 text-left"
>
<span className="text-text-body">我在本群的昵称</span>
<span className="flex items-center gap-0.5 text-[13px] text-text-tertiary-2">
{myProfileQuery.data?.nickname || '未设置'}
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</span>
</button>
)}
</div>
<div className="flex flex-col bg-white px-4 text-sm md:border-b md:border-border-list-item md:px-5">
<div className="flex items-center justify-between py-3">
<span className="text-text-body">置顶聊天</span>
<Toggle
checked={!!settingsQuery.data?.pinned}
disabled={!settingsQuery.data || updateSettings.isPending}
onChange={() => updateSettings.mutate({ pinned: !settingsQuery.data?.pinned })}
/>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-text-body">消息免打扰</span>
<Toggle
checked={!!settingsQuery.data?.mute_notifications}
disabled={!settingsQuery.data || updateSettings.isPending}
onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })}
/>
</div>
</div>
<div className="flex flex-col bg-white px-4 pb-2 text-sm md:px-5">
{/* Desktop groups 群管理 here (#1a); mobile already rendered it in the card above (#1e),
so this copy is md-only to avoid showing the row twice. */}
{isAdmin ? (
<button type="button" onClick={() => setOpenModal('admin')} className="hidden items-center justify-between py-3 text-left md:flex">
<span className="flex items-center gap-2 text-text-body">
<Icon name="admin_panel_settings" className="text-[18px] text-accent" />
群管理
</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
) : null}
<button type="button" onClick={clearHistory} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">清空聊天记录</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
<button type="button" onClick={reportAbuse} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">投诉举报</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
</div>
{/* Desktop (#1a): bordered pill button, centered with margin -- unchanged. */}
<button
type="button"
onClick={startLeaveOrDisband}
disabled={leaveMutation.isPending || disbandMutation.isPending}
className="mx-5 my-4 hidden h-10 flex-none rounded-lg border border-danger-border-soft text-sm text-danger-text disabled:opacity-50 md:block"
>
{isOwner ? '解散群聊' : '退出群聊'}
</button>
{/* Mobile (#1e): its own plain full-width white card, no border/radius. */}
<button
type="button"
onClick={startLeaveOrDisband}
disabled={leaveMutation.isPending || disbandMutation.isPending}
className="flex-none bg-white py-3.5 text-center text-base text-danger-text disabled:opacity-50 md:hidden"
>
{isOwner ? '解散群聊' : '退出群聊'}
</button>
</div>
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
{openModal === 'members' ? (
<GroupMembersModal topicId={topicId} canInvite={canInvite} onClose={() => setOpenModal(null)} />
) : null}
{openModal === 'announcement' ? (
<GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(null)} />
) : null}
{openModal === 'files' ? <GroupFilesModal topicId={topicId} isAdmin={isAdmin} onClose={() => setOpenModal(null)} /> : null}
{openModal === 'admin' ? <GroupAdminModal topicId={topicId} onClose={() => setOpenModal(null)} /> : null}
{openModal === 'qrcode' ? <QRCodeModal groupTopic={topicId} onClose={() => setOpenModal(null)} /> : null}
</aside>
);
}
+297
View File
@@ -0,0 +1,297 @@
import { useRef, useState } from 'react';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { addGroupFile, deleteGroupFile, getAllGroupMembers, getGroupFiles, type GroupFile } from '@/lib/api/groups';
import { fileAuthHeaders, fileDownloadUrl, uploadFile } from '@/lib/api/upload';
import { useClickOutside, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useSessionStore } from '@/stores/session';
function isPdf(f: GroupFile): boolean {
return f.mime_type === 'application/pdf' || f.file_name.toLowerCase().endsWith('.pdf');
}
/** Authenticated fetch-to-blob, shared by download and "open in new tab" -- both need the same
* custom auth header a plain `<a href>`/`window.open` can't carry (see AttachmentPreview.tsx's own
* comment on this constraint), they just do different things with the resulting blob URL. */
async function fetchFileBlobUrl(fileId: string): Promise<string> {
const res = await fetch(fileDownloadUrl(`/v0/file/s/${fileId}`), { headers: fileAuthHeaders() });
// Group files are GC'd as "unreferenced" within hours (known backend issue) -- without this
// check, a 404's JSON error body gets treated as if it were the real file.
if (!res.ok) throw new Error();
return URL.createObjectURL(await res.blob());
}
interface GroupFilesModalProps {
topicId: string;
/** Confirmed against the real backend (im_be/server/im_http.go's serveIMGroupFiles): delete is
* allowed for the uploader OR a group admin/owner, not the uploader alone -- without this, an
* owner/admin has no way to remove another member's upload from this UI even though the server
* would accept the request. */
isAdmin: boolean;
onClose: () => void;
}
const DOCUMENT_EXT = ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt'];
/** `POST .../files`'s `file_type` field has no documented enum anywhere in the protocol doc -- ported
* from the old app's own real, working `group-management-view.jsx` (mime-prefix first, a document-
* extension allowlist as fallback), which itself notes this field being omitted entirely used to
* make every group file show a generic icon regardless of real type -- the read-side icon-branching
* bug found in this rewrite's own review pass had the same root cause. */
function fileTypeFromMime(mime: string, name: string): string {
const prefix = mime.split('/')[0];
if (prefix === 'image' || prefix === 'video' || prefix === 'audio') return prefix;
const ext = /\.([a-zA-Z0-9]+)$/.exec(name)?.[1]?.toLowerCase();
if (mime === 'application/pdf' || (ext && DOCUMENT_EXT.includes(ext))) return 'document';
return 'file';
}
/**
* README §4.9 (480px). Files are registered via the message-send path (attachments.ts) or a direct
* upload here -- this view lists/uploads/downloads/deletes the group's file INDEX (lib/api/groups.ts's
* getGroupFiles/addGroupFile/deleteGroupFile). Upload reuses the same native `/v0/file/u/` channel as
* chat attachments/avatars (uploadFile), then registers the result via addGroupFile -- which needs
* more fields than the protocol doc's own minimal example (see groups.ts's header comment), ported
* from the old app's real, working upload flow rather than guessed from the doc.
*/
export function GroupFilesModal({ topicId, isAdmin, onClose }: GroupFilesModalProps) {
const queryClient = useQueryClient();
const myUserId = useSessionStore((s) => s.myUserId);
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
const [downloadError, setDownloadError] = useState('');
const [actionError, setActionError] = useState('');
const [uploading, setUploading] = useState(false);
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
useEscapeKey(onClose);
const filesQuery = useInfiniteQuery({
queryKey: ['group-files', topicId, debouncedQuery],
queryFn: ({ pageParam }) => getGroupFiles(topicId, debouncedQuery, '', pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const files = filesQuery.data?.pages.flatMap((p) => p.items) ?? [];
// `GroupFile.uploader_id` is a bare user id with no display name attached (confirmed live) --
// mock `03-group.dc.html #1h`'s rows all show "size · uploader · time", not just "size · time".
// Same lookup-by-id-against-the-member-list pattern GroupAnnouncementModal already uses for its
// own author row.
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const uploaderName = (uploaderId: string) => membersQuery.data?.find((m) => m.user_id === uploaderId)?.display_name || uploaderId;
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setUploading(true);
try {
// Confirmed live: the upload response never echoes back name/size/mime (see upload.ts's own
// UploadedFile comment) -- read them off the original File object instead, exactly like the
// chat-attachment upload path (lib/tinode/attachments.ts) already correctly does.
const uploaded = await uploadFile(file, topicId);
await addGroupFile(topicId, {
file_id: uploaded.file_id,
file_name: file.name,
mime_type: file.type,
file_type: fileTypeFromMime(file.type, file.name),
file_size: file.size,
});
queryClient.invalidateQueries({ queryKey: ['group-files', topicId] });
} catch (err) {
setActionError(err instanceof Error ? err.message : '上传失败');
} finally {
setUploading(false);
}
};
const deleteMutation = useMutation({
mutationFn: (groupFileId: string) => deleteGroupFile(topicId, groupFileId),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['group-files', topicId] }),
onError: (err) => setActionError(err instanceof Error ? err.message : '删除失败'),
});
const downloadFile = (f: GroupFile) =>
fetchFileBlobUrl(f.file_id)
.then((url) => {
const link = document.createElement('a');
link.href = url;
link.download = f.file_name;
link.click();
URL.revokeObjectURL(url);
})
.catch(() => setDownloadError('文件下载失败,可能已过期'));
const openFileInNewTab = (f: GroupFile) =>
fetchFileBlobUrl(f.file_id)
.then((url) => window.open(url, '_blank', 'noopener,noreferrer'))
.catch(() => setDownloadError('文件打开失败,可能已过期'));
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[520px] w-[480px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">群文件 {files.length}</strong>
<div className="flex items-center gap-1">
{/* Mock (#1h): accent-blue `upload` icon + "上传" label, not a bare icon-only button in
the neutral gray this used to be. */}
<label
className={`flex cursor-pointer items-center gap-1 rounded-md px-2 py-1 text-[13px] text-accent hover:bg-surface-rail ${uploading ? 'pointer-events-none opacity-50' : ''}`}
>
<Icon name={uploading ? 'hourglass_empty' : 'upload'} className="text-[18px]" />
上传
<input type="file" onChange={handleUpload} disabled={uploading} className="hidden" />
</label>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</div>
</header>
<div className="flex-none px-4 pt-3">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索群文件"
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{filesQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : filesQuery.isError ? (
<div className="py-10 text-center text-sm text-danger-text">加载失败,请重试</div>
) : files.length === 0 ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无群文件</div>
) : (
<>
{files.map((f) => {
const style = fileTypeStyle(f.file_name, f.mime_type);
// No `permissions` field exists on the real response (see groups.ts's own comment),
// but the real delete authorization (confirmed against im_be/server/im_http.go's
// serveIMGroupFiles) is uploader OR group admin/owner -- `isAdmin` is passed down from
// GroupDetailColumn, which already resolves the caller's real role for this same group.
const canDelete = isAdmin || (!!myUserId && f.uploader_id === myUserId);
const pdf = isPdf(f);
// 03-group.dc.html #1h draws three DIFFERENT trailing icons across its 3 example
// rows: a plain `download` for a generic .docx, `open_in_new` for a .pdf (browsers
// can render PDFs inline, so "open" is more useful than "save"), and `more_horiz` for
// the row whose uploader is "我" (me) -- i.e. the icon is a function of BOTH file type
// AND whether this is my own upload, not either one alone.
return (
<div key={f.group_file_id} className="relative flex items-center gap-1 rounded-lg hover:bg-surface-rail">
<button
type="button"
onClick={() => (pdf ? openFileInNewTab(f) : downloadFile(f))}
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-3 py-2 text-left"
>
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{f.file_name}</span>
<span className="truncate text-xs text-text-tertiary-2">
{formatFileSize(f.file_size)} · {uploaderName(f.uploader_id)} · {formatEventTimestamp(new Date(f.created_at))}
</span>
</span>
{canDelete ? null : <Icon name={pdf ? 'open_in_new' : 'download'} className="flex-none text-[20px] text-text-placeholder-2" />}
</button>
{canDelete ? (
<FileMenu
isPdf={pdf}
open={openMenuId === f.group_file_id}
onToggle={() => setOpenMenuId((cur) => (cur === f.group_file_id ? null : f.group_file_id))}
onClose={() => setOpenMenuId(null)}
onDownload={() => downloadFile(f)}
onOpen={() => openFileInNewTab(f)}
onDelete={() => {
setOpenMenuId(null);
if (window.confirm(`确定删除文件"${f.file_name}"吗?`)) deleteMutation.mutate(f.group_file_id);
}}
deleting={deleteMutation.isPending}
/>
) : null}
</div>
);
})}
{filesQuery.hasNextPage ? (
<button
type="button"
onClick={() => filesQuery.fetchNextPage()}
disabled={filesQuery.isFetchingNextPage}
className="w-full py-3 text-center text-sm text-accent disabled:opacity-60"
>
{filesQuery.isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
) : null}
</>
)}
</div>
<Toast message={downloadError} variant="error" onDone={() => setDownloadError('')} />
<Toast message={actionError} variant="error" onDone={() => setActionError('')} />
</div>
</div>
);
}
/** The mock's `more_horiz` trailing icon for a file I uploaded, opening a small anchored menu with
* the actions the plain trailing icon otherwise carries alone (预览/下载) plus 删除 -- no mock draws
* the open menu itself (only the closed `more_horiz` icon), so the menu's own layout borrows the
* small-popover convention already used for MessageContextMenu.tsx (white card, shadow, icon+label
* rows, a divider before the destructive action). */
function FileMenu({
isPdf,
open,
onToggle,
onClose,
onDownload,
onOpen,
onDelete,
deleting,
}: {
isPdf: boolean;
open: boolean;
onToggle: () => void;
onClose: () => void;
onDownload: () => void;
onOpen: () => void;
onDelete: () => void;
deleting: boolean;
}) {
const ref = useRef<HTMLDivElement>(null);
useClickOutside(ref, onClose, open);
return (
<div ref={ref} className="relative flex-none">
<button type="button" aria-label="更多操作" onClick={onToggle} className="grid h-8 w-8 place-items-center rounded-md text-text-tertiary-3 hover:bg-white">
<Icon name="more_horiz" className="text-[20px]" />
</button>
{open ? (
<div className="absolute right-0 top-full z-10 mt-1 w-36 rounded-lg bg-white py-1 shadow-modal">
{isPdf ? (
<button type="button" onClick={onOpen} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-text-body">
<Icon name="open_in_new" className="text-[18px] text-text-tertiary-2" />
预览
</button>
) : null}
<button type="button" onClick={onDownload} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-text-body">
<Icon name="download" className="text-[18px] text-text-tertiary-2" />
下载
</button>
<div className="my-1 border-t border-border-list-item" />
<button type="button" onClick={onDelete} disabled={deleting} className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-danger-text disabled:opacity-50">
<Icon name="delete_outline" className="text-[18px]" />
删除
</button>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,144 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import { getFriends } from '@/lib/api/friends';
import { getAllGroupMembers, inviteGroupMembers, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
// README §4.3: role tags are a BORDER style, no fill -- matches MessageBubble.tsx's already-correct
// MessageRoleTag exactly (this file previously used filled `bg-*-bg` pills instead).
const ROLE_CLASS: Record<GroupRole, string> = { owner: 'border border-warn text-warn', admin: 'border border-accent text-accent', member: '' };
function RoleTag({ role }: { role: GroupRole }) {
if (role === 'member') return null;
return <span className={`flex-none rounded-[3px] px-1 text-[10px] leading-[14px] ${ROLE_CLASS[role]}`}>{ROLE_LABEL[role]}</span>;
}
interface GroupMembersModalProps {
topicId: string;
/** Whether the current viewer can invite -- owner/admin, OR any member when the group's
* `governance.member_invite` toggle (GroupAdminModal's "邀请入群权限") is set to "所有成员".
* Computed by the caller (GroupDetailColumn already has both `my_role` and `governance` in scope
* from the same settings query) rather than re-deriving from a bare role here, which is exactly
* what silently made that governance toggle a no-op before this was wired through. */
canInvite: boolean;
onClose: () => void;
}
/** README §4.4's "查看全部成员" -- full member list, plus an invite flow backed by
* `inviteGroupMembers` (README §4.7's create/invite modal covers the same endpoint for adding to
* an existing group, not just at creation time). No "移出成员" affordance here: this protocol has
* no documented REST or WS operation for removing a group member at all (grepped the whole
* protocol doc) -- a real backend gap, not a client oversight, so this doesn't pretend to offer it. */
export function GroupMembersModal({ topicId, canInvite, onClose }: GroupMembersModalProps) {
const queryClient = useQueryClient();
const [query, setQuery] = useState('');
const [inviting, setInviting] = useState(false);
const [error, setError] = useState('');
useEscapeKey(onClose);
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const members = membersQuery.data || [];
const filtered = members.filter((m) => !query.trim() || (m.display_name || m.user_id).toLowerCase().includes(query.trim().toLowerCase()));
const friendsQuery = useQuery({
queryKey: ['friends'],
queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)),
enabled: inviting,
});
// README §4.7: friends already in the group stay VISIBLE, dimmed + labeled + unselectable -- not
// silently filtered out (which used to make the list look shorter than the user's real friend
// list with no explanation why some names were missing).
const inviteCandidates = (friendsQuery.data || []).map((f) => ({ ...f, alreadyMember: members.some((m) => m.user_id === f.user_id) }));
const inviteMutation = useMutation({
mutationFn: (userId: string) => inviteGroupMembers(topicId, [userId]),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
},
onError: (err) => setError(err instanceof Error ? err.message : '邀请失败'),
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">{inviting ? '邀请成员' : `群成员 ${members.length}`}</strong>
<div className="flex items-center gap-2">
{!inviting && canInvite ? (
<button type="button" onClick={() => setInviting(true)} className="text-sm text-accent">
邀请
</button>
) : null}
<button type="button" onClick={inviting ? () => setInviting(false) : onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name={inviting ? 'arrow_back' : 'close'} />
</button>
</div>
</header>
<div className="flex-none px-4 pt-3">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={inviting ? '搜索联系人' : '搜索群成员'}
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{inviting ? (
inviteCandidates
.filter((f) => !query.trim() || (f.remark || f.display_name || f.user_id).toLowerCase().includes(query.trim().toLowerCase()))
.map((f) => {
const label = f.remark || f.display_name || f.user_id;
return (
<button
key={f.user_id}
type="button"
disabled={inviteMutation.isPending || f.alreadyMember}
onClick={() => inviteMutation.mutate(f.user_id)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail disabled:hover:bg-transparent ${
f.alreadyMember ? 'opacity-50' : ''
}`}
>
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(f.user_id)}`}>
{avatarLetter(label)}
</span>
<span className="flex-1 truncate text-sm text-text-body">{label}</span>
{f.alreadyMember ? (
<span className="flex-none text-xs text-text-tertiary-3">已在群内</span>
) : (
<Icon name="person_add" className="text-[18px] text-accent" />
)}
</button>
);
})
) : membersQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : (
filtered.map((m) => (
<div key={m.user_id} className="flex w-full items-center gap-3 rounded-lg px-3 py-2">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
</span>
<span className="flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
<RoleTag role={m.role} />
</div>
))
)}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
</div>
);
}
@@ -0,0 +1,137 @@
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { fetchAllPages } from '@/lib/api/client';
import { acceptGroupJoinRequest, getAllGroupMembers, getGroupJoinRequests, rejectGroupJoinRequest, type GroupJoinRequest } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { formatEventTimestamp } from '@/lib/messages/time';
interface JoinRequestsModalProps {
topicId: string;
onClose: () => void;
}
/**
* README §4.6. Reject sends `{version}` ONLY -- no `reason` field, even though the protocol doc's
* own example shows one (see lib/api/groups.ts's rejectGroupJoinRequest comment: the old app
* confirmed live that the real server 400s on an unrecognized `reason` key).
*/
export function JoinRequestsModal({ topicId, onClose }: JoinRequestsModalProps) {
const queryClient = useQueryClient();
const [handled, setHandled] = useState<Record<string, 'accepted' | 'rejected'>>({});
useEscapeKey(onClose);
const query = useQuery({
queryKey: ['group-join-requests', topicId],
// `'pending'`-only used to mean a request resolved before this session (or by another admin)
// just never appeared here at all -- the render below already has a real "已同意/已拒绝" branch
// keyed off the server's own `req.status`, it just never got fed anything but pending requests to
// apply that branch to. README §4.6's own mock shows an already-resolved row persisting in the
// list, matching `'all'`, not `'pending'`.
queryFn: () => fetchAllPages<GroupJoinRequest>((cursor) => getGroupJoinRequests(topicId, 'all', cursor)),
});
// The applicant themself has no display name anywhere on the real response (see groups.ts's own
// GroupJoinRequest comment) and isn't a member yet, so there's no list to resolve THEIR name from
// either -- shown as a bare id, honestly, rather than guessed. The INVITER (mock: "王工 邀请") IS
// already a member by definition, so that name can be resolved the same way GroupFilesModal's
// uploader name and GroupAnnouncementModal's author name already do.
const membersQuery = useQuery({ queryKey: ['group-members', topicId], queryFn: () => getAllGroupMembers(topicId) });
const memberName = (userId: string) => membersQuery.data?.find((m) => m.user_id === userId)?.display_name || userId;
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['group-summary', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members', topicId] });
queryClient.invalidateQueries({ queryKey: ['group-members-preview', topicId] });
queryClient.invalidateQueries({ queryKey: ['group', topicId] });
};
const acceptMutation = useMutation({
mutationFn: (req: GroupJoinRequest) => acceptGroupJoinRequest(topicId, req.request_id, req.version),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'accepted' }));
invalidate();
},
});
const rejectMutation = useMutation({
mutationFn: (req: GroupJoinRequest) => rejectGroupJoinRequest(topicId, req.request_id, req.version),
onSuccess: (_data, req) => {
setHandled((prev) => ({ ...prev, [req.request_id]: 'rejected' }));
// Unlike accept, nothing here changes group membership -- but `group-summary`'s
// `pending_join_request_count` badge (read by GroupAdminModal's "进群申请" row) does need to
// drop by one, and this previously invalidated nothing at all, leaving that badge stuck high.
invalidate();
},
});
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[480px] w-[560px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">进群申请</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex-1 overflow-y-auto px-2 py-2">
{query.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : !query.data?.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无进群申请</div>
) : (
query.data.map((req) => {
const status = handled[req.request_id];
return (
<div key={req.request_id} className="flex items-center gap-3 px-3 py-2.5">
<span className={`grid h-11 w-11 flex-none place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(req.user_id)}`}>
{avatarLetter(req.user_id)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<strong className="truncate text-sm font-medium text-text-body">{req.user_id}</strong>
<span className="truncate text-xs text-text-tertiary-2">
{/* mock (#1c): "{邀请人} 邀请" when invited, else a source label -- NOT
`source==='qr' ? ... : 邀请`, which used to show "邀请" for every
non-qr source including a plain self-initiated search request that
nobody actually invited (confirmed live: `source` really does take a
`"search"` value distinct from an invite). `inviter_id` is the real
signal for "was this an invite", not `source`. */}
{req.inviter_id ? `${memberName(req.inviter_id)} 邀请` : req.source === 'qr' ? '扫码申请' : '申请加入'}
{req.message ? ` · "${req.message}"` : ''} · {formatEventTimestamp(new Date(req.created_at))}
</span>
</span>
{status || req.status !== 'pending' ? (
<span className="flex-none text-xs text-text-tertiary-3">
{status === 'accepted' || req.status === 'accepted' ? '已同意' : '已拒绝'}
</span>
) : (
<span className="flex flex-none items-center gap-2">
<button
type="button"
onClick={() => rejectMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary disabled:opacity-50"
>
拒绝
</button>
<button
type="button"
onClick={() => acceptMutation.mutate(req)}
disabled={acceptMutation.isPending || rejectMutation.isPending}
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
>
同意
</button>
</span>
)}
</div>
);
})
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,183 @@
import { useEffect, useState } from 'react';
import { Icon } from '@/app/Icon';
import { useAuthedBlobUrl } from '@/lib/hooks';
import { fileTypeStyle, formatFileSize } from '@/lib/messages/attachments';
interface ImageData {
val?: string;
ref?: string;
mime?: string;
width?: number;
height?: number;
}
export function ImageAttachment({ data }: { data: ImageData }) {
const inline = typeof data.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
const src = inline || remote.url;
if (!src) {
return (
<div className="grid h-[170px] w-[260px] place-items-center rounded-lg bg-surface-rail">
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[28px] text-text-tertiary-3" />
</div>
);
}
// README §3.2: fixed 260x170, not a same-aspect-ratio-preserving max-height/width box.
return <img src={src} alt="" className="h-[170px] w-[260px] rounded-lg object-cover" />;
}
interface FileData {
val?: string;
ref?: string;
mime?: string;
name?: string;
size?: number;
}
export function FileAttachment({ data }: { data: FileData }) {
const style = fileTypeStyle(data.name, data.mime);
const remote = useAuthedBlobUrl(typeof data.val === 'string' ? undefined : data.ref);
const inline = typeof data.val === 'string' ? `data:${data.mime || 'application/octet-stream'};base64,${data.val}` : null;
const href = inline || remote.url || undefined;
return (
<a
href={href}
download={data.name}
onClick={(e) => {
if (remote.error) {
e.preventDefault();
remote.retry();
} else if (!href) {
e.preventDefault();
}
}}
className="flex w-[280px] items-center gap-3 rounded-lg bg-white px-3.5 py-3 no-underline shadow-bubble"
>
<span className="grid h-10 w-10 flex-none place-items-center rounded-lg" style={{ backgroundColor: style.bg }}>
<Icon name={style.icon} className="text-[20px]" style={{ color: style.fg }} />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{data.name || '文件'}</span>
<span className={`text-xs ${remote.error ? 'text-danger-text' : 'text-text-tertiary-2'}`}>
{typeof data.size === 'number' ? formatFileSize(data.size) : ''} ·{' '}
{remote.error ? '下载失败,点击重试' : remote.loading ? '加载中…' : '点击下载'}
</span>
</span>
<Icon
name={remote.error ? 'error_outline' : 'download'}
className={`ml-auto flex-none text-[20px] ${remote.error ? 'text-danger-text' : 'text-text-placeholder-2'}`}
/>
</a>
);
}
interface VideoData {
val?: string;
ref?: string;
mime?: string;
preview?: string;
duration?: number;
}
function formatDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/** README §3.2: 260x170 like ImageAttachment, `play_circle` overlay + duration badge, no controls
* until tapped. The video's own `val`/`mime` describe the full video body (confirmed live via
* favorites' identical `VD` entity shape, docs/design_handoff plan file Part D) -- not paintable as
* an `<img>`, so the static poster comes from the separate inline `preview` field (a real JPEG,
* confirmed by its base64 signature) instead. Tapping swaps the poster for a real `<video>` playing
* from `val`/`ref`, mirroring AudioAttachment's inline-vs-authenticated-fetch duality. */
export function VideoAttachment({ data }: { data: VideoData }) {
const [playing, setPlaying] = useState(false);
const posterInline = typeof data.preview === 'string' ? `data:image/jpeg;base64,${data.preview}` : null;
const videoInline = typeof data.val === 'string' ? `data:${data.mime || 'video/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(videoInline ? undefined : data.ref);
const videoSrc = videoInline || remote.url;
if (playing && videoSrc) {
// eslint-disable-next-line jsx-a11y/media-has-caption -- user-generated chat media, no captions exist to attach.
return <video src={videoSrc} controls autoPlay className="h-[170px] w-[260px] rounded-lg bg-black object-contain" />;
}
return (
<button
type="button"
onClick={() => setPlaying(true)}
disabled={!videoSrc}
className="relative block h-[170px] w-[260px] overflow-hidden rounded-lg bg-surface-rail"
>
{posterInline ? (
<img src={posterInline} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center">
<Icon name={remote.error ? 'broken_image' : 'videocam'} className="text-[28px] text-text-tertiary-3" />
</div>
)}
{videoSrc ? <Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" /> : null}
{typeof data.duration === 'number' ? (
<span className="absolute bottom-1.5 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
) : null}
</button>
);
}
interface AudioData {
val?: string;
ref?: string;
mime?: string;
duration?: number;
}
export function AudioAttachment({ data }: { data: AudioData }) {
const inline = typeof data.val === 'string' ? `data:${data.mime || 'audio/webm'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data.ref);
const src = inline || remote.url;
const [playing, setPlaying] = useState(false);
const [playError, setPlayError] = useState(false);
const [audioEl] = useState(() => (typeof Audio !== 'undefined' ? new Audio() : null));
useEffect(() => {
if (!audioEl) return undefined;
const onEnded = () => setPlaying(false);
audioEl.addEventListener('ended', onEnded);
// Without this, navigating away (or scrolling this bubble out) while a voice message is still
// playing leaves it playing in the background with no control left on screen to stop it.
return () => {
audioEl.removeEventListener('ended', onEnded);
audioEl.pause();
};
}, [audioEl]);
const toggle = () => {
if (!audioEl || !src) return;
if (playing) {
audioEl.pause();
setPlaying(false);
return;
}
if (audioEl.src !== src) audioEl.src = src;
setPlayError(false);
audioEl
.play()
.then(() => setPlaying(true))
.catch(() => setPlayError(true));
};
return (
<button type="button" onClick={toggle} disabled={!src} className="flex w-[200px] items-center gap-2">
<Icon
name={playError ? 'error_outline' : playing ? 'pause_circle' : 'play_circle'}
className={`text-[24px] ${playError ? 'text-danger-text' : 'text-accent'}`}
/>
<span className="h-4 flex-1 rounded bg-[repeating-linear-gradient(90deg,#b7c3d4_0px,#b7c3d4_2px,transparent_2px,transparent_5px)]" />
<span className={`flex-none text-xs ${playError ? 'text-danger-text' : 'text-text-secondary'}`}>
{playError ? '播放失败' : remote.loading ? '…' : formatDuration(data.duration)}
</span>
</button>
);
}
@@ -0,0 +1,194 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { CreateGroupModal } from '@/features/groups/CreateGroupModal';
import { fetchAllPages } from '@/lib/api/client';
import { getGroups } from '@/lib/api/groups';
import { useBootstrap } from '@/lib/api/hooks';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { formatMessagePreview } from '@/lib/messages/preview';
import { formatListTime } from '@/lib/messages/time';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
import { useSessionStore } from '@/stores/session';
/**
* README §2.1's always-pinned utility row. Real, confirmed backend fact (docs/IM-前后端对接
* 文档.md:909): topic name is fixed ("sys"), but 系统消息's unread comes from a separate
* notifications table (not the topic itself). `/messages/system` renders a real dedicated timeline
* (SystemMessagesView, off `GET /v1/im/system-notifications`) -- nested under MessagesLayout like
* every `:topicId` conversation, not a standalone top-level route, so the chat list column stays
* docked next to it (a user report caught the list vanishing when this was still `/chat/system`,
* a route outside MessagesLayout entirely).
*
* 文件助手 (a pinned entry that redirected into a plain ConversationView on the `slf` topic --
* README §9.2's own dedicated 3-column file browser has no backing REST API at all, confirmed via a
* research pass) was deliberately removed from here: the user found it confusing -- it's really
* just a chat thread with yourself wearing a folder icon, and DetailColumn's friend-only actions
* (拉黑/举报/删除好友) made no sense pointed at yourself. Hidden across every entry point (NavRail,
* here, ContactsListColumn, SettingsLayout's mobile shortcuts) rather than half-built into a real
* file browser the backend can't support yet. `slf` itself is untouched -- if it ever has real
* message history it still surfaces as an perfectly ordinary contact row, sorted like any other.
*/
const PINNED_ENTRIES = [
{
key: 'system_messages' as const,
badgeKey: 'system_unread_count' as const,
label: '系统消息',
icon: 'notifications',
route: '/messages/system',
colorClass: 'bg-warn-bg text-warn',
},
];
/** Chat-list row sort: 置顶 (README §2.1's "置顶 > 最近") first, then by `touched` (last activity)
* descending within each group. The pinned flag is read straight off the SDK's own ambient
* `contact.private.pinned` (confirmed present in tinode-sdk.d.ts's `TopicDescPrivate`) rather than
* a separate REST fetch per topic -- DetailColumn's "置顶聊天" toggle already writes through the
* conversation-settings REST endpoint, and the server mirrors that same flag back onto the topic's
* Tinode-level `private` field, so this is free (no extra request) instead of N per-topic fetches
* just to render the list. */
export function ChatListColumn() {
const { topicId } = useParams();
const location = useLocation();
const navigate = useNavigate();
const myUserId = useSessionStore((s) => s.myUserId);
const tinode = useTinode();
const contacts = useChatList();
const bootstrap = useBootstrap();
// Shared with GroupListView's own `['groups','all']` query (same key) -- React Query dedupes the
// two rather than this list issuing its own redundant fetch, and it's one bulk paginated call
// regardless (docs/IM-前后端对接文档.md:1444), not one request per group row.
const groupsQuery = useQuery({ queryKey: ['groups', 'all'], queryFn: () => fetchAllPages((cursor) => getGroups('', '', cursor)) });
const memberCountByTopic = new Map(groupsQuery.data?.map((g) => [g.topic, g.member_count]));
const [query, setQuery] = useState('');
const [creatingGroup, setCreatingGroup] = useState(false);
const sorted = [...contacts].sort((a, b) => {
const pinDelta = (b.private?.pinned ? 1 : 0) - (a.private?.pinned ? 1 : 0);
return pinDelta || (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0);
});
const trimmedQuery = query.trim();
const filtered = trimmedQuery ? sorted.filter((c) => (c.public?.fn || c.topic || '').toLowerCase().includes(trimmedQuery.toLowerCase())) : sorted;
const pinned = PINNED_ENTRIES.filter((entry) => bootstrap.data?.entries?.[entry.key]?.enabled);
return (
<aside className="flex h-full w-full flex-none flex-col border-r border-border-panel bg-white md:w-[300px] lg:w-80">
<header className="flex h-15 flex-none items-center justify-between px-5">
<h1 className="m-0 text-xl font-semibold text-text-heading">消息</h1>
{/* README §5.4's 添加好友 entry point is Phase 4 (contacts), not built yet -- this only
opens 创建群聊 (§4.7) for now, the one thing this icon can actually do today. */}
<button type="button" onClick={() => setCreatingGroup(true)} className="grid h-8 w-8 place-items-center text-text-heading">
<Icon name="add_comment" />
</button>
</header>
<div className="mx-3 mb-2.5 flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索会话、联系人、消息"
className="ml-2 w-full bg-transparent text-sm text-text-body outline-none placeholder:text-text-placeholder-2"
/>
</div>
<div className="flex flex-1 flex-col overflow-y-auto">
{pinned.map((entry) => {
const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0;
const active = location.pathname === entry.route;
return (
<Link
key={entry.key}
to={entry.route}
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-border-list-item py-2.5 pl-4 pr-3 no-underline ${
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
}`}
>
<span className={`grid h-11 w-11 place-items-center rounded-lg ${entry.colorClass}`}>
<Icon name={entry.icon} className="text-2xl" />
</span>
<span className="flex min-w-0 flex-col gap-1">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{entry.label}</strong>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
{unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{unread}
</b>
) : null}
</span>
</Link>
);
})}
{filtered.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 text-text-tertiary-4">
<Icon name={trimmedQuery ? 'search_off' : 'forum'} className="!text-4xl" />
<span className="text-sm">{trimmedQuery ? '未找到相关会话' : '暂无会话'}</span>
</div>
) : (
filtered.map((contact) => {
const name = contact.public?.fn || contact.topic;
const preview = formatMessagePreview(contact.latestMessage?.(), myUserId);
const active = topicId === contact.topic;
const isGroup = tinode.getTopic(contact.topic).isGroupType();
const memberCount = isGroup ? memberCountByTopic.get(contact.topic) : undefined;
const muted = !!contact.private?.mute_notifications;
return (
<Link
key={contact.topic}
to={`/messages/${contact.topic}`}
className={`grid min-h-[68px] grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 border-b border-border-list-item py-2.5 pl-4 pr-3 no-underline ${
active ? 'bg-accent-selected-bg' : 'bg-white hover:bg-[#f6f8fb]'
}`}
>
<span
className={`relative grid h-11 w-11 place-items-center rounded-lg text-base font-medium text-white ${avatarBgClass(contact.topic || '')}`}
>
{avatarLetter(name)}
{contact.online ? (
<span className="absolute -bottom-0.5 -right-0.5 h-2.5 w-2.5 rounded-full border-2 border-white bg-online" />
) : null}
</span>
<span className="flex min-w-0 flex-col gap-1">
<span className="flex items-center gap-1.5">
<strong className="truncate text-[15px] font-medium text-[#272b31]">{name}</strong>
{/* 02-messages.dc.html #1a/#1c: group rows carry a "N人" member-count pill right
after the name (e.g. "项目协作群 28人"); P2P rows never have one. */}
{typeof memberCount === 'number' ? (
<span className="flex-none rounded-full bg-[#f1f2f4] px-1.5 py-0.5 text-[11px] text-text-tertiary-3">{memberCount}人</span>
) : null}
</span>
<small className="truncate text-[13px] text-text-tertiary-2">{preview}</small>
</span>
<span className="flex flex-none flex-col items-end justify-between gap-1.5">
<time className="text-xs text-text-tertiary-3">{contact.touched ? formatListTime(contact.touched) : ''}</time>
{/* Same reference mock: a muted conversation's unread count is replaced by a small
muted-bell glyph, not shown alongside or as a louder red badge -- the whole point
of 消息免打扰 (DetailColumn's own toggle, which writes this same `private` field)
is to stop this row from demanding attention the way an unmuted one does. */}
{muted ? (
contact.unread ? <Icon name="notifications_off" className="text-base text-text-tertiary-4" /> : null
) : contact.unread ? (
<b className="grid h-[18px] min-w-[18px] place-items-center rounded-full bg-unread px-1.5 text-[11px] font-medium text-white">
{contact.unread}
</b>
) : null}
</span>
</Link>
);
})
)}
</div>
{creatingGroup ? (
<CreateGroupModal
onClose={() => setCreatingGroup(false)}
onCreated={(topic) => {
setCreatingGroup(false);
navigate(`/messages/${topic}`);
}}
/>
) : null}
</aside>
);
}
@@ -0,0 +1,848 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link, useOutletContext, useParams } from 'react-router-dom';
import type { StoredMessage } from 'tinode-sdk';
import { Drafty } from 'tinode-sdk';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { EmojiPanel } from '@/features/messages/EmojiPanel';
import { ForwardPickerModal } from '@/features/messages/ForwardPickerModal';
import { MentionPicker } from '@/features/messages/MentionPicker';
import { MessageContextMenu } from '@/features/messages/MessageContextMenu';
import { MessageRow } from '@/features/messages/MessageBubble';
import { VoiceRecorder } from '@/features/messages/VoiceRecorder';
import { GroupAnnouncementModal } from '@/features/groups/GroupAnnouncementModal';
import { JoinRequestsModal } from '@/features/groups/JoinRequestsModal';
import { useBootstrap } from '@/lib/api/hooks';
import { favoriteMessages, type MediaType, recallMessage } from '@/lib/api/messages';
import { getAllGroupMembers, getGroup, getGroupGovernance, getGroupMutes, getGroupSummary } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { FETCH_PAGE_TRIGGER, MESSAGES_PAGE } from '@/lib/config';
import { useClickOutside } from '@/lib/hooks';
import { availableActions, recallRemainingSeconds, type MessageAction } from '@/lib/messages/actions';
import { isRecalled } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { buildMentionedContent, detectMentionTrigger, type PendingMention } from '@/lib/messages/mentions';
import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments';
import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks';
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
import { closeConversation, ensureTopicAttached, openConversation } from '@/lib/tinode/topics';
import { useCallStore } from '@/stores/calls';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
interface OutletCtx {
onToggleDetail: () => void;
/** Whether the detail panel is CURRENTLY shown -- at >=1280px this is the persisted
* (localStorage) docked-panel state, at 960-1279px the temporary drawer state. Drives the
* header "i" button's highlight. */
detailVisible: boolean;
/** Opens the shared history-search modal (owned by MessagesLayout, the closest common ancestor of
* this view and DetailColumn, which can also open it from its own "查找聊天记录" row). */
onOpenHistory: (tab: 'text' | MediaType) => void;
}
interface ContextMenuState {
x: number;
y: number;
msg: StoredMessage;
}
function notImplemented() {
window.alert('该功能暂未开放,如需联系客服。');
}
export function ConversationView() {
const { topicId } = useParams();
const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>();
const myUserId = useSessionStore((s) => s.myUserId);
const tinode = useTinode();
const contacts = useChatList();
const contact = contacts.find((c) => c.topic === topicId);
const messages = useTopicMessages(topicId);
const bootstrap = useBootstrap();
// Keyed by topicId, not a single string -- ConversationView is one long-lived instance reused
// across every conversation (the route is `:topicId`, React Router doesn't remount on param
// change), so a plain single draft leaks the PREVIOUS conversation's unsent text into whichever
// one you open next.
const [draftByTopic, setDraftByTopic] = useState<Record<string, string>>({});
const draft = (topicId && draftByTopic[topicId]) || '';
const setDraft = (value: string) => {
if (topicId) setDraftByTopic((prev) => ({ ...prev, [topicId]: value }));
};
// Same per-topic keying as draftByTopic, and for the same reason -- a mention picked while
// composing in group A must not still be "pending" if the user switches to group B and back
// before sending (buildMentionedContent would then splice a real MN entity for a name that's
// just inert leftover text in a completely different conversation's draft).
const [mentionsByTopic, setMentionsByTopic] = useState<Record<string, PendingMention[]>>({});
const pendingMentions = (topicId && mentionsByTopic[topicId]) || [];
const [mentionTrigger, setMentionTrigger] = useState<{ start: number; query: string } | null>(null);
const [groupModal, setGroupModal] = useState<'announcement' | 'join-requests' | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [menu, setMenu] = useState<ContextMenuState | null>(null);
const [selectMode, setSelectMode] = useState(false);
const [selectedSeqs, setSelectedSeqs] = useState<Set<number>>(new Set());
const [loadingOlder, setLoadingOlder] = useState(false);
const [recording, setRecording] = useState(false);
const [bulkForwardOpen, setBulkForwardOpen] = useState(false);
const [replyTo, setReplyTo] = useState<StoredMessage | null>(null);
const [forwardMsg, setForwardMsg] = useState<StoredMessage | null>(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const emojiContainerRef = useRef<HTMLDivElement>(null);
const mentionContainerRef = useRef<HTMLDivElement>(null);
// Neither popover had a way to dismiss except re-clicking its own toggle -- a real gap a user
// report caught (open the emoji panel, click elsewhere in the app, and it just sits there).
useClickOutside(emojiContainerRef, () => setEmojiOpen(false), emojiOpen);
useClickOutside(mentionContainerRef, () => setMentionTrigger(null), !!mentionTrigger);
const [errorToast, setErrorToast] = useState('');
const [successToast, setSuccessToast] = useState('');
const streamRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
// "Jump to message" (HistorySearchModal result click, via stores/messageJump.ts). `highlightedSeq`
// drives MessageRow's brief flash; `pendingJumpSeqRef` is a ref (not state) because it's read/written
// across two different effects below without needing a render of its own.
const jumpTarget = useMessageJumpStore((s) => s.target);
const [highlightedSeq, setHighlightedSeq] = useState<number | null>(null);
const pendingJumpSeqRef = useRef<number | null>(null);
// Bumped every time a conversation is (re)opened below -- NOT the same as comparing topicId by
// value, which would false-positive if the user opens A, then B, then A again within a single
// loadOlder() round trip: topicId ends up back at "A" even though the DOM has since been torn
// down and rebuilt by the intervening open/close cycle, so a same-topicId check alone would wrongly
// treat that as "still the same open" and apply a scroll-restore computed against content that
// isn't there anymore.
const openGenerationRef = useRef(0);
// Every "fire and forget" send/recall/favorite/forward action below used to swallow its own
// failure with an empty `.catch(() => {})` (or, for forward, no catch at all) -- e.g. an
// over-size-limit attachment would throw a friendly Chinese error from uploadFile() that then
// just vanished, leaving the user staring at nothing happening with no idea why.
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
// docs/IM-前后端对接文档.md §4.16: "成功 Toast 只在至少一项成功时展示,部分失败需给出汇总" -- this
// response's `created_count`/`existing_count`/`failed[]` were being resolved and thrown away
// entirely, so favoriting from a message's context menu or the multi-select bar gave the user
// total silence either way: no confirmation on full success, and no way to learn that some of
// several selected messages weren't actually favorited.
const notifyFavoriteResult = (result: { created_count: number; existing_count: number; failed: Array<{ seq: number; reason: string }> }) => {
if (result.created_count + result.existing_count > 0) setSuccessToast('已收藏');
if (result.failed.length) setErrorToast(`${result.failed.length} 条消息收藏失败`);
};
useEffect(() => {
if (!topicId) return undefined;
openConversation(topicId).catch(() => {});
return () => {
closeConversation(topicId).catch(() => {});
};
}, [topicId]);
// useLayoutEffect, not useEffect -- this component never remounts on topicId change (route is
// `:topicId`), so a plain useEffect (which fires after paint) can let one frame render with the
// PREVIOUS conversation's select-mode/menu/etc. still showing over the new conversation's message
// list underneath, self-correcting only on the next tick.
useLayoutEffect(() => {
openGenerationRef.current += 1;
setSelectMode(false);
setSelectedSeqs(new Set());
setMenu(null);
setReplyTo(null);
setForwardMsg(null);
setBulkForwardOpen(false);
setEmojiOpen(false);
// Without this, a mention picker left open while typing in group A reappears in group B after a
// fast conversation switch (isGroup is true there too), anchored to a caret offset that describes
// A's draft, not B's -- picking a suggestion would splice "@Name" into the wrong text at the wrong
// position.
setMentionTrigger(null);
// Unmounting VoiceRecorder stops its media stream via its own cleanup effect -- without this,
// a recording started in conversation A silently keeps going after navigating to B, and its
// onSend callback (re-created every render off the CURRENT topicId) would publish the eventual
// recording into B instead of the conversation the user was actually recording in.
setRecording(false);
// Without these two, a "加载更多" in flight when the user switches conversations leaves a
// stale spinner showing (never resolves against the topic it started on) and, if the PREVIOUS
// conversation had been scrolled up when it was left, the ref carries that `false` into the
// NEW conversation and silently skips scrolling it to the bottom on open.
setLoadingOlder(false);
autoScrollRef.current = true;
// An error toast from the previous conversation (e.g. "图片发送失败") would otherwise keep
// showing over whichever conversation is now open, misattributing the failure.
setErrorToast('');
setSuccessToast('');
// A jump-to-message request still pending when the user switches away (its fetch was in flight,
// or it resolved but the scroll-on-mount effect hadn't fired yet) would otherwise survive into
// the NEW conversation and, once ITS messages populate, try to scroll+highlight whatever row in
// B happens to share the SAME seq number the user searched for in A -- seq is per-topic, so a
// collision across two different conversations is completely ordinary, not a rare edge case.
pendingJumpSeqRef.current = null;
setHighlightedSeq(null);
}, [topicId]);
useEffect(() => {
if (autoScrollRef.current) {
streamRef.current?.scrollTo({ top: streamRef.current.scrollHeight });
}
// `topicId` is included so switching between two conversations that both happen to have
// already loaded the same number of messages (e.g. both exactly MESSAGES_PAGE on first open)
// still scrolls the newly-opened one to the bottom -- `messages.length` alone wouldn't change.
}, [messages.length, topicId]);
// Consumes a pending "jump to message" request (HistorySearchModal result click). Fetches directly
// via `since`/`before` (arbitrary absolute seq bounds per the Tinode wire protocol, not required to
// be adjacent to whatever's already cached) rather than routing through loadOlder()'s gap-fill --
// that machinery is scoped to backfilling holes within the currently-visible min/max span, not
// "there's an entirely separate old cluster somewhere else the user wants to see right now".
useEffect(() => {
if (!jumpTarget || jumpTarget.topic !== topicId) return;
useMessageJumpStore.getState().clear();
const seq = jumpTarget.seq;
pendingJumpSeqRef.current = seq;
if (messages.some((m) => m.seq === seq)) return; // already loaded -- the effect below scrolls to it
autoScrollRef.current = false; // otherwise the scroll-to-bottom effect above fights this jump
const t = tinode.getTopic(topicId);
const half = Math.floor(MESSAGES_PAGE / 2);
t.getMeta(t.startMetaQuery().withData(Math.max(1, seq - half), seq + half + 1, MESSAGES_PAGE).build()).catch(() => {
pendingJumpSeqRef.current = null;
showError(null, '未能加载该消息,可能已被删除');
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jumpTarget, topicId]);
// Performs the actual scroll+highlight once the target row exists in the DOM -- covers both the
// "already loaded" case (this and the effect above run in the same commit, so the ref is already
// set by the time this checks) and the "had to fetch first" case (re-fires once `messages` picks up
// the newly-arrived data via useTopicMessages' own subscription, no manual wiring needed here).
useEffect(() => {
const seq = pendingJumpSeqRef.current;
if (seq == null) return;
const el = streamRef.current?.querySelector(`[data-seq="${seq}"]`);
if (!el) return;
pendingJumpSeqRef.current = null;
el.scrollIntoView({ block: 'center' });
setHighlightedSeq(seq);
window.setTimeout(() => setHighlightedSeq((cur) => (cur === seq ? null : cur)), 2000);
}, [messages, jumpTarget]);
useEffect(() => {
if (!topicId || messages.length === 0) return;
tinode.getTopic(topicId).noteRead(0);
}, [topicId, messages.length, tinode]);
// Computed independently of `topic` below (not `topic.isGroupType()`) so this and the group-data
// queries it gates can sit before the `if (!topicId)` early return, alongside every other hook --
// `tinode.getTopic()` is a cheap cache lookup-or-create either way, calling it twice is harmless.
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
const membersQuery = useQuery({
queryKey: ['group-members', topicId],
queryFn: () => getAllGroupMembers(topicId || ''),
enabled: isGroup,
});
const groupProfileQuery = useQuery({
queryKey: ['group', topicId],
queryFn: () => getGroup(topicId || ''),
enabled: isGroup,
});
const groupSummaryQuery = useQuery({
queryKey: ['group-summary', topicId],
queryFn: () => getGroupSummary(topicId || ''),
enabled: isGroup,
});
const governanceQuery = useQuery({
queryKey: ['group-governance', topicId],
queryFn: () => getGroupGovernance(topicId || ''),
enabled: isGroup,
});
const mutesQuery = useQuery({
queryKey: ['group-mutes', topicId],
queryFn: () => getGroupMutes(topicId || ''),
enabled: isGroup,
});
const memberByUserId = useMemo(() => new Map((membersQuery.data || []).map((m) => [m.user_id, m])), [membersQuery.data]);
const myMember = myUserId ? memberByUserId.get(myUserId) : undefined;
const isGroupAdmin = myMember?.role === 'owner' || myMember?.role === 'admin';
const groupSublineParts = [
governanceQuery.data?.join_approval ? '已开启进群验证' : null,
mutesQuery.data?.items.length ? `${mutesQuery.data.items.length} 位成员被禁言` : null,
].filter(Boolean);
if (!topicId) return null;
const topic = tinode.getTopic(topicId);
const peerName = contact?.public?.fn || topicId;
const groupMemberCount = groupProfileQuery.data?.member_count ?? membersQuery.data?.length ?? 0;
// `peerName` is the GROUP's own name for a group topic, not any member's -- reusing it to label
// "who is being quoted" (as both the reply-composer preview and the actual sent quote block used
// to) meant every single group-chat reply permanently displayed "回复 <群名>:…" for every member,
// regardless of who actually sent the quoted message. `memberByUserId` already resolves this
// correctly per-bubble (senderName below); reused here for the identical lookup rather than a
// second, divergent resolution path.
const quotedSenderLabel = (fromUserId: string | undefined) => {
if (myUserId && fromUserId === myUserId) return '我';
if (isGroup && fromUserId) return memberByUserId.get(fromUserId)?.display_name || fromUserId;
return peerName;
};
/**
* "加载更多" (README §3.2/old app's messages-view.jsx): not simple since/before paging -- computes
* which seq ranges are actually missing from the local cache within the currently-visible window
* (topic.msgHasMoreMessages) and fills exactly those gaps (topic.getMessagesPage), since messages
* can have server-side deletions punching holes in an otherwise-contiguous range.
*/
const loadOlder = async () => {
if (loadingOlder) return;
const calledForGeneration = openGenerationRef.current;
const el = streamRef.current;
const min = topic.minMsgSeq();
const max = topic.maxMsgSeq();
const gaps = topic.msgHasMoreMessages(min, max, false);
if (gaps.length === 0) return;
setLoadingOlder(true);
const prevHeight = el?.scrollHeight ?? 0;
const prevTop = el?.scrollTop ?? 0;
try {
await topic.getMessagesPage(MESSAGES_PAGE, gaps, min, max);
} finally {
// If the conversation was closed and reopened (even back to this SAME topicId) while this was
// in flight, `el` now shows content from a different open/close cycle -- prevTop/prevHeight
// were measured against content that isn't there anymore, so applying them would jump the
// current conversation's scroll to a meaningless spot. Comparing topicId alone would miss an
// A->B->A round trip (same string, different generation); only the specific open this request
// was made for should ever clear its own loading flag or touch the scroll position.
if (openGenerationRef.current === calledForGeneration) {
setLoadingOlder(false);
requestAnimationFrame(() => {
if (el) el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
});
}
}
};
const handleScroll = () => {
const el = streamRef.current;
if (!el) return;
autoScrollRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
if (el.scrollTop <= FETCH_PAGE_TRIGGER) {
loadOlder();
}
};
const submit = () => {
const text = draft.trim();
if (!text) return;
setDraft('');
setMentionTrigger(null);
if (replyTo) {
// Mentions inside a reply don't get spliced into real MN entities -- a quoted reply that also
// pings someone is a rare enough compound case that it isn't worth a second stitching path;
// the "@name" text still sends fine, just without the entity/highlight.
sendReply(topic, text, replyTo, quotedSenderLabel(replyTo.from)).catch((err) => showError(err, '发送失败'));
setReplyTo(null);
} else if (pendingMentions.length > 0) {
sendPrebuiltContent(topic, buildMentionedContent(text, pendingMentions)).catch((err) => showError(err, '发送失败'));
} else {
sendText(topic, text).catch((err) => showError(err, '发送失败'));
}
if (topicId) setMentionsByTopic((prev) => ({ ...prev, [topicId]: [] }));
};
const handleDraftChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = e.target.value;
setDraft(value);
if (isGroup) setMentionTrigger(detectMentionTrigger(value, e.target.selectionStart ?? value.length));
};
const pickMention = (choice: PendingMention) => {
if (!mentionTrigger || !topicId) return;
const before = draft.slice(0, mentionTrigger.start);
const after = draft.slice(mentionTrigger.start + 1 + mentionTrigger.query.length);
const inserted = `${before}@${choice.label} `;
setDraft(inserted + after);
setMentionsByTopic((prev) => ({ ...prev, [topicId]: [...(prev[topicId] || []), choice] }));
setMentionTrigger(null);
setEmojiOpen(false);
// Matches insertMentionTrigger's own restore below -- without an explicit setSelectionRange
// (deferred a frame so the textarea's DOM value has actually updated to `inserted + after`
// first), the cursor doesn't reliably land right after the just-inserted "@Name ", making it
// awkward to keep typing; a bare focus() alone doesn't move the caret to a specific offset.
const el = textareaRef.current;
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(inserted.length, inserted.length);
});
};
/** README §4.3's composer "@" toolbar icon -- same effect as typing '@' by hand, for groups. */
const insertMentionTrigger = () => {
const el = textareaRef.current;
const pos = el?.selectionStart ?? draft.length;
const needsSpace = pos > 0 && !/\s/.test(draft[pos - 1] ?? ' ');
const insertion = `${needsSpace ? ' ' : ''}@`;
const next = draft.slice(0, pos) + insertion + draft.slice(pos);
setDraft(next);
setMentionTrigger({ start: pos + insertion.length - 1, query: '' });
setEmojiOpen(false);
requestAnimationFrame(() => {
el?.focus();
el?.setSelectionRange(pos + insertion.length, pos + insertion.length);
});
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && mentionTrigger) {
setMentionTrigger(null);
return;
}
// A picked mention should delete as one atomic "@label" unit on Backspace, not character by
// character (README §4.3 doesn't spell this out explicitly, but every real chat app treats an
// inserted mention this way, and a user report confirmed the plain-textarea default -- delete
// one character at a time -- read as broken here specifically). Only intercepts a plain caret
// (no active selection) sitting right after one of THIS topic's actually-tracked mentions, so
// an unrelated hand-typed "foo@bar" is untouched.
if (e.key === 'Backspace' && topicId && pendingMentions.length > 0) {
const el = e.currentTarget;
if (el.selectionStart === el.selectionEnd) {
const pos = el.selectionStart;
const before = draft.slice(0, pos);
const hit = pendingMentions.find((m) => before.endsWith(`@${m.label} `) || before.endsWith(`@${m.label}`));
if (hit) {
const token = before.endsWith(`@${hit.label} `) ? `@${hit.label} ` : `@${hit.label}`;
const newBefore = before.slice(0, before.length - token.length);
const next = newBefore + draft.slice(pos);
e.preventDefault();
setDraft(next);
setMentionsByTopic((prev) => ({ ...prev, [topicId]: (prev[topicId] || []).filter((m) => m !== hit) }));
requestAnimationFrame(() => el.setSelectionRange(newBefore.length, newBefore.length));
return;
}
}
}
if (e.key !== 'Enter' || e.shiftKey) return;
// Guards against CJK IME candidate-selection Enter keystrokes being mistaken for submit --
// a real, unfixed bug class in the old app (confirmed by its own research: zero isComposing/
// compositionstart/keyCode-229 guards anywhere in that codebase).
if (e.nativeEvent.isComposing) return;
e.preventDefault();
submit();
};
const toggleSelect = (seq: number) => {
setSelectedSeqs((prev) => {
const next = new Set(prev);
if (next.has(seq)) next.delete(seq);
else next.add(seq);
return next;
});
};
const exitSelectMode = () => {
setSelectMode(false);
setSelectedSeqs(new Set());
};
const bulkDelete = () => {
if (selectedSeqs.size === 0) return;
if (!window.confirm(`删除已选择的 ${selectedSeqs.size} 条消息?`)) return;
topic.delMessagesList([...selectedSeqs], false);
exitSelectMode();
};
const bulkFavorite = () => {
if (selectedSeqs.size === 0) return;
// Same recalled-mid-session race as bulkForward above -- a message favorited with null content
// would just be a useless empty entry in 我的收藏 with no way to tell what it used to say.
const seqs = [...selectedSeqs].filter((seq) => {
const msg = topic.findMessage(seq);
return !msg || !isRecalled(msg);
});
if (seqs.length) {
favoriteMessages(seqs.map((seq) => ({ topic: topicId, seq })))
.then(notifyFavoriteResult)
.catch((err) => showError(err, '收藏失败'));
}
exitSelectMode();
};
const bulkForward = (targetTopics: string[], comment: string) => {
// `selectedSeqs` is a Set, which iterates in INSERTION (click) order, not numeric order -- a
// user who happened to click message #10 before #3 would otherwise forward them in that click
// order, landing #10 ahead of #3 in the target conversation and misrepresenting which one was
// actually said first.
const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b);
for (const targetTopic of targetTopics) {
// A target that isn't the conversation currently open (i.e. almost any real forward target)
// is not actually subscribed, and publishing on an unsubscribed topic is rejected outright by
// the SDK -- see ensureTopicAttached's own comment for why this is the common case, not an
// edge case.
ensureTopicAttached(targetTopic)
.then((target) => {
for (const seq of orderedSeqs) {
const msg = topic.findMessage(seq);
// A message can be recalled by its sender AFTER it was already selected (the checkbox
// itself refuses a NEW selection of a recalled message, but can't retroactively un-select
// one recalled mid-session) -- forwarding null content would silently send an empty bubble
// with a misleading "forwarded from" tag rather than the message the user actually picked.
if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
}
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
})
.catch((err) => showError(err, '转发失败'));
}
setBulkForwardOpen(false);
exitSelectMode();
};
const handleAction = (action: MessageAction) => {
const msg = menu?.msg;
setMenu(null);
if (!msg || typeof msg.seq !== 'number') return;
switch (action) {
case 'copy':
navigator.clipboard.writeText(Drafty.toPlainText(msg.content ?? '')).catch(() => {});
break;
case 'recall':
recallMessage(topicId, msg.seq)
.then(() => topic.delMessagesList([msg.seq], true))
.catch((err) => showError(err, '撤回失败'));
break;
case 'delete':
topic.delMessagesList([msg.seq], false);
break;
case 'select':
setSelectMode(true);
setSelectedSeqs(new Set([msg.seq]));
break;
case 'quote':
setReplyTo(msg);
break;
case 'favorite':
favoriteMessages([{ topic: topicId, seq: msg.seq }])
.then(notifyFavoriteResult)
.catch((err) => showError(err, '收藏失败'));
break;
case 'forward':
setForwardMsg(msg);
break;
}
};
const handlePickImage = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) sendImage(topic, file).catch((err) => showError(err, '图片发送失败'));
};
const handlePickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (file) sendFile(topic, file).catch((err) => showError(err, '文件发送失败'));
};
return (
<div className="flex h-full w-full flex-col bg-surface-chat">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-5">
{selectMode ? (
<>
<span className="text-[15px] font-medium text-text-heading">已选择 {selectedSeqs.size} 条消息</span>
<button type="button" onClick={exitSelectMode} className="text-sm text-accent">
取消
</button>
</>
) : (
<>
<div className="flex items-center gap-2.5">
<Link to="/messages" className="grid h-9.5 w-9.5 flex-none place-items-center text-text-heading md:hidden">
<Icon name="arrow_back_ios_new" />
</Link>
<button type="button" onClick={onToggleDetail} className="flex items-center gap-2.5 text-left">
<span
className={`grid h-9.5 w-9.5 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(topicId)}`}
>
{avatarLetter(peerName)}
</span>
<div className="flex flex-col">
<strong className="text-base font-semibold text-text-heading">
{peerName}
{isGroup ? <span className="ml-1 font-normal text-text-tertiary-2">({groupMemberCount})</span> : null}
</strong>
{isGroup ? (
groupSublineParts.length ? <span className="text-xs text-text-tertiary-2">{groupSublineParts.join(' · ')}</span> : null
) : (
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
{contact?.online ? '在线' : '离线'}
</span>
)}
</div>
</button>
</div>
<div className="flex items-center gap-1">
{isGroup ? null : (
<>
<button
type="button"
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'audio');
}}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
>
<Icon name="call" />
</button>
<button
type="button"
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'video');
}}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
>
<Icon name="videocam" />
</button>
</>
)}
<button
type="button"
onClick={() => onOpenHistory('text')}
className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1"
>
<Icon name="search" />
</button>
<button
type="button"
onClick={onToggleDetail}
className={`grid h-9.5 w-9.5 place-items-center rounded-lg ${detailVisible ? 'bg-accent-soft-bg text-accent' : 'text-text-tertiary-1'}`}
>
<Icon name="info" />
</button>
</div>
</>
)}
</header>
{isGroup && groupSummaryQuery.data?.announcement?.content ? (
<button
type="button"
onClick={() => setGroupModal('announcement')}
className="flex items-center gap-2 border-b border-[#eceef1] bg-[#fff8e6] px-5 py-2.5 text-left text-[13px] text-[#6d4c41]"
>
<Icon name="campaign" className="flex-none text-[18px] text-announce" />
<strong className="flex-none font-semibold">群公告</strong>
<span className="min-w-0 flex-1 truncate">{groupSummaryQuery.data.announcement.content}</span>
<Icon name="chevron_right" className="flex-none text-[18px] text-[#c2a26c]" />
</button>
) : null}
{isGroup && isGroupAdmin && !!groupSummaryQuery.data?.pending_join_request_count ? (
<div className="flex items-center gap-2 border-b border-[#eceef1] bg-[#e8f1fe] px-5 py-2.5 text-[13px] text-[#1a4f9c]">
<Icon name="person_add" className="flex-none text-[18px] text-accent" />
<span className="min-w-0 flex-1 truncate">{groupSummaryQuery.data.pending_join_request_count} 条新的进群申请</span>
<button type="button" onClick={() => setGroupModal('join-requests')} className="flex-none font-medium text-accent">
去处理
</button>
</div>
) : null}
<div ref={streamRef} onScroll={handleScroll} className="flex flex-1 flex-col gap-4 overflow-y-auto px-7 py-5">
{loadingOlder ? <div className="py-2 text-center text-xs text-text-tertiary-4">加载中…</div> : null}
{messages.map((msg) => {
const sender = isGroup && msg.from ? memberByUserId.get(msg.from) : undefined;
return (
<MessageRow
key={msg.seq}
topic={topic}
msg={msg}
mine={!!myUserId && msg.from === myUserId}
isGroup={isGroup}
peerName={peerName}
senderName={sender?.display_name}
senderRole={sender?.role}
selectMode={selectMode}
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
highlighted={typeof msg.seq === 'number' && highlightedSeq === msg.seq}
onToggleSelect={toggleSelect}
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
onError={setErrorToast}
/>
);
})}
</div>
{selectMode ? (
<div className="flex h-18 flex-none items-center justify-center gap-10 border-t border-border-panel bg-white">
<button
type="button"
onClick={() => setBulkForwardOpen(true)}
disabled={selectedSeqs.size === 0}
className="flex flex-col items-center gap-1 text-text-secondary disabled:opacity-40"
>
<Icon name="reply" className="text-[22px]" />
<span className="text-xs">转发</span>
</button>
<button
type="button"
onClick={bulkFavorite}
disabled={selectedSeqs.size === 0}
className="flex flex-col items-center gap-1 text-text-secondary disabled:opacity-40"
>
<Icon name="inventory_2" className="text-[22px]" />
<span className="text-xs">收藏</span>
</button>
<button type="button" onClick={bulkDelete} className="flex flex-col items-center gap-1 text-danger-text">
<Icon name="delete_outline" className="text-[22px]" />
<span className="text-xs">删除</span>
</button>
</div>
) : (
<div ref={mentionContainerRef} className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
{replyTo ? (
<div className="mb-2 flex items-center justify-between rounded-lg bg-surface-input px-3 py-1.5 text-xs text-text-tertiary-1">
<span className="truncate">
回复 {quotedSenderLabel(replyTo.from)}:{renderDrafty(replyTo.content)}
</span>
<button type="button" onClick={() => setReplyTo(null)} className="flex-none text-text-tertiary-3">
<Icon name="close" className="text-[16px]" />
</button>
</div>
) : null}
<div className="relative flex items-center gap-0.5 px-1 pb-1">
<div ref={emojiContainerRef} className="relative">
<button
type="button"
onClick={() => {
setEmojiOpen((v) => !v);
setMentionTrigger(null);
}}
className={`grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] ${emojiOpen ? 'bg-accent-soft-bg text-accent' : 'text-icon-default hover:bg-[#f2f4f7]'}`}
>
<Icon name="sentiment_satisfied_alt" />
</button>
{emojiOpen ? (
<div className="absolute bottom-full left-0 z-20 mb-2">
<EmojiPanel
onPick={(emoji) => {
setDraft(draft + emoji);
setEmojiOpen(false);
}}
/>
</div>
) : null}
</div>
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]">
<Icon name="image" />
<input type="file" accept="image/*" onChange={handlePickImage} className="hidden" />
</label>
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]">
<Icon name="attach_file" />
<input type="file" onChange={handlePickFile} className="hidden" />
</label>
<button
type="button"
onClick={notImplemented}
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
>
<Icon name="contact_page" />
</button>
{isGroup ? (
<button
type="button"
onClick={insertMentionTrigger}
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
>
<Icon name="alternate_email" />
</button>
) : null}
<button
type="button"
onClick={() => setRecording(true)}
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
>
<Icon name="mic_none" />
</button>
<button
type="button"
onClick={() => onOpenHistory('text')}
className="grid h-8.5 w-8.5 place-items-center rounded-md text-[22px] text-icon-default hover:bg-[#f2f4f7]"
>
<Icon name="history" />
</button>
{mentionTrigger && isGroup ? (
<div className="absolute bottom-full left-0 z-20 mb-2">
<MentionPicker topicId={topicId} query={mentionTrigger.query} onPick={pickMention} />
</div>
) : null}
</div>
{recording ? (
<VoiceRecorder
onCancel={() => setRecording(false)}
onSend={(blob, durationMs) => {
setRecording(false);
sendVoice(topic, blob, durationMs).catch((err) => showError(err, '语音发送失败'));
}}
/>
) : (
<>
{/* textarea + send button must share one flex row (design: 02-messages.dc.html's
`display:flex; align-items:flex-end; gap:12px`) -- they were previously two block-level
siblings with no shared flex parent (the actual parent only had `flex-none`, a flex-CHILD
property that does nothing to make ITSELF a flex container), so the button fell to its
own line below the textarea instead of sitting beside it. */}
<div className="flex items-end gap-3">
<textarea
ref={textareaRef}
value={draft}
onChange={handleDraftChange}
onKeyDown={handleKeyDown}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
className="min-h-[72px] flex-1 resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
/>
<button
type="button"
onClick={submit}
disabled={!draft.trim()}
className="h-9 flex-none rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
>
发送
</button>
</div>
</>
)}
</div>
)}
{menu ? (
<MessageContextMenu
x={menu.x}
y={menu.y}
actions={availableActions(menu.msg, myUserId, bootstrap.data?.message_policy)}
recallRemainingSeconds={recallRemainingSeconds(menu.msg, myUserId, bootstrap.data?.message_policy)}
onAction={handleAction}
onClose={() => setMenu(null)}
/>
) : null}
{forwardMsg ? (
<ForwardPickerModal
onClose={() => setForwardMsg(null)}
onPick={(targetTopics, comment) => {
for (const targetTopic of targetTopics) {
ensureTopicAttached(targetTopic)
.then((target) => {
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
})
.catch((err) => showError(err, '转发失败'));
}
setForwardMsg(null);
}}
/>
) : null}
{bulkForwardOpen ? <ForwardPickerModal onClose={() => setBulkForwardOpen(false)} onPick={bulkForward} /> : null}
{groupModal === 'announcement' ? (
<GroupAnnouncementModal topicId={topicId} canEdit={isGroupAdmin} onClose={() => setGroupModal(null)} />
) : null}
{groupModal === 'join-requests' ? <JoinRequestsModal topicId={topicId} onClose={() => setGroupModal(null)} /> : null}
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
<Toast message={successToast} variant="success" onDone={() => setSuccessToast('')} />
</div>
);
}
+304
View File
@@ -0,0 +1,304 @@
import { useLayoutEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { fetchAllPages } from '@/lib/api/client';
import {
blockUser,
deleteFriend,
getBlocks,
getConversationSettings,
getFriends,
getUserProfile,
unblockUser,
updateConversationSettings,
updateFriendRemark,
} from '@/lib/api/friends';
import type { MediaType } from '@/lib/api/messages';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useTinode } from '@/lib/tinode/hooks';
import { useCallStore } from '@/stores/calls';
interface DetailColumnProps {
topicId: string;
peerName: string;
onClose?: () => void;
onOpenHistory: (tab: 'text' | MediaType) => void;
}
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: () => void; disabled?: boolean }) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={onChange}
// README §3.4: mobile switches are 44×26 (22×22 knob), one size up from desktop's 40×22
// (18×18 knob) -- same fix as GroupDetailColumn.tsx's identical Toggle copy. The travel
// distance stays 18px at both sizes since track and knob grow by the same 4px.
className={`relative h-[26px] w-11 flex-none rounded-full transition-colors disabled:opacity-50 md:h-[22px] md:w-10 ${checked ? 'bg-accent' : 'bg-[#d5d9df]'}`}
>
<span
className={`absolute left-0.5 top-0.5 h-[22px] w-[22px] rounded-full bg-white transition-transform md:h-[18px] md:w-[18px] ${checked ? 'translate-x-[18px]' : ''}`}
/>
</button>
);
}
export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: DetailColumnProps) {
const navigate = useNavigate();
const tinode = useTinode();
const queryClient = useQueryClient();
const [remarkEditing, setRemarkEditing] = useState(false);
const [remarkDraft, setRemarkDraft] = useState('');
const [errorToast, setErrorToast] = useState('');
const showError = (err: unknown, fallback: string) => setErrorToast(err instanceof Error ? err.message : fallback);
// DetailColumn is reused across every contact when docked (no `key={topicId}`, same instance),
// so without this an in-progress remark edit for the PREVIOUS contact stays open and could be
// saved against the NEW one, and a leftover error toast would keep showing over the new contact
// misattributing the failure. useLayoutEffect (not useEffect) so this resolves before paint --
// this component never remounts on topicId change, so a post-paint effect can let one frame
// render with the previous contact's stale edit box still open.
useLayoutEffect(() => {
setRemarkEditing(false);
setRemarkDraft('');
setErrorToast('');
}, [topicId]);
// `slf` (self-chat, e.g. opened via a chat-list row once it has real history -- there is no
// "文件助手" entry point pointing at it anymore, see ChatListColumn.tsx's PINNED_ENTRIES doc
// comment) isn't a friend and isn't really "someone else" at all -- getUserProfile('slf') has no
// real user to look up, and every friend-only action below (remark/block/report/delete friend)
// is nonsensical pointed at yourself. Gated out entirely rather than left to silently no-op or
// error, confirmed live: this used to render "加入黑名单"/"投诉举报"/"删除好友" for your own notes
// to yourself.
const isSelf = topicId === 'slf';
const friendsQuery = useQuery({ queryKey: ['friends'], queryFn: () => fetchAllPages((cursor) => getFriends('', cursor)) });
const friend = friendsQuery.data?.find((f) => f.user_id === topicId);
const profileQuery = useQuery({ queryKey: ['user-profile', topicId], queryFn: () => getUserProfile(topicId), enabled: !isSelf });
const idLine = [profileQuery.data?.public_account && `ID:${profileQuery.data.public_account}`, profileQuery.data?.region]
.filter(Boolean)
.join(' · ');
const settingsQuery = useQuery({
queryKey: ['conversation-settings', topicId],
queryFn: () => getConversationSettings(topicId),
});
const updateSettings = useMutation({
mutationFn: (patch: { pinned?: boolean; mute_notifications?: boolean }) =>
updateConversationSettings(topicId, { ...patch, version: settingsQuery.data?.version ?? 0 }),
onSuccess: (data) => queryClient.setQueryData(['conversation-settings', topicId], data),
onError: (err) => showError(err, '设置失败,请重试'),
});
const remarkMutation = useMutation({
mutationFn: (remark: string) => updateFriendRemark(topicId, remark),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['friends'] });
setRemarkEditing(false);
},
onError: (err) => showError(err, '备注保存失败'),
});
const blockMutation = useMutation({
mutationFn: (blocked: boolean) => (blocked ? blockUser(topicId) : unblockUser(topicId)),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['blocks'] }),
onError: (err) => showError(err, '设置失败,请重试'),
});
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const isBlocked = !!blocksQuery.data?.some((b) => b.user.user_id === topicId);
const deleteFriendMutation = useMutation({
mutationFn: () => deleteFriend(topicId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['friends'] });
navigate('/messages');
},
onError: (err) => showError(err, '删除好友失败'),
});
const clearHistory = () => {
if (!window.confirm('确定清空与该联系人的聊天记录吗?此操作仅清除本地记录,不影响对方。')) return;
tinode.getTopic(topicId).delMessagesAll(false);
};
const startDeleteFriend = () => {
if (!window.confirm(`确定删除好友 ${peerName} 吗?`)) return;
deleteFriendMutation.mutate();
};
const reportAbuse = () => {
window.alert('该功能暂未开放,如需投诉举报请联系客服。');
};
return (
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[320px] lg:w-[300px]">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold">聊天设置</strong>
{onClose ? (
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
) : null}
</header>
<div className="flex flex-col items-center gap-1 border-b border-border-list-item px-5 pb-4 pt-6">
<span className={`grid h-18 w-18 place-items-center rounded-2xl text-2xl font-medium text-white ${avatarBgClass(topicId)}`}>
{avatarLetter(peerName)}
</span>
<strong className="mt-2 text-[17px] font-semibold text-text-heading">{peerName}</strong>
{idLine ? <span className="text-xs text-text-tertiary-2">{idLine}</span> : null}
{profileQuery.data?.signature ? (
<p className="m-0 text-center text-[13px] text-text-secondary">{profileQuery.data.signature}</p>
) : null}
<div className="mt-3 flex gap-3">
<button
type="button"
onClick={onClose}
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent"
>
<Icon name="chat_bubble_outline" className="text-[20px]" />
<span className="text-xs text-text-secondary">发消息</span>
</button>
{isSelf ? null : (
<>
<button
type="button"
disabled={profileQuery.data?.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'audio');
}}
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
>
<Icon name="call" className="text-[20px]" />
<span className="text-xs text-text-secondary">语音</span>
</button>
<button
type="button"
disabled={profileQuery.data?.capabilities?.can_call === false}
onClick={() => {
if (useCallStore.getState().phase === 'idle') useCallStore.getState().startCall(topicId, peerName, 'video');
}}
className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent disabled:opacity-50"
>
<Icon name="videocam" className="text-[20px]" />
<span className="text-xs text-text-secondary">视频</span>
</button>
</>
)}
</div>
</div>
<div className="border-b border-border-list-item px-5 pb-3 pt-2 text-sm">
<button type="button" onClick={() => onOpenHistory('text')} className="flex w-full items-center justify-between py-2">
<span className="text-text-body">查找聊天记录</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
<div className="mt-1 grid grid-cols-4 gap-2">
{(
[
{ type: 'image' as const, label: '图片', icon: 'image' },
{ type: 'video' as const, label: '视频', icon: 'videocam' },
{ type: 'file' as const, label: '文件', icon: 'description' },
{ type: 'link' as const, label: '链接', icon: 'link' },
] satisfies { type: MediaType; label: string; icon: string }[]
).map((item) => (
<button key={item.type} type="button" onClick={() => onOpenHistory(item.type)} className="flex flex-col items-center gap-1 py-1">
<Icon name={item.icon} className="text-[22px] text-text-tertiary-4" />
<span className="text-xs text-text-secondary">{item.label}</span>
</button>
))}
</div>
</div>
<div className="flex flex-col border-b border-border-list-item px-5 text-sm">
{isSelf ? null : remarkEditing ? (
<div className="flex items-center gap-2 py-3">
<span className="flex-none text-text-body">备注</span>
<input
autoFocus
value={remarkDraft}
onChange={(e) => setRemarkDraft(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && remarkMutation.mutate(remarkDraft)}
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
/>
<button type="button" onClick={() => remarkMutation.mutate(remarkDraft)} className="flex-none text-accent">
保存
</button>
</div>
) : (
<button
type="button"
onClick={() => {
setRemarkDraft(friend?.remark || '');
setRemarkEditing(true);
}}
className="flex items-center justify-between py-3 text-left"
>
<span className="text-text-body">备注</span>
<span className="flex items-center gap-0.5 text-[13px] text-text-tertiary-2">
{friend?.remark || '未设置'}
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</span>
</button>
)}
<div className="flex items-center justify-between py-3">
<span className="text-text-body">置顶聊天</span>
<Toggle
checked={!!settingsQuery.data?.pinned}
disabled={!settingsQuery.data || updateSettings.isPending}
onChange={() => updateSettings.mutate({ pinned: !settingsQuery.data?.pinned })}
/>
</div>
<div className="flex items-center justify-between py-3">
<span className="text-text-body">消息免打扰</span>
<Toggle
checked={!!settingsQuery.data?.mute_notifications}
disabled={!settingsQuery.data || updateSettings.isPending}
onChange={() => updateSettings.mutate({ mute_notifications: !settingsQuery.data?.mute_notifications })}
/>
</div>
{isSelf ? null : (
<div className="flex items-center justify-between py-3">
<span className="text-text-body">加入黑名单</span>
<Toggle checked={isBlocked} disabled={blockMutation.isPending} onChange={() => blockMutation.mutate(!isBlocked)} />
</div>
)}
</div>
<div className="flex flex-col px-5 pb-2 text-sm">
<button type="button" onClick={clearHistory} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">清空聊天记录</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
{isSelf ? null : (
<button type="button" onClick={reportAbuse} className="flex items-center justify-between py-3 text-left">
<span className="text-text-body">投诉举报</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
)}
</div>
{isSelf ? null : (
<button
type="button"
onClick={startDeleteFriend}
className="mx-5 my-4 h-10 flex-none rounded-lg border border-danger-border-soft text-sm text-danger-text"
>
删除好友
</button>
)}
<Toast message={errorToast} variant="error" onDone={() => setErrorToast('')} />
</aside>
);
}
+87
View File
@@ -0,0 +1,87 @@
import { useState } from 'react';
// README §3.3: "分类 Tab(最近/表情/手势/物品),12 列网格" -- the design mockup itself has no
// specific emoji asset list, just gray placeholder tiles captioned "use system emoji or a branded
// pack." This is a plain Unicode emoji grid (no custom art), same call the old app made (per its own
// comment: no WeChat/QQ-style sticker assets available, system emoji is the fallback).
const EMOJI_FACES = [
'😀', '😁', '😂', '🤣', '😊', '😍', '😘', '😜', '🤔', '😐', '😴', '😭', '😡', '🥳', '😱', '🤯', '🥰', '😇', '🙃', '😉',
'😆', '😅', '☺️', '😋', '😝', '🤗', '🤩', '🥺', '😢', '😤', '🤬', '🥵', '🥶', '😵', '🤢', '😷', '🤒', '🥴', '😪', '🙄',
];
const EMOJI_GESTURES = [
'👍', '👎', '👏', '🙏', '💪', '🤝', '✌️', '👌', '🤞', '👋', '👊', '✊', '🤛', '🤜', '🤟', '🤘', '👆', '👇', '👈', '👉',
'☝️', '✋', '🤚', '🖐️', '🖖', '🤙', '💅', '🤳', '👐', '🙌', '🤲', '🫶', '🤌', '🫰', '🫵', '👋', '💃', '🕺', '🙋', '🙆',
];
const EMOJI_OBJECTS = [
'❤️', '💔', '💯', '🔥', '✨', '🎉', '🎂', '☕', '🍺', '🌹', '🎁', '🎈', '⭐', '🌈', '☀️', '🌙', '⚡', '💧', '🎵', '🎮',
'📷', '📱', '💻', '📌', '📎', '🔔', '💰', '⏰', '🔑', '💡', '📖', '✏️', '🍎', '🍕', '🍔', '🍜', '🎄', '🎃', '⚽', '🏆',
];
type EmojiTab = 'recent' | 'faces' | 'gestures' | 'objects';
const TABS: { key: EmojiTab; label: string; list: string[] }[] = [
{ key: 'recent', label: '最近', list: [] },
{ key: 'faces', label: '表情', list: EMOJI_FACES },
{ key: 'gestures', label: '手势', list: EMOJI_GESTURES },
{ key: 'objects', label: '物品', list: EMOJI_OBJECTS },
];
interface EmojiPanelProps {
onPick: (emoji: string) => void;
}
export function EmojiPanel({ onPick }: EmojiPanelProps) {
const [tab, setTab] = useState<EmojiTab>('recent');
const recent = (() => {
try {
return JSON.parse(localStorage.getItem('im-recent-emoji') || '[]') as string[];
} catch {
return [];
}
})();
const pick = (emoji: string) => {
const next = [emoji, ...recent.filter((e) => e !== emoji)].slice(0, 36);
try {
localStorage.setItem('im-recent-emoji', JSON.stringify(next));
} catch {
/* storage unavailable -- recents just won't persist */
}
onPick(emoji);
};
const list = tab === 'recent' ? recent : (TABS.find((t) => t.key === tab)?.list ?? []);
return (
<div className="w-[380px] rounded-xl border border-border-panel bg-white p-2 shadow-menu">
<div className="mb-1 flex gap-1 px-1">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`rounded-lg px-2.5 py-1 text-xs ${tab === t.key ? 'bg-accent-soft-bg text-accent' : 'text-text-tertiary-2'}`}
>
{t.label}
</button>
))}
</div>
{list.length === 0 ? (
<div className="py-6 text-center text-xs text-text-tertiary-4">暂无最近使用</div>
) : (
<div className="grid grid-cols-12 gap-1">
{list.map((emoji, i) => (
<button
key={`${emoji}-${i}`}
type="button"
onClick={() => pick(emoji)}
className="grid h-8 w-8 place-items-center rounded-lg text-lg hover:bg-surface-rail"
>
{emoji}
</button>
))}
</div>
)}
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import { Icon } from '@/app/Icon';
export function EmptyMain() {
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-surface-content text-text-tertiary-4">
<Icon name="forum" className="!text-5xl" />
<span className="text-sm">选择一个会话开始聊天</span>
</div>
);
}
@@ -0,0 +1,188 @@
import { useMemo, useState } from 'react';
import type { Contact } from 'tinode-sdk';
import { Icon } from '@/app/Icon';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
const MAX_COMMENT_LENGTH = 200;
interface ForwardPickerModalProps {
onPick: (targetTopics: string[], comment: string) => void;
onClose: () => void;
}
/**
* README §3.6: multi-select targets + an optional comment, sent in one action. Used by the message
* context menu's single-message "转发", the multi-select bulk bar's "转发", and favorites' bulk
* forward -- all three previously shared this component when it only supported picking ONE target
* and sending immediately with no comment, meaning forwarding to several people required repeating
* the whole picker flow once per recipient. `forwardFavorites` (favorites.ts) already accepts
* `target_topics: string[]`/`comment` server-side; chat-message forwarding has no such batch REST
* endpoint (`forwardMessage` in lib/tinode/send.ts publishes one native {pub} per target instead),
* so callers loop `targetTopics` themselves -- this component only collects the selection.
*/
export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) {
const contacts = useChatList();
const tinode = useTinode();
const [query, setQuery] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const [comment, setComment] = useState('');
useEscapeKey(onClose);
const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim()));
// The list used to mix friends and groups in one flat run -- every other list in this app
// (ChatListColumn's pinned/sorted rows, ContactsListColumn) already keeps the two visually
// separate, so this was the odd one out and made a specific group hard to spot among dozens of
// P2P contacts.
const friends = useMemo(
() => filtered.filter((c) => !!c.topic && !tinode.getTopic(c.topic).isGroupType()),
[filtered, tinode],
);
const groups = useMemo(
() => filtered.filter((c) => !!c.topic && tinode.getTopic(c.topic).isGroupType()),
[filtered, tinode],
);
const toggle = (topic: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(topic)) next.delete(topic);
else next.add(topic);
return next;
});
};
// 好友/群聊各自独立的全选 -- 一次转发常常只想覆盖其中一类(比如"发给所有群"但不想带上私聊
// 好友),一个笼统的全选做不到这一点,所以拆成两个独立开关而不是列表整体一个。
const friendTopics = useMemo(() => friends.map((c) => c.topic).filter((t): t is string => !!t), [friends]);
const groupTopics = useMemo(() => groups.map((c) => c.topic).filter((t): t is string => !!t), [groups]);
const allFriendsSelected = friendTopics.length > 0 && friendTopics.every((t) => selected.has(t));
const allGroupsSelected = groupTopics.length > 0 && groupTopics.every((t) => selected.has(t));
const toggleSectionSelectAll = (topics: string[], allSelected: boolean) => {
setSelected((prev) => {
if (allSelected) {
// Only drop the ones the CURRENT search actually shows -- a selection made under an earlier,
// now-cleared query shouldn't be wiped out by un-selecting "all" of a narrower view.
const next = new Set(prev);
for (const t of topics) next.delete(t);
return next;
}
return new Set([...prev, ...topics]);
});
};
const send = () => {
if (selected.size === 0) return;
onPick([...selected], comment.trim());
};
const renderRow = (c: Contact) => {
const isSelected = !!c.topic && selected.has(c.topic);
return (
<button
key={c.topic}
type="button"
onClick={() => c.topic && toggle(c.topic)}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left hover:bg-surface-rail"
>
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${avatarBgClass(c.topic || '')}`}
>
{avatarLetter(c.public?.fn)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
<span
className={`grid h-5 w-5 flex-none place-items-center rounded-full border ${isSelected ? 'border-accent bg-accent' : 'border-[#d5d9df]'}`}
>
{isSelected ? <Icon name="check" className="text-[14px] text-white" /> : null}
</span>
</button>
);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[560px] w-[420px] flex-col rounded-xl bg-white shadow-modal" onClick={(e) => e.stopPropagation()}>
<header className="flex h-14 flex-none items-center justify-between border-b border-border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">
转发到{selected.size ? `(已选 ${selected.size} 个接收方)` : ''}
</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<div className="flex-none px-4 pt-3">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索会话或联系人"
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2"
/>
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{friends.length ? (
<>
<div className="flex items-center justify-between px-3 pb-1 pt-2">
<span className="text-xs text-text-tertiary-2">好友</span>
<button
type="button"
onClick={() => toggleSectionSelectAll(friendTopics, allFriendsSelected)}
className="text-xs text-accent"
>
{allFriendsSelected ? '取消全选' : '全选'}
</button>
</div>
{friends.map(renderRow)}
</>
) : null}
{groups.length ? (
<>
<div className="flex items-center justify-between px-3 pb-1 pt-2">
<span className="text-xs text-text-tertiary-2">群聊</span>
<button
type="button"
onClick={() => toggleSectionSelectAll(groupTopics, allGroupsSelected)}
className="text-xs text-accent"
>
{allGroupsSelected ? '取消全选' : '全选'}
</button>
</div>
{groups.map(renderRow)}
</>
) : null}
{!friends.length && !groups.length ? (
<div className="flex flex-col items-center justify-center gap-2 py-10 text-text-tertiary-4">
<Icon name="search_off" className="!text-4xl" />
<span className="text-sm">未找到相关联系人</span>
</div>
) : null}
</div>
<div className="flex-none border-t border-border-list-item p-3">
<textarea
value={comment}
onChange={(e) => setComment(e.target.value.slice(0, MAX_COMMENT_LENGTH))}
rows={2}
placeholder="附言(选填)"
className="w-full resize-none rounded-lg bg-surface-input p-2.5 text-sm outline-none"
/>
<button
type="button"
onClick={send}
disabled={selected.size === 0}
className="mt-2 h-9 w-full rounded-lg bg-btn text-sm font-medium text-white disabled:bg-btn-disabled"
>
发送{selected.size ? `(${selected.size})` : ''}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,393 @@
import { useState } from 'react';
import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { getAllGroupMembers } from '@/lib/api/groups';
import { getTopicMedia, searchTopicMessages, type MediaFetchType, type MediaItem, type MediaType, type SearchHit } from '@/lib/api/messages';
import { checkUrlSafety } from '@/lib/api/security';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useAuthedBlobUrl, useDebouncedValue, useEscapeKey } from '@/lib/hooks';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatEventTimestamp } from '@/lib/messages/time';
import { useMessageJumpStore } from '@/stores/messageJump';
import { useSessionStore } from '@/stores/session';
type Tab = 'text' | MediaType;
const TABS: { key: Tab; label: string; icon: string }[] = [
{ key: 'text', label: '聊天记录', icon: 'search' },
{ key: 'image', label: '图片', icon: 'image' },
{ key: 'video', label: '视频', icon: 'videocam' },
{ key: 'file', label: '文件', icon: 'description' },
{ key: 'link', label: '链接', icon: 'link' },
];
function formatDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/** README §3.4 grid thumbnails -- same inline-vs-authenticated-fetch duality as AttachmentPreview's
* ImageAttachment (a grid cell needs its own aspect-square crop, so this doesn't reuse that
* component directly, but reads the same `ent[0].data.{val,ref}` shape). */
function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
const data = item.ent?.[0]?.data;
const inline = typeof data?.val === 'string' ? `data:${data.mime || 'image/jpeg'};base64,${data.val}` : null;
const remote = useAuthedBlobUrl(inline ? undefined : data?.ref);
const src = inline || remote.url;
return (
<div className="relative aspect-square overflow-hidden bg-surface-rail">
{src ? (
<img src={src} alt="" className="h-full w-full object-cover" />
) : (
<div className="grid h-full w-full place-items-center">
<Icon name={remote.error ? 'broken_image' : 'image'} className="text-[24px] text-text-tertiary-3" />
</div>
)}
{isVideo && src ? (
<div className="absolute inset-0 bg-black/10">
<Icon name="play_circle" className="absolute inset-0 m-auto text-[32px] text-white/90" />
{data?.duration ? (
<span className="absolute bottom-1 right-1.5 rounded bg-black/50 px-1 text-[11px] text-white">{formatDuration(data.duration)}</span>
) : null}
</div>
) : null}
</div>
);
}
/** Renders a search hit's original message content (text/file/voice/...) via the same Drafty
* formatter regular chat bubbles use -- `snapshot.content` is either a real, complete Drafty
* document OR (for a plain unformatted text message, confirmed live) a bare string, exactly the
* same duality `renderDrafty` already resolves for every other caller -- so this passes it straight
* through rather than picking fields back out of it by hand. The previous version's manual
* `{txt:content.txt||' ', ...}` reconstruction silently produced a single blank space for the bare-
* string case (`content.txt` on a string primitive is `undefined`), which is exactly what a live
* search against a real plain-text message showed before this fix. */
function SnapshotContent({ item, highlightQuery }: { item: SearchHit; highlightQuery?: string }) {
const content = item.snapshot?.content;
if (!content) return <span className="text-text-tertiary-4">(内容不可用)</span>;
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
}
/**
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat history must pass a server-side risk
* check before opening (the server fetches/classifies the URL out-of-band; the client never probes
* it directly). `warn` still lets the user proceed after an explicit confirm; `block` does not.
* This only guards the enrichment shape (`item.url` etc, per docs) -- if a hit doesn't carry that
* (unconfirmed live, see SearchHit's doc comment), it falls back to the plain snapshot rendering,
* whose inline links aren't gated here (same as any other chat-bubble link).
*/
function LinkResult({ item }: { item: SearchHit & { url?: string; title?: string; summary?: string } }) {
const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'history_search') });
if (!item.url) return <SnapshotContent item={item} />;
const open = async () => {
let result;
try {
result = await safetyCheck.mutateAsync(item.url!);
} catch {
window.alert('链接安全检查失败,请稍后重试。');
return;
}
if (result.decision === 'block') {
window.alert('该链接存在安全风险,已被拦截。');
return;
}
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
window.open(item.url, '_blank', 'noopener,noreferrer');
};
return (
<div className="rounded-lg bg-surface-rail px-3.5 py-3">
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.ts))}</div>
{item.title ? <div className="mt-0.5 truncate text-sm font-medium text-text-body">{item.title}</div> : null}
<button
type="button"
onClick={open}
disabled={safetyCheck.isPending}
className="mt-0.5 line-clamp-2 break-all text-left text-sm text-accent disabled:opacity-60"
>
{item.url}
</button>
{item.summary ? <div className="mt-0.5 line-clamp-2 text-xs text-text-tertiary-2">{item.summary}</div> : null}
</div>
);
}
function LoadMoreButton({ onClick, loading }: { onClick: () => void; loading: boolean }) {
return (
<button type="button" onClick={onClick} disabled={loading} className="py-3 text-center text-sm text-accent disabled:opacity-60">
{loading ? '加载中…' : '加载更多'}
</button>
);
}
interface HistorySearchModalProps {
topicId: string;
initialTab?: Tab;
onClose: () => void;
/** For sender-name/avatar resolution on the 消息 tab (`05-favorites-history.dc.html #1b`'s rows
* each carry the sender's name + colored avatar, e.g. "陈明远"/"接语彤"/"我") -- a group needs a
* member-list lookup per hit since `from` can be anyone; a P2P conversation only ever has two
* possible senders (me or the peer), so the caller's already-resolved `peerName` is enough. */
isGroup: boolean;
peerName: string;
}
/** README §3.4's "查找聊天记录 ›" + 4-icon grid, opened as a centered modal (README §1.5). */
export function HistorySearchModal({ topicId, initialTab = 'text', onClose, isGroup, peerName }: HistorySearchModalProps) {
const [tab, setTab] = useState<Tab>(initialTab);
const [query, setQuery] = useState('');
const debouncedQuery = useDebouncedValue(query, 300);
useEscapeKey(onClose);
const myUserId = useSessionStore((s) => s.myUserId);
const membersQuery = useQuery({
queryKey: ['group-members', topicId],
queryFn: () => getAllGroupMembers(topicId),
enabled: isGroup,
});
// Confirmed live: this endpoint's `from` (both the top-level field and `snapshot.from`) comes back
// with its `usr` prefix STRIPPED -- the same quirk already documented for favorites' own
// `snapshot.from` (see favorites.ts's header comment), just discovered here independently rather
// than assumed to apply. Comparing it directly against `myUserId`/`GroupMember.user_id` (both full
// `usr...` ids) would never match "me" or any real member, so every sender always fell back to the
// raw id. Reconstructing the full id is safe specifically because the stripping is a fixed, known
// prefix removal, not a lossy truncation.
const normalizeSender = (from: string) => (from.startsWith('usr') ? from : `usr${from}`);
const senderLabel = (from: string): string => {
const full = normalizeSender(from);
if (myUserId && full === myUserId) return '我';
if (isGroup) return membersQuery.data?.find((m) => m.user_id === full)?.display_name || from;
return peerName;
};
const searchQuery = useInfiniteQuery({
queryKey: ['topic-search', topicId, debouncedQuery],
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, 'text', pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'text' && debouncedQuery.trim().length > 0,
});
const searchItems = searchQuery.data?.pages.flatMap((p) => p.items) ?? [];
const mediaQuery = useInfiniteQuery({
queryKey: ['topic-media', topicId, tab],
queryFn: ({ pageParam }) => getTopicMedia(topicId, tab as MediaFetchType, pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'image' || tab === 'video',
});
const mediaItems = mediaQuery.data?.pages.flatMap((p) => p.items) ?? [];
const fileLinkQuery = useInfiniteQuery({
queryKey: ['topic-search-media', topicId, tab, debouncedQuery],
// `searchTopicMessages` genuinely supports a keyword filter for file/link results (unlike
// `getTopicMedia`'s image/video browse, which is date-grouped, not keyword-searchable, per its
// own signature) -- this used to hardcode `''`, silently discarding the search box's query the
// moment a user switched off the 消息 tab, even though the box itself is spec'd as one shared
// header element sitting above all four tabs, not a 消息-tab-only control.
queryFn: ({ pageParam }) => searchTopicMessages(topicId, debouncedQuery, tab as 'file' | 'link', pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
enabled: tab === 'file' || tab === 'link',
});
const fileLinkItems = fileLinkQuery.data?.pages.flatMap((p) => p.items) ?? [];
// Search hits carry `seq` (used until now only for the timestamp/`key`) but nothing acted on it --
// clicking a result did nothing at all. ConversationView owns the actual scroll-to/highlight logic
// (it needs the message loaded first, which this modal has no reason to know how to do); this just
// hands off the target and gets out of the way.
const jumpToMessage = (seq: number) => {
useMessageJumpStore.getState().requestJump(topicId, seq);
onClose();
};
return (
// README §6.3 (mock `05/1e`): mobile is a full-bleed PAGE, not a dimmed centered dialog -- no
// overlay tint, no rounded card, search input lives in the header itself (with a "取消" text
// button instead of a close icon). Desktop (`md`+) keeps the original centered 720x620 dialog
// exactly as before.
<div className="fixed inset-0 z-50 flex items-center justify-center bg-transparent md:bg-[#22262d]/25" onClick={onClose}>
<div
className="flex h-full w-full flex-col bg-white md:h-[620px] md:w-[720px] md:rounded-xl md:shadow-modal"
onClick={(e) => e.stopPropagation()}
>
<header className="hidden h-14 flex-none items-center justify-between border-b border-border-list-item px-5 md:flex">
<strong className="text-[15px] font-semibold text-text-heading">查找聊天记录</strong>
<button type="button" onClick={onClose} className="grid h-8 w-8 place-items-center text-text-tertiary-4">
<Icon name="close" />
</button>
</header>
<header className="flex h-[52px] flex-none items-center gap-2.5 px-4 md:hidden">
<div className="flex h-9.5 flex-1 items-center gap-2 rounded-[10px] bg-surface-search px-2.5">
<Icon name="search" className="text-[20px] text-text-placeholder-2" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
disabled={tab === 'image' || tab === 'video'}
className="w-full min-w-0 bg-transparent text-[15px] outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
/>
{query ? (
<button type="button" onClick={() => setQuery('')} className="grid flex-none place-items-center">
<Icon name="cancel" className="text-[18px] text-text-placeholder-2" />
</button>
) : null}
</div>
<button type="button" onClick={onClose} className="flex-none text-[15px] text-accent">
取消
</button>
</header>
<div className="flex flex-none gap-1 overflow-x-auto border-b border-border-list-item px-5 pt-3">
{TABS.map((t) => (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className={`flex flex-none items-center gap-1.5 whitespace-nowrap rounded-t-lg px-3 py-2 text-sm ${
tab === t.key ? 'border-b-2 border-accent font-medium text-accent' : 'text-text-secondary'
}`}
>
<Icon name={t.icon} className="text-[16px]" />
{t.label}
</button>
))}
</div>
<div className="hidden flex-none px-5 py-3 md:block">
<div className="flex h-9 items-center rounded-lg bg-surface-search px-3">
<Icon name="search" className="text-[18px] text-text-placeholder-2" />
<input
autoFocus
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={tab === 'text' ? '搜索聊天记录' : tab === 'file' ? '搜索文件' : tab === 'link' ? '搜索链接' : '搜索聊天记录'}
disabled={tab === 'image' || tab === 'video'}
className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2 disabled:cursor-not-allowed"
/>
</div>
</div>
{tab === 'text' ? (
<div className="flex flex-1 flex-col overflow-hidden">
<div className="flex-1 overflow-y-auto px-5 pb-4">
{!debouncedQuery.trim() ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">输入关键词搜索这个会话的聊天记录</div>
) : searchQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">搜索中…</div>
) : searchQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>搜索失败,请重试</span>
<button type="button" onClick={() => searchQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !searchItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">没有找到相关消息</div>
) : (
<>
<ul className="flex flex-col gap-1">
{searchItems.map((item) => {
const fullFrom = normalizeSender(item.from);
const name = senderLabel(item.from);
return (
<li key={item.seq}>
<button
type="button"
onClick={() => jumpToMessage(item.seq)}
className="flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left hover:bg-surface-rail"
>
<span
className={`grid h-10 w-10 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
myUserId && fullFrom === myUserId ? 'bg-accent' : avatarBgClass(fullFrom)
}`}
>
{avatarLetter(name)}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
<span className="truncate text-text-tertiary-2">{name}</span>
<span className="flex-none">{formatEventTimestamp(new Date(item.ts))}</span>
</div>
<div className="truncate">
<SnapshotContent item={item} highlightQuery={debouncedQuery} />
</div>
</div>
</button>
</li>
);
})}
</ul>
{searchQuery.hasNextPage ? (
<LoadMoreButton onClick={() => searchQuery.fetchNextPage()} loading={searchQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
</div>
) : tab === 'image' || tab === 'video' ? (
<div className="flex-1 overflow-y-auto p-5">
{mediaQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : mediaQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => mediaQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !mediaItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
) : (
<>
<div className="grid grid-cols-4 gap-0.5 overflow-hidden rounded-lg">
{mediaItems.map((item) => (
<MediaThumb key={item.seq} item={item} isVideo={tab === 'video'} />
))}
</div>
{mediaQuery.hasNextPage ? (
<LoadMoreButton onClick={() => mediaQuery.fetchNextPage()} loading={mediaQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto p-5">
{fileLinkQuery.isLoading ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">加载中…</div>
) : fileLinkQuery.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => fileLinkQuery.refetch()} className="text-accent">
重试
</button>
</div>
) : !fileLinkItems.length ? (
<div className="py-10 text-center text-sm text-text-tertiary-4">暂无内容</div>
) : (
<>
<div className="flex flex-col gap-2">
{fileLinkItems.map((item) =>
tab === 'link' ? (
<LinkResult key={item.seq} item={item} />
) : (
<SnapshotContent key={item.seq} item={item} highlightQuery={debouncedQuery} />
),
)}
</div>
{fileLinkQuery.hasNextPage ? (
<LoadMoreButton onClick={() => fileLinkQuery.fetchNextPage()} loading={fileLinkQuery.isFetchingNextPage} />
) : null}
</>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,79 @@
import { useQuery } from '@tanstack/react-query';
import { Icon } from '@/app/Icon';
import { getGroupMembers, type GroupRole } from '@/lib/api/groups';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useDebouncedValue } from '@/lib/hooks';
import { MENTION_ALL_SENTINEL, type PendingMention } from '@/lib/messages/mentions';
const ROLE_LABEL: Record<GroupRole, string> = { owner: '群主', admin: '管理员', member: '' };
interface MentionPickerProps {
topicId: string;
query: string;
onPick: (choice: PendingMention) => void;
}
/**
* 设计稿 4.3:输入 `@` 触发的 260px 浮层。候选来自 `GET .../members?query=&mentionable=true`
* (协议文档 1052/1735 行)-- 这一个响应同时带 `items`(已经是可提及成员,不用再拿全量列表自己过滤)
* 和 `permissions.can_mention_all`,比向 ConversationView 另外要这两样数据都便宜。"所有人"是虚拟项,
* 不对应真实成员,只在 `can_mention_all` 为真时出现在首位。
*/
export function MentionPicker({ topicId, query, onPick }: MentionPickerProps) {
const debouncedQuery = useDebouncedValue(query, 200);
const membersQuery = useQuery({
queryKey: ['group-mentionable-members', topicId, debouncedQuery],
queryFn: () => getGroupMembers(topicId, { query: debouncedQuery, mentionable: true }),
});
const items = membersQuery.data?.items || [];
const canMentionAll = !!membersQuery.data?.permissions?.can_mention_all;
const showAll = canMentionAll && (!query || '所有人'.includes(query));
if (!showAll && items.length === 0 && !membersQuery.isLoading) return null;
return (
<div className="w-[260px] overflow-hidden rounded-[10px] border border-border-panel bg-white py-1 shadow-menu">
{membersQuery.isLoading ? (
<div className="px-3 py-4 text-center text-xs text-text-tertiary-4">加载中…</div>
) : (
<>
{showAll ? (
<button
type="button"
onClick={() => onPick({ userId: MENTION_ALL_SENTINEL, label: '所有人' })}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
>
<span className="grid h-7 w-7 flex-none place-items-center rounded-lg bg-accent text-white">
<Icon name="group" className="text-[16px]" />
</span>
<span className="text-sm text-text-body">所有人</span>
</button>
) : null}
{items.map((m) => (
<button
key={m.user_id}
type="button"
onClick={() => onPick({ userId: m.user_id, label: m.display_name || m.user_id })}
className="flex w-full items-center gap-2 px-3 py-2 text-left hover:bg-[#f2f6fd]"
>
<span className={`grid h-7 w-7 flex-none place-items-center rounded-lg text-xs font-medium text-white ${avatarBgClass(m.user_id)}`}>
{avatarLetter(m.display_name)}
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-body">{m.display_name || m.user_id}</span>
{m.role !== 'member' ? (
<span
className={`flex-none rounded-[3px] border px-1 text-[10px] leading-[14px] ${
m.role === 'owner' ? 'border-warn text-warn' : 'border-accent text-accent'
}`}
>
{ROLE_LABEL[m.role]}
</span>
) : null}
</button>
))}
</>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More