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>
This commit is contained in:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
+50 -7
View File
@@ -3,16 +3,32 @@ import { Navigate, createBrowserRouter, RouterProvider } from 'react-router-dom'
import { AppShell } from '@/app/AppShell';
import { RequireAuth } from '@/app/RequireAuth';
import { SectionPlaceholder } from '@/app/SectionPlaceholder';
import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage';
import { LoginPage } from '@/features/auth/LoginPage';
import { RegisterPage } from '@/features/auth/RegisterPage';
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 { AboutPage } from '@/features/settings/AboutPage';
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();
@@ -35,15 +51,42 @@ const router = createBrowserRouter([
element: <MessagesLayout />,
children: [
{ index: true, element: <EmptyMain /> },
{ path: 'system', element: <SystemMessagesView /> },
{ path: ':topicId', element: <ConversationView /> },
],
},
{ path: 'contacts', element: <SectionPlaceholder title="通讯录" /> },
{ path: 'favorites', element: <SectionPlaceholder title="我的收藏" /> },
{ path: 'files', element: <SectionPlaceholder title="文件助手" /> },
{ path: 'chat/system', element: <SectionPlaceholder title="系统消息" /> },
{ path: 'calls', element: <SectionPlaceholder title="通话记录" /> },
{ path: 'settings', element: <SectionPlaceholder title="设置" /> },
{
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 /> },
{ path: 'about', element: <AboutPage /> },
],
},
],
},
],
+10
View File
@@ -1,7 +1,11 @@
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';
@@ -15,6 +19,11 @@ import { NavRail } from './NavRail';
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"/"slf" have their own authoritative unread source in entry_badges (bootstrap.ts) -- excluded
// here in case they ever also show up as regular Tinode subscriptions in `contacts`, which would
// otherwise double-count the same unread messages into this badge.
@@ -36,6 +45,7 @@ export function AppShell() {
<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>
);
}
+5 -2
View File
@@ -1,7 +1,10 @@
/** Thin wrapper around a Material Icons ligature glyph (font loaded in index.html). */
/** 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 className={`material-icons select-none ${className}`} style={style}>
<i aria-hidden="true" className={`material-icons select-none ${className}`} style={style}>
{name}
</i>
);
+30 -8
View File
@@ -1,4 +1,4 @@
import { NavLink } from 'react-router-dom';
import { Link, useLocation } from 'react-router-dom';
import { Icon } from './Icon';
@@ -21,16 +21,38 @@ const BOTTOM_ITEMS: NavRailItem[] = [
{ to: '/settings', icon: 'settings', label: '设置' },
];
/** `/files` is `<Navigate to="/messages/slf" replace />` (App.tsx) -- the real URL right after
* clicking "文件助手" is `/messages/slf`, which a plain (non-`end`) NavLink match makes "消息"
* light up for (a same-topicId prefix match) while "文件助手" itself (`to: '/files'`, a path that
* no longer exists once redirected) never matches at all. Overriding both items' active state off
* the real pathname instead of trusting NavLink's own prefix match fixes both sides of the mix-up. */
function isNavItemActive(to: string, pathname: string): boolean {
if (to === '/files') return pathname === '/messages/slf';
if (to === '/messages') return pathname.startsWith('/messages') && pathname !== '/messages/slf';
return pathname === to || pathname.startsWith(`${to}/`);
}
function NavRailButton({ item }: { item: NavRailItem }) {
const location = useLocation();
const isActive = isNavItemActive(item.to, location.pathname);
return (
<NavLink
// 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). That's exactly `/files`'s own problem above -- NavLink's naive
// match would light up "消息" for `/messages/slf`, not "文件助手" -- so a second, independent
// aria-current computation fighting the same override this file already needed for `className`
// is a real, verifiable a11y bug (screen readers and sighted highlighting would disagree on
// which icon is current). Driving both off the one already-correct `isActive` avoids that
// entirely.
<Link
to={item.to}
title={item.label}
className={({ isActive }) =>
`relative grid h-11 w-11 place-items-center rounded-[10px] no-underline ${
isActive ? 'bg-accent-soft-bg text-accent' : 'text-text-secondary'
}`
}
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 ? (
@@ -38,7 +60,7 @@ function NavRailButton({ item }: { item: NavRailItem }) {
{item.badge}
</b>
) : null}
</NavLink>
</Link>
);
}
+38 -6
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from 'react';
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';
@@ -27,10 +28,12 @@ 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;
@@ -44,11 +47,16 @@ export function RequireAuth() {
.then((uid) => {
if (cancelled) return;
setMyUserId(uid);
subscribeMeTopic().catch(() => setContactsError('会话列表加载失败,请刷新页面重试'));
})
.catch(() => {
.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);
}
})
@@ -59,7 +67,19 @@ export function RequireAuth() {
cancelled = true;
window.clearTimeout(timeout);
};
}, [tenant, myUserId, hasToken, setMyUserId]);
}, [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/register that already called
// setMyUserId before this component ever mounted) -- LoginPage/RegisterPage used to each fire
// their 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) {
@@ -70,10 +90,22 @@ export function RequireAuth() {
</>
);
}
if (hasToken && !restoreFailed && !restoreTimedOut) {
// `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>
-7
View File
@@ -1,7 +0,0 @@
export function SectionPlaceholder({ title }: { title: string }) {
return (
<div className="flex h-full w-full items-center justify-center bg-surface-content text-sm text-text-tertiary-4">
{title}(后续阶段实现)
</div>
);
}
+6 -1
View File
@@ -29,7 +29,12 @@ export function Toast({ message, variant = 'success', onDone }: ToastProps) {
return (
<div
role="status"
className="fixed bottom-8 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-text-heading/94 px-4 py-3 text-sm text-white"
// `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}
@@ -84,13 +84,16 @@ export function EnterpriseRegisterPage() {
<div className="flex flex-col gap-1.5">
<label className="text-sm text-text-body">统一社会信用代码</label>
<input
value={creditCode}
onChange={(e) => setCreditCode(e.target.value.trim().toUpperCase().replace(/[^0-9A-Z]/g, ''))}
placeholder="请输入18位统一社会信用代码"
maxLength={18}
className="h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none"
/>
<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>
@@ -105,7 +108,7 @@ export function EnterpriseRegisterPage() {
onChange={(e) => setFile(e.target.files?.[0] || null)}
className="hidden"
/>
<Icon name="add_photo_alternate" className="text-[22px] text-text-tertiary-3" />
<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>
+3 -9
View File
@@ -6,9 +6,8 @@ import { Icon } from '@/app/Icon';
import { Agreement } from '@/features/auth/components/Agreement';
import { PasswordInput } from '@/features/auth/components/PasswordInput';
import { BASE_APP_NAME } from '@/lib/config';
import { connect, getTinode } from '@/lib/tinode/connection';
import { getTinode, loginBasic } from '@/lib/tinode/connection';
import { loginErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
import { subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
export function LoginPage() {
@@ -43,10 +42,7 @@ export function LoginPage() {
setError('');
try {
const tinode = getTinode();
if (!tinode.isConnected()) {
await connect();
}
const ctrl = await tinode.loginBasic(login.trim(), password);
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;
@@ -54,7 +50,6 @@ export function LoginPage() {
const authToken = tinode.getAuthToken();
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
setMyUserId(tinode.getCurrentUserID() || null);
subscribeMeTopic().catch(() => {});
navigate('/messages');
} catch (err) {
setError(loginErrorMessage(err));
@@ -132,10 +127,9 @@ export function LoginPage() {
马上注册
</Link>
</p>
<Agreement checked={agreed} onChange={setAgreed} />
</form>
</div>
<Agreement checked={agreed} onChange={setAgreed} />
</div>
</div>
);
+28 -7
View File
@@ -10,13 +10,21 @@ import { verifyRegisterCode } from '@/lib/api/auth';
import { ImApiError } from '@/lib/api/client';
import { getTinode, registerAccount } from '@/lib/tinode/connection';
import { registerErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors';
import { subscribeMeTopic } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
const USERNAME_RE = /^[a-z0-9]{1,10}$/;
const PHONE_RE = /^\d{11}$/;
const INVITE_RE = /^[A-Za-z]{5}$/;
/** 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);
}
interface FormErrors {
username?: string;
phone?: string;
@@ -52,6 +60,10 @@ export function RegisterPage() {
if (!tenant) return <Navigate to="/tenant" replace />;
const usingPhone = !!phone;
// `tenants/resolve`'s own real per-tenant password_policy (docs/IM-前后端对接文档.md:534) -- read
// off the tenant instead of a hardcoded guess, so this can never drift from ResetPasswordPage's
// own minimum (they'd silently diverged to two different hardcoded numbers before this existed).
const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -65,7 +77,7 @@ export function RegisterPage() {
} else if (!INVITE_RE.test(invite)) {
next.invite = '用户名注册需填写 5 位字母邀请码';
}
if (password.length < 8) next.password = '密码至少 8 位';
if (password.length < minPasswordLength) next.password = `密码至少 ${minPasswordLength} 位`;
setErrors(next);
setFormError('');
if (Object.keys(next).length || !agreed || submitting) return;
@@ -98,7 +110,6 @@ export function RegisterPage() {
const authToken = getTinode().getAuthToken();
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
setMyUserId((ctrl.params?.user as string | undefined) ?? null);
subscribeMeTopic().catch(() => {});
navigate('/messages');
} catch (err) {
setFormError(registerErrorMessage(err));
@@ -128,7 +139,7 @@ export function RegisterPage() {
<div className="flex flex-col gap-1.5">
<input
value={username}
onChange={(e) => setUsername(e.target.value.replace(/[^a-z0-9]/g, '').slice(0, 10))}
onChange={(e) => setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 10))}
placeholder="用户名(1-10位小写字母或数字)"
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 ${
@@ -153,9 +164,11 @@ export function RegisterPage() {
<span className="text-[15px] text-text-tertiary-2">+86</span>
<input
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
placeholder="请输入手机号"
type="tel"
inputMode="numeric"
autoComplete="tel"
className="w-full bg-transparent text-[15px] outline-none"
/>
</label>
@@ -163,12 +176,13 @@ export function RegisterPage() {
</div>
<div className="flex flex-col gap-1.5">
<div className="flex gap-2">
<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"
autoComplete="one-time-code"
className={`h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none ${
errors.code ? 'border border-danger-text' : ''
}`}
@@ -183,7 +197,14 @@ export function RegisterPage() {
{errors.code ? <div className="text-xs text-danger-border">{errors.code}</div> : null}
</div>
<PasswordInput value={password} onChange={setPassword} placeholder="设置密码" autoComplete="new-password" error={errors.password} />
<PasswordInput
value={password}
onChange={setPassword}
placeholder="设置密码"
autoComplete="new-password"
error={errors.password}
maxLength={tenant.passwordPolicy?.maxLength}
/>
<div className="flex flex-col gap-1.5">
{/* 邀请码大小写敏感(唯一索引按 ascii_bin 排序),不能像其它字段那样统一大小写 */}
+67 -11
View File
@@ -12,6 +12,15 @@ 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);
@@ -22,6 +31,10 @@ export function ResetPasswordPage() {
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('');
@@ -34,15 +47,28 @@ export function ResetPasswordPage() {
}, [phone]);
if (!tenant) return <Navigate to="/tenant" replace />;
// Same real per-tenant policy RegisterPage reads (docs/IM-前后端对接文档.md:534) -- was hardcoded
// to 8 here, matching Register's OWN hardcode by coincidence, not because either 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 (>= 8, no upper bound -- the backend's checkPasswordPolicy only
// enforces a minimum length, so the message says exactly that and nothing more).
if (password.length < 8) return setError('密码至少 8 位');
if (password !== confirm) return setError('两次输入的密码不一致');
// 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('');
@@ -58,12 +84,20 @@ export function ResetPasswordPage() {
};
return (
<div className="flex h-dvh w-full flex-col bg-surface-page px-6 py-8">
<Link to="/login" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
<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 -- same pattern as RegisterPage's own min-[900px]:hidden header. */}
<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">
<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>
@@ -82,14 +116,16 @@ export function ResetPasswordPage() {
<span className="text-[15px] text-text-tertiary-2">+86</span>
<input
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
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 gap-2">
<div className="flex items-center gap-2">
<input
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
@@ -100,8 +136,28 @@ export function ResetPasswordPage() {
<VerificationCodeButton phone={phone} scene="reset_password" onChallenge={setChallengeId} onError={setError} />
</div>
<PasswordInput value={password} onChange={setPassword} placeholder="新密码" autoComplete="new-password" />
<PasswordInput value={confirm} onChange={setConfirm} placeholder="再次输入新密码" autoComplete="new-password" />
<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"
+23 -1
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
@@ -10,7 +11,9 @@ 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('');
@@ -32,7 +35,26 @@ export function TenantEntryPage() {
// 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();
setTenant({ code: res.tenant.code, name: res.tenant.name, endpoints: res.endpoints, host, secure });
// 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 : '企业码不存在或已停用,请核实后重试');
@@ -8,9 +8,10 @@ interface PasswordInputProps {
placeholder: string;
autoComplete?: string;
error?: string;
maxLength?: number;
}
export function PasswordInput({ value, onChange, placeholder, autoComplete, error }: PasswordInputProps) {
export function PasswordInput({ value, onChange, placeholder, autoComplete, error, maxLength }: PasswordInputProps) {
const [visible, setVisible] = useState(false);
return (
@@ -26,6 +27,7 @@ export function PasswordInput({ value, onChange, placeholder, autoComplete, erro
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
autoComplete={autoComplete}
maxLength={maxLength}
className="w-full bg-transparent text-[15px] outline-none"
/>
<button
@@ -68,7 +68,7 @@ export function VerificationCodeButton({ phone, scene, onChallenge, onError }: V
type="button"
onClick={click}
disabled={busy || seconds > 0 || !PHONE_RE.test(phone)}
className="w-32 flex-none rounded-lg bg-accent-soft-bg text-sm text-accent disabled:bg-[#e6e9ee] disabled:text-text-tertiary-3"
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: 'files', route: '/files', icon: 'folder', label: '文件助手', colorClass: 'bg-success-bg text-success' },
{ 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 + 4 fixed entries (新朋友/群聊/文件助手/黑名单) + 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>
);
}
+71 -10
View File
@@ -74,6 +74,58 @@ export function FileAttachment({ data }: { data: FileData }) {
);
}
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;
@@ -81,23 +133,24 @@ interface AudioData {
duration?: number;
}
function formatAudioDuration(ms: number | undefined): string {
const totalSeconds = Math.round((ms || 0) / 1000);
return `${Math.floor(totalSeconds / 60)}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
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);
return () => audioEl.removeEventListener('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 = () => {
@@ -108,15 +161,23 @@ export function AudioAttachment({ data }: { data: AudioData }) {
return;
}
if (audioEl.src !== src) audioEl.src = src;
audioEl.play().catch(() => {});
setPlaying(true);
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={playing ? 'pause_circle' : 'play_circle'} className="text-[24px] text-accent" />
<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 text-text-secondary">{remote.loading ? '…' : formatAudioDuration(data.duration)}</span>
<span className={`flex-none text-xs ${playError ? 'text-danger-text' : 'text-text-secondary'}`}>
{playError ? '播放失败' : remote.loading ? '…' : formatDuration(data.duration)}
</span>
</button>
);
}
+74 -15
View File
@@ -1,22 +1,31 @@
import { useState } from 'react';
import { Link, useParams } from 'react-router-dom';
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 } from '@/lib/tinode/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
import { useSessionStore } from '@/stores/session';
/**
* README §2.1's two always-pinned utility rows. Real, confirmed backend facts (docs/IM-前后端对接
* 文档.md:909): topic names are fixed ("slf"/"sys"), but 系统消息's unread comes from a separate
* notifications table (not the topic itself) and 文件助手's own dedicated 3-column layout (README
* §9.2) is Phase 8, not built yet -- so both route to the same SectionPlaceholder-backed "later
* phase" destinations the rest of the nav already uses (contacts/favorites/calls/settings), rather
* than a half-working ConversationView that assumes a regular P2P/group contact.
* 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). `/files` redirects into a real
* ConversationView on the `slf` topic instead -- README §9.2's own dedicated 3-column file browser
* (categories, storage quota, upload progress) has no backing REST API at all (confirmed via a
* research pass), so a plain conversation view is the honest choice over a quota card with invented
* numbers.
*/
const PINNED_ENTRIES = [
{
@@ -24,7 +33,7 @@ const PINNED_ENTRIES = [
badgeKey: 'system_unread_count' as const,
label: '系统消息',
icon: 'notifications',
route: '/chat/system',
route: '/messages/system',
colorClass: 'bg-warn-bg text-warn',
},
{
@@ -37,16 +46,32 @@ const PINNED_ENTRIES = [
},
];
/** Chat-list row sort: by `touched` (last activity) descending. Pinned-conversations-first isn't
* wired yet -- that's driven by the P2P conversation-settings API (GET/PATCH /conversations/{topic}/
* settings {pinned}), which the detail panel doesn't build until its "置顶聊天" toggle lands. */
/** 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 sorted = [...contacts].sort((a, b) => (b.touched?.getTime() ?? 0) - (a.touched?.getTime() ?? 0));
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);
@@ -55,7 +80,11 @@ export function ChatListColumn() {
<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>
<Icon name="add_comment" className="text-text-heading" />
{/* 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" />
@@ -69,11 +98,17 @@ export function ChatListColumn() {
<div className="flex flex-1 flex-col overflow-y-auto">
{pinned.map((entry) => {
const unread = bootstrap.data?.entry_badges?.[entry.badgeKey] || 0;
// `/files` redirects to `/messages/slf`, so its "currently open" check is keyed off the
// resulting topicId rather than the route it navigates from, unlike `/messages/system`
// which is the real, final path.
const active = entry.route === '/files' ? topicId === 'slf' : 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-list-item bg-white py-2.5 pl-4 pr-3 no-underline hover:bg-[#f6f8fb]"
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" />
@@ -101,11 +136,14 @@ export function ChatListColumn() {
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-list-item py-2.5 pl-4 pr-3 no-underline ${
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]'
}`}
>
@@ -120,12 +158,23 @@ export function ChatListColumn() {
<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>
{contact.unread ? (
{/* 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>
@@ -136,6 +185,16 @@ export function ChatListColumn() {
})
)}
</div>
{creatingGroup ? (
<CreateGroupModal
onClose={() => setCreatingGroup(false)}
onCreated={(topic) => {
setCreatingGroup(false);
navigate(`/messages/${topic}`);
}}
/>
) : null}
</aside>
);
}
+419 -80
View File
@@ -1,5 +1,6 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
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';
@@ -8,28 +9,35 @@ 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, sendReply, sendText } from '@/lib/tinode/send';
import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send';
import { closeConversation, 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 -- true always at >=1280px (docked, no close
* affordance), true/false at 960-1279px depending on the drawer's open state. Drives the header
* "i" button's highlight; previously hardcoded to the breakpoint itself (always blue at
* 960-1279px regardless of the drawer's real state, always grey at >=1280px despite the panel
* being permanently open there) rather than this actual state. */
/** 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). */
@@ -42,6 +50,10 @@ interface ContextMenuState {
msg: StoredMessage;
}
function notImplemented() {
window.alert('该功能暂未开放,如需联系客服。');
}
export function ConversationView() {
const { topicId } = useParams();
const { onToggleDetail, detailVisible, onOpenHistory } = useOutletContext<OutletCtx>();
@@ -60,6 +72,15 @@ export function ConversationView() {
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());
@@ -69,9 +90,22 @@ export function ConversationView() {
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
@@ -85,6 +119,15 @@ export function ConversationView() {
// 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;
@@ -107,6 +150,11 @@ export function ConversationView() {
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
@@ -121,6 +169,14 @@ export function ConversationView() {
// 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(() => {
@@ -132,15 +188,99 @@ export function ConversationView() {
// 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 isGroup = topic.isGroupType();
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
@@ -190,16 +330,92 @@ export function ConversationView() {
const text = draft.trim();
if (!text) return;
setDraft('');
setMentionTrigger(null);
if (replyTo) {
const label = myUserId && replyTo.from === myUserId ? '我' : peerName;
sendReply(topic, text, replyTo, label);
// 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);
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/
@@ -232,15 +448,37 @@ export function ConversationView() {
const bulkFavorite = () => {
if (selectedSeqs.size === 0) return;
favoriteMessages([...selectedSeqs].map((seq) => ({ topic: topicId, seq }))).catch((err) => showError(err, '收藏失败'));
// 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 = (targetTopic: string) => {
const target = tinode.getTopic(targetTopic);
for (const seq of selectedSeqs) {
const msg = topic.findMessage(seq);
if (msg) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败'));
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) {
const target = tinode.getTopic(targetTopic);
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, '附言发送失败'));
}
setBulkForwardOpen(false);
exitSelectMode();
@@ -270,7 +508,9 @@ export function ConversationView() {
setReplyTo(msg);
break;
case 'favorite':
favoriteMessages([{ topic: topicId, seq: msg.seq }]).catch((err) => showError(err, '收藏失败'));
favoriteMessages([{ topic: topicId, seq: msg.seq }])
.then(notifyFavoriteResult)
.catch((err) => showError(err, '收藏失败'));
break;
case 'forward':
setForwardMsg(msg);
@@ -312,19 +552,42 @@ export function ConversationView() {
{avatarLetter(peerName)}
</span>
<div className="flex flex-col">
<strong className="text-base font-semibold text-text-heading">{peerName}</strong>
<span className={`text-xs ${contact?.online ? 'text-online' : 'text-text-tertiary-2'}`}>
{contact?.online ? '在线' : '离线'}
</span>
<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>
</div>
<div className="flex items-center gap-1">
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="call" />
</button>
<button type="button" className="grid h-9.5 w-9.5 place-items-center rounded-lg text-text-tertiary-1">
<Icon name="videocam" />
</button>
{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')}
@@ -344,27 +607,56 @@ export function ConversationView() {
)}
</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) => (
<MessageRow
key={msg.seq}
topic={topic}
msg={msg}
mine={!!myUserId && msg.from === myUserId}
isGroup={isGroup}
peerName={peerName}
selectMode={selectMode}
selected={typeof msg.seq === 'number' && selectedSeqs.has(msg.seq)}
menuActive={typeof msg.seq === 'number' && menu?.msg.seq === msg.seq}
onToggleSelect={toggleSelect}
onContextMenu={(e, m) => setMenu({ x: e.clientX, y: e.clientY, msg: m })}
/>
))}
{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-around border-t border-border-panel bg-white">
<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)}
@@ -389,52 +681,83 @@ export function ConversationView() {
</button>
</div>
) : (
<div className="flex-none border-t border-border-panel bg-white px-4 pb-3 pt-2">
<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">
回复 {myUserId && replyTo.from === myUserId ? '我' : peerName}:{renderDrafty(replyTo.content)}
回复 {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-1 px-1 pb-1">
<button
type="button"
onClick={() => setEmojiOpen((v) => !v)}
className={`grid h-8.5 w-8.5 place-items-center rounded text-[22px] ${emojiOpen ? 'bg-accent-soft-bg text-accent' : 'text-icon-default'}`}
>
<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}
<label className="grid h-8.5 w-8.5 cursor-pointer place-items-center rounded text-[22px] text-icon-default">
<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 text-[22px] text-icon-default">
<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>
<Icon name="contact_page" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<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 text-[22px] text-icon-default"
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>
<Icon name="history" className="grid h-8.5 w-8.5 place-items-center rounded text-[22px] text-icon-default" />
<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
@@ -446,19 +769,25 @@ export function ConversationView() {
/>
) : (
<>
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入消息,Enter 发送,Shift+Enter 换行"
className="min-h-[72px] w-full resize-none bg-transparent text-[15px] leading-normal outline-none placeholder:text-text-placeholder-1"
/>
<div className="flex justify-end">
{/* 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 rounded-lg bg-btn px-5.5 text-sm font-medium text-white shadow-btn-sm disabled:bg-btn-disabled disabled:shadow-none"
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>
@@ -482,8 +811,12 @@ export function ConversationView() {
{forwardMsg ? (
<ForwardPickerModal
onClose={() => setForwardMsg(null)}
onPick={(targetTopic) => {
forwardMessage(topicId, forwardMsg, tinode.getTopic(targetTopic)).catch((err) => showError(err, '转发失败'));
onPick={(targetTopics, comment) => {
for (const targetTopic of targetTopics) {
const target = tinode.getTopic(targetTopic);
forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败'));
if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败'));
}
setForwardMsg(null);
}}
/>
@@ -491,7 +824,13 @@ export function ConversationView() {
{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>
);
}
+27 -9
View File
@@ -20,6 +20,7 @@ import {
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;
@@ -36,10 +37,13 @@ function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (
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]'}`}
// 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 top-0.5 h-[18px] w-[18px] rounded-full bg-white transition-transform ${checked ? 'translate-x-[20px]' : 'translate-x-0.5'}`}
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>
);
@@ -128,8 +132,8 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
};
return (
<aside className="flex h-full w-full flex-none flex-col overflow-y-auto border-l border-border-panel bg-white md:w-[300px]">
<header className="flex h-15 flex-none items-center justify-between border-b border-list-item px-5">
<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">
@@ -138,7 +142,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
) : null}
</header>
<div className="flex flex-col items-center gap-1 border-b border-list-item px-5 pb-4 pt-6">
<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>
@@ -156,18 +160,32 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
<Icon name="chat_bubble_outline" className="text-[20px]" />
<span className="text-xs text-text-secondary">发消息</span>
</button>
<button type="button" className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent">
<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" className="flex w-19 flex-col items-center gap-1 rounded-[10px] bg-surface-rail py-2.5 text-accent">
<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-list-item px-5 pb-3 pt-2 text-sm">
<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" />
@@ -189,7 +207,7 @@ export function DetailColumn({ topicId, peerName, onClose, onOpenHistory }: Deta
</div>
</div>
<div className="flex flex-col border-b border-list-item px-5 text-sm">
<div className="flex flex-col border-b border-border-list-item px-5 text-sm">
{remarkEditing ? (
<div className="flex items-center gap-2 py-3">
<span className="flex-none text-text-body">备注</span>
@@ -5,27 +5,52 @@ import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { useEscapeKey } from '@/lib/hooks';
import { useChatList } from '@/lib/tinode/hooks';
const MAX_COMMENT_LENGTH = 200;
interface ForwardPickerModalProps {
onPick: (targetTopic: string) => void;
onPick: (targetTopics: string[], comment: string) => void;
onClose: () => void;
}
/**
* Simplified single-target contact picker for the message context menu's "转发". The full design
* (README §3.6) supports multi-select + a merge/separate-forward toggle for the multi-select bulk
* bar's forward action -- this is the smaller single-message version, one tap sends immediately.
* 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 [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()));
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 send = () => {
if (selected.size === 0) return;
onPick([...selected], comment.trim());
};
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 justify-between border-b border-list-item px-5">
<strong className="text-[15px] font-semibold text-text-heading">转发到</strong>
<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>
@@ -43,21 +68,46 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps)
</div>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2">
{filtered.map((c) => (
<button
key={c.topic}
type="button"
onClick={() => c.topic && onPick(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 || '')}`}
{filtered.map((c) => {
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"
>
{avatarLetter(c.public?.fn)}
</span>
<span className="truncate text-sm text-text-body">{c.public?.fn || c.topic}</span>
</button>
))}
<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>
);
})}
</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>
+159 -46
View File
@@ -1,13 +1,17 @@
import { useState } from 'react';
import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
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;
@@ -55,22 +59,17 @@ function MediaThumb({ item, isVideo }: { item: MediaItem; isVideo: boolean }) {
}
/** Renders a search hit's original message content (text/file/voice/...) via the same Drafty
* formatter regular chat bubbles use -- `snapshot.content` is a real, complete Drafty document
* (docs/IM-前后端对接文档.md's flat `SearchFileHit`-style shape doesn't actually exist on this
* server; see messages.ts's SearchHit doc comment), so this passes it straight through rather than
* picking fields back out of it by hand. */
function SnapshotContent({ item }: { item: SearchHit }) {
* 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({
txt: content.txt || ' ',
fmt: (content.fmt as { at?: number; len?: number; tp?: string; key?: number }[] | undefined) || [{ at: -1 }],
ent: (content.ent || []).map((e) => ({ tp: e.tp || '', data: (e.data || {}) as Record<string, unknown> })),
})}
</div>
);
return <div className="text-sm text-text-body">{renderDrafty(content, highlightQuery)}</div>;
}
/**
@@ -131,14 +130,40 @@ 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 }: HistorySearchModalProps) {
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],
@@ -159,31 +184,72 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
const mediaItems = mediaQuery.data?.pages.flatMap((p) => p.items) ?? [];
const fileLinkQuery = useInfiniteQuery({
queryKey: ['topic-search-media', topicId, tab],
queryFn: ({ pageParam }) => searchTopicMessages(topicId, '', tab as 'file' | 'link', pageParam),
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#22262d]/25" onClick={onClose}>
<div className="flex h-[620px] w-[720px] 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-list-item px-5">
// 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 border-b border-list-item px-5 pt-3">
<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 items-center gap-1.5 rounded-t-lg px-3 py-2 text-sm ${
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'
}`}
>
@@ -193,40 +259,69 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
))}
</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-none px-5 py-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-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) => (
<li key={item.seq} className="rounded-lg px-3 py-2.5 hover:bg-surface-rail">
<div className="flex items-center justify-between text-xs text-text-tertiary-3">
<span>{formatEventTimestamp(new Date(item.ts))}</span>
</div>
<div className="mt-0.5 truncate">
<SnapshotContent item={item} />
</div>
</li>
))}
{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} />
@@ -239,6 +334,13 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
<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>
) : (
@@ -258,13 +360,24 @@ export function HistorySearchModal({ topicId, initialTab = 'text', onClose }: Hi
<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} />,
tab === 'link' ? (
<LinkResult key={item.seq} item={item} />
) : (
<SnapshotContent key={item.seq} item={item} highlightQuery={debouncedQuery} />
),
)}
</div>
{fileLinkQuery.hasNextPage ? (
@@ -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>
);
}
+101 -20
View File
@@ -1,26 +1,68 @@
import type { StoredMessage, Topic } from 'tinode-sdk';
import { Link } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { avatarBgClass, avatarLetter } from '@/lib/avatar';
import { isRecalled, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
import { callResultDisplay, contactCardContentOf, isRecalled, isServerDeleted, systemNoticeText, type ContactCardContent } from '@/lib/messages/dispatch';
import { renderDrafty } from '@/lib/messages/drafty';
import { formatBubbleTime, formatEventTimestamp } from '@/lib/messages/time';
import { isFailedStatus, isSendingStatus, outgoingStatusLabel } from '@/lib/messages/status';
import { retryMessage } from '@/lib/tinode/send';
/** 设计稿 4.3:气泡上方角色标签,边框样式(不是填色)-- 群主 #ff951c、管理员 #1677ff,均不加背景。
* 与 GroupMembersModal/GroupDetailColumn 里另外两种角色标签(填色 tag、圆角数字角标)故意不共用
* 一个组件:三处的视觉规格互不相同,抽象成一个带 variant 参数的组件不会比各写各的更清楚。 */
/** Distinct from a regular Drafty-rendered bubble -- see `contactCardContentOf`'s own doc comment
* for why this is dispatched by `head['x-im-type']` rather than a Drafty style tag. Tapping opens
* the real profile page, the one thing a contact-card message is fundamentally for. */
function ContactCardBubble({ content }: { content: ContactCardContent }) {
const name = content.display_name || content.public_account || content.user_id;
return (
<Link to={`/contacts/${content.user_id}`} className="flex w-[220px] items-center gap-3 no-underline">
<Avatar id={content.user_id} name={name} avatarFileId={content.avatar_file_id} className="h-11 w-11 flex-none rounded-lg text-base" />
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-text-body">{name}</span>
{content.public_account ? <span className="truncate text-xs text-text-tertiary-2">ID: {content.public_account}</span> : null}
</span>
</Link>
);
}
function MessageRoleTag({ role }: { role: 'owner' | 'admin' | undefined }) {
if (role === 'owner') return <span className="rounded-[3px] border border-warn px-1 text-[10px] leading-[14px] text-warn">群主</span>;
if (role === 'admin') return <span className="rounded-[3px] border border-accent px-1 text-[10px] leading-[14px] text-accent">管理员</span>;
return null;
}
interface MessageRowProps {
topic: Topic;
msg: StoredMessage;
mine: boolean;
isGroup: boolean;
peerName: string;
/** Only meaningful when `isGroup` -- the sender's real group-scoped name/role, resolved by
* ConversationView from its own member-list fetch. `peerName` alone is the GROUP's name, wrong
* for per-message attribution once there's more than one possible sender. */
senderName?: string;
senderRole?: 'owner' | 'admin' | 'member';
selectMode?: boolean;
selected?: boolean;
/** README §3.5: "选中气泡加 outline:2px solid #1677ff" -- true while this message is the open
* context menu's target. */
menuActive?: boolean;
/** True for a few seconds right after jumping here from a 查找聊天记录 search result (ConversationView's
* scroll-to-message flow) -- a brief background flash, distinct from `menuActive`'s permanent-while-
* open outline, since the two states mean different things (context menu open vs. "you asked to find
* this one") and can't reuse the same visual without conflating them. */
highlighted?: boolean;
onToggleSelect?: (seq: number) => void;
onContextMenu?: (e: React.MouseEvent, msg: StoredMessage) => void;
/** Fired when the inline "重试" retry (below) itself fails -- e.g. still offline. Without this the
* failed-send icon just silently stays put with no feedback at all that the retry attempt was
* even made. */
onError?: (message: string) => void;
}
export function MessageRow({
@@ -29,20 +71,28 @@ export function MessageRow({
mine,
isGroup,
peerName,
senderName,
senderRole,
selectMode,
selected,
menuActive,
highlighted,
onToggleSelect,
onContextMenu,
onError,
}: MessageRowProps) {
if (isServerDeleted(msg)) {
return <div className="py-1 text-center text-xs text-text-tertiary-4">此消息已被删除</div>;
return (
<div data-seq={msg.seq} className="py-1 text-center text-xs text-text-tertiary-4">
此消息已被删除
</div>
);
}
const noticeText = systemNoticeText(msg);
if (noticeText) {
return (
<div className="flex flex-col items-center gap-1 py-1">
<div data-seq={msg.seq} className="flex flex-col items-center gap-1 py-1">
<div className="text-xs text-text-tertiary-4">{noticeText}</div>
{msg.ts ? <div className="text-xs text-text-tertiary-4">{formatEventTimestamp(msg.ts)}</div> : null}
</div>
@@ -52,23 +102,33 @@ export function MessageRow({
const failed = isFailedStatus(topic, msg);
const sending = isSendingStatus(topic, msg);
const statusLabel = mine ? outgoingStatusLabel(topic, msg, isGroup) : msg.ts ? formatBubbleTime(msg.ts) : '';
const callResult = callResultDisplay(msg, mine);
const contactCard = contactCardContentOf(msg);
const retry = () => {
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq);
if (typeof msg.seq === 'number') retryMessage(topic, msg.seq)?.catch(() => onError?.('重试发送失败'));
};
return (
<div className="flex items-center gap-2">
<div data-seq={msg.seq} className={`flex items-center gap-2 ${highlighted ? 'animate-[jump-highlight_2s_ease-out]' : ''}`}>
{selectMode ? (
<button
type="button"
onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)}
className={`grid h-5 w-5 flex-none place-items-center rounded-full text-[13px] text-white ${
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2]'
}`}
>
{selected ? <Icon name="check" className="text-[14px]" /> : null}
</button>
// A recalled message (content nulled out, rendered as the "⚠ 消息已删除" placeholder below)
// has nothing real to forward/favorite -- an empty flex-none spacer of the same size keeps
// every row's content left-edge aligned instead of the checkbox's absence shifting this one
// row over, while still refusing to let it join a batch action.
isRecalled(msg) ? (
<div className="h-5 w-5 flex-none" />
) : (
<button
type="button"
onClick={() => typeof msg.seq === 'number' && onToggleSelect?.(msg.seq)}
className={`grid h-5 w-5 flex-none place-items-center rounded-full text-[13px] text-white ${
selected ? 'bg-accent' : 'border-[1.5px] border-[#c5cad2]'
}`}
>
{selected ? <Icon name="check" className="text-[14px]" /> : null}
</button>
)
) : null}
<div
onContextMenu={(e) => {
@@ -76,25 +136,46 @@ export function MessageRow({
e.preventDefault();
onContextMenu?.(e, msg);
}}
className={`flex flex-1 items-end gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
className={`flex flex-1 items-start gap-2.5 ${mine ? 'flex-row-reverse' : ''}`}
>
<span
className={`grid h-9 w-9 flex-none place-items-center rounded-lg text-sm font-medium text-white ${
mine ? 'bg-accent' : avatarBgClass(msg.from || peerName)
mine ? 'bg-accent' : avatarBgClass(msg.from || (isGroup ? '' : peerName))
}`}
>
{avatarLetter(mine ? '我' : peerName)}
{avatarLetter(mine ? '我' : isGroup ? senderName || msg.from : peerName)}
</span>
<div className={`flex max-w-[60%] flex-col gap-1 ${mine ? 'items-end' : 'items-start'}`}>
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text. */}
<div className={`flex items-end gap-1.5 ${mine ? 'flex-row-reverse' : ''}`}>
{isGroup ? (
<div className={`flex items-center gap-1 px-0.5 text-xs text-text-tertiary-4 ${mine ? 'flex-row-reverse' : ''}`}>
<span>{mine ? '我' : senderName || msg.from}</span>
<MessageRoleTag role={senderRole === 'owner' || senderRole === 'admin' ? senderRole : undefined} />
</div>
) : null}
{/* README §3.2: the failed-send icon sits beside the bubble, not below it with the retry text.
items-center (not -end) so the icon stays vertically centered on the bubble regardless of
how many lines the bubble wraps to; no flex-row-reverse here (unlike the outer avatar row)
-- this icon only ever appears on `mine` bubbles and belongs on the OUTER edge, away from
the avatar, which is what the DOM order [icon, bubble] already gives without reversing it. */}
<div className="flex items-center gap-1.5">
{failed ? <Icon name="error" className="flex-none text-[20px] text-danger-text" /> : null}
<div
className={`px-3.5 py-2.5 text-[15px] leading-[1.55] break-words ${
mine ? 'rounded-[12px_4px_12px_12px] bg-bubble-mine' : 'rounded-[4px_12px_12px_12px] bg-white shadow-bubble'
} ${isRecalled(msg) ? 'text-text-tertiary-4 italic' : ''} ${menuActive ? 'outline outline-2 outline-offset-2 outline-accent' : ''}`}
>
{isRecalled(msg) ? '⚠ 消息已删除' : renderDrafty(msg.content)}
{isRecalled(msg) ? (
'⚠ 消息已删除'
) : callResult ? (
<span className="flex items-center gap-1.5">
<Icon name={callResult.icon} className={`text-[18px] ${callResult.danger ? 'text-danger-text' : 'text-current'}`} />
{callResult.text}
</span>
) : contactCard ? (
<ContactCardBubble content={contactCard} />
) : (
renderDrafty(msg.content)
)}
</div>
</div>
{failed ? (
@@ -90,7 +90,7 @@ export function MessageContextMenu({ x, y, actions, recallRemainingSeconds, onAc
</button>
);
})}
{trailing.length ? <div className="my-1 h-px bg-list-item" /> : null}
{trailing.length ? <div className="my-1 h-px bg-border-list-item" /> : null}
{trailing.map((action) => {
const item = ITEMS[action];
return (
+104 -17
View File
@@ -1,38 +1,111 @@
import { useLayoutEffect, useState } from 'react';
import { Outlet, useParams } from 'react-router-dom';
import { Outlet, useLocation, useParams } from 'react-router-dom';
import { ChatListColumn } from '@/features/messages/ChatListColumn';
import { DetailColumn } from '@/features/messages/DetailColumn';
import { HistorySearchModal } from '@/features/messages/HistorySearchModal';
import { OfflineBanner } from '@/features/messages/OfflineBanner';
import { GroupDetailColumn } from '@/features/groups/GroupDetailColumn';
import type { MediaType } from '@/lib/api/messages';
import { useMediaQuery } from '@/lib/hooks';
import { useChatList } from '@/lib/tinode/hooks';
import { useChatList, useTinode } from '@/lib/tinode/hooks';
/** Group topics (`grp...`) get the group-specific settings rail; everything else (`usr...` P2P,
* `slf...` self) keeps the existing DetailColumn. `isGroupType()` is a pure name-prefix check on
* the SDK's Topic wrapper (tinode-sdk's Topic.topicType()) -- no subscribe/network call, safe to
* read on every render. */
function ConversationDetailPanel({
isGroup,
topicId,
peerName,
onClose,
onOpenHistory,
}: {
isGroup: boolean;
topicId: string;
peerName: string;
onClose?: () => void;
onOpenHistory: (tab: 'text' | MediaType) => void;
}) {
return isGroup ? (
<GroupDetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
) : (
<DetailColumn topicId={topicId} peerName={peerName} onClose={onClose} onOpenHistory={onOpenHistory} />
);
}
const DETAIL_PANEL_OPEN_KEY = 'im-detail-panel-open';
function readPersistedDetailPanelOpen(): boolean {
try {
return localStorage.getItem(DETAIL_PANEL_OPEN_KEY) === '1';
} catch {
return false;
}
}
/**
* The 4-tier responsive split described in the design handoff:
* - <640 and 640-959: list OR main, whichever the route implies (topicId present -> main; the
* conversation header then needs its own back-to-list affordance, since NavRail alone doesn't
* provide one at this tier -- see ConversationView).
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon.
* - >=1280: list + main + detail all docked side by side.
* - 960-1279: list + main together, detail as an overlay drawer toggled by the header's "i" icon
* (README's `detailDrawerOpen`, explicitly "三列临时" -- temporary, not persisted).
* - >=1280: list + main + detail all docked side by side, but the detail column itself is STILL
* closed by default and toggled by the same "i" icon (README's `detailPanelOpen`, "桌面持久化"
* -- desktop, persisted to localStorage) -- this is a real column reserved in the 4-column
* diagram, not an always-on panel with no way to close it; that was this layout's own bug until
* a user report caught it (the "i" button did nothing at this width, and the panel had no close
* affordance at all).
* Below 960px (both the two-column and single-column tiers), the detail panel instead pushes as a
* full-page overlay -- README §1.4's "详情/设置等作为整页推入" convention -- since there's no room
* to dock or drawer it in without covering the conversation entirely anyway.
*/
export function MessagesLayout() {
const { topicId } = useParams();
const location = useLocation();
const [detailOpen, setDetailOpen] = useState(false);
const [detailPanelOpen, setDetailPanelOpen] = useState(readPersistedDetailPanelOpen);
const [historyTab, setHistoryTab] = useState<'text' | MediaType | null>(null);
const isDocked = useMediaQuery('(min-width: 1280px)');
const showListOnNarrow = !topicId;
// Which of the 3 detail-panel render sites below is allowed to actually mount -- previously all
// three were gated ONLY by `detailPanelOpen`/`detailOpen` and picked apart visually with `hidden`/
// `md:`/`lg:` classes, so crossing the 1280px breakpoint while a panel was open left the OTHER
// tier's copy still truthy and mounted-but-hidden (confirmed: `detailPanelOpen` and `detailOpen`
// are two independent booleans that only toggleDetail() keeps in sync, and it only ever updates
// the one matching `isDocked` AT CLICK TIME -- resizing across 1280px afterward does not reconcile
// the other one). A hidden-but-mounted GroupDetailColumn/DetailColumn still runs its own queries
// and effects for no visible reason, and briefly having two real instances of the same panel alive
// is exactly the kind of thing that turns into a real bug the moment either one has a side effect
// that isn't safe to run twice.
const isMdUp = useMediaQuery('(min-width: 960px)');
// `/messages/system` is a real Outlet child (SystemMessagesView) but doesn't match `:topicId`, so
// it needs its own check here -- otherwise this reads as "no conversation selected" and collapses
// to the same list-only view as the bare `/messages` index at narrow widths.
const showListOnNarrow = !topicId && location.pathname !== '/messages/system';
const contacts = useChatList();
const tinode = useTinode();
const peerName = contacts.find((c) => c.topic === topicId)?.public?.fn || topicId || '';
// The "i" button should read as active whenever the detail panel is actually visible right now --
// always true when docked (>=1280px, no close affordance), otherwise whatever the drawer/full-page
// toggle state is. Previously hardcoded to the breakpoint itself instead of this real state.
const detailVisible = isDocked || detailOpen;
const isGroup = !!topicId && tinode.getTopic(topicId).isGroupType();
const detailVisible = isDocked ? detailPanelOpen : detailOpen;
const toggleDetail = () => {
if (isDocked) {
setDetailPanelOpen((v) => {
const next = !v;
try {
localStorage.setItem(DETAIL_PANEL_OPEN_KEY, next ? '1' : '0');
} catch {
// Private-browsing/quota errors just mean the preference won't survive a reload -- the
// toggle itself still works for the rest of this session.
}
return next;
});
} else {
setDetailOpen((v) => !v);
}
};
// MessagesLayout is one long-lived instance reused across every conversation (route is
// `:topicId`) -- a history-search modal left open while switching topics would otherwise keep
@@ -49,28 +122,42 @@ export function MessagesLayout() {
<ChatListColumn />
</div>
<div className={`h-full min-w-0 flex-1 ${showListOnNarrow ? 'hidden md:block' : ''}`}>
<Outlet context={{ onToggleDetail: () => setDetailOpen((v) => !v), detailVisible, onOpenHistory: setHistoryTab }} />
<Outlet context={{ onToggleDetail: toggleDetail, detailVisible, onOpenHistory: setHistoryTab }} />
</div>
</div>
{topicId ? (
{isDocked && detailPanelOpen && topicId ? (
<div className="hidden h-full lg:block">
<DetailColumn topicId={topicId} peerName={peerName} onOpenHistory={setHistoryTab} />
<ConversationDetailPanel isGroup={isGroup} topicId={topicId} peerName={peerName} onClose={toggleDetail} onOpenHistory={setHistoryTab} />
</div>
) : null}
{detailOpen && topicId ? (
{!isDocked && isMdUp && detailOpen && topicId ? (
<div className="absolute inset-0 hidden md:block lg:hidden">
<div className="absolute inset-0 bg-[#22262d]/25" onClick={() => setDetailOpen(false)} />
<div className="absolute inset-y-0 right-0 shadow-[-8px_0_32px_rgba(0,0,0,0.12)]">
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} onOpenHistory={setHistoryTab} />
<ConversationDetailPanel
isGroup={isGroup}
topicId={topicId}
peerName={peerName}
onClose={() => setDetailOpen(false)}
onOpenHistory={setHistoryTab}
/>
</div>
</div>
) : null}
{detailOpen && topicId ? (
{!isDocked && !isMdUp && detailOpen && topicId ? (
<div className="fixed inset-0 z-30 bg-white md:hidden">
<DetailColumn topicId={topicId} peerName={peerName} onClose={() => setDetailOpen(false)} onOpenHistory={setHistoryTab} />
<ConversationDetailPanel
isGroup={isGroup}
topicId={topicId}
peerName={peerName}
onClose={() => setDetailOpen(false)}
onOpenHistory={setHistoryTab}
/>
</div>
) : null}
{historyTab && topicId ? <HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} /> : null}
{historyTab && topicId ? (
<HistorySearchModal topicId={topicId} initialTab={historyTab} onClose={() => setHistoryTab(null)} isGroup={isGroup} peerName={peerName} />
) : null}
</div>
);
}
+5 -25
View File
@@ -1,22 +1,16 @@
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { useBootstrap } from '@/lib/api/hooks';
import { retryReconnectNow } from '@/lib/tinode/connection';
import { useReconnectState } from '@/lib/tinode/hooks';
/**
* README §3.7's four top-banner states, overlaying ChatList+Main. Only one shows at a time,
* priority: offline > reconnecting > just-reconnected > realname-reminder (most to least urgent).
* "已重新连接,正在同步 N 条新消息" is shown without a fake N -- the old app never implemented this
* state at all (confirmed by research), and there's no reliable signal here for "how many messages
* arrived while disconnected," so inventing a number would just be dishonest data.
* README §3.7's top-banner states, overlaying ChatList+Main. Only one shows at a time, priority:
* offline > reconnecting > just-reconnected (most to least urgent). "已重新连接,正在同步 N 条新
* 消息" is shown without a fake N -- the old app never implemented this state at all (confirmed by
* research), and there's no reliable signal here for "how many messages arrived while disconnected,"
* so inventing a number would just be dishonest data.
*/
export function OfflineBanner() {
const navigate = useNavigate();
const reconnect = useReconnectState();
const bootstrap = useBootstrap();
const realname = bootstrap.data?.security?.realname;
if (!reconnect.networkOnline) {
return (
@@ -52,19 +46,5 @@ export function OfflineBanner() {
);
}
if (realname && !realname.status.includes('verified') && realname.allow_skip) {
return (
<div className="flex h-10 flex-none items-center justify-between bg-warn-bg px-4 text-[13px] text-[#8a5a12]">
<span className="flex items-center gap-2">
<Icon name="gpp_maybe" className="text-[16px] text-warn" />
根据法规要求,请先完成实名认证后再使用全部功能
</span>
<button type="button" onClick={() => navigate('/settings')} className="text-accent">
去认证
</button>
</div>
);
}
return null;
}
+41
View File
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
import { useMutation } from '@tanstack/react-query';
import { checkUrlSafety } from '@/lib/api/security';
/**
* docs/IM-前后端对接文档.md:1822,1898 -- links found in chat content 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.
* Same UX contract as HistorySearchModal.tsx's own (dead-code, see its comment) LinkResult -- this is
* the renderer (lib/messages/drafty.tsx's `LN` case) that component itself falls back to for a hit
* that doesn't carry its own `url` enrichment field, so fixing it here covers both call sites: this
* was previously the only actually-reachable one, since SearchHit's real response has no `url` field.
*/
export function SafeLink({ href, children }: { href: string | undefined; children: ReactNode }) {
const safetyCheck = useMutation({ mutationFn: (url: string) => checkUrlSafety(url, 'chat_message') });
if (!href) return <span className="text-accent underline">{children}</span>;
const open = async () => {
let result;
try {
result = await safetyCheck.mutateAsync(href);
} catch {
window.alert('链接安全检查失败,请稍后重试。');
return;
}
if (result.decision === 'block') {
window.alert('该链接存在安全风险,已被拦截。');
return;
}
if (result.decision === 'warn' && !window.confirm('该链接可能存在风险,是否仍要打开?')) return;
window.open(href, '_blank', 'noopener,noreferrer');
};
return (
<button type="button" onClick={open} disabled={safetyCheck.isPending} className="text-accent underline disabled:opacity-60">
{children}
</button>
);
}
+4 -3
View File
@@ -122,12 +122,13 @@ export function VoiceRecorder({ onSend, onCancel }: VoiceRecorderProps) {
}
return (
<div className="flex h-14 items-center justify-between rounded-lg bg-[#fff0f0] px-4">
<span className="flex items-center gap-2 text-sm font-medium text-danger-text">
<div className="flex h-14 items-center justify-between gap-3 rounded-lg bg-[#fff0f0] px-4">
<span className="flex flex-none items-center gap-2 text-sm font-medium text-danger-text">
<span className="h-2.5 w-2.5 animate-pulse rounded-full bg-danger-text" />
录音中 {formatSeconds(seconds)}
</span>
<div className="flex items-center gap-4">
<span className="h-4 min-w-0 flex-1 rounded bg-[repeating-linear-gradient(90deg,#f3b8b8_0px,#f3b8b8_2px,transparent_2px,transparent_5px)]" />
<div className="flex flex-none items-center gap-4">
<button type="button" onClick={() => finish(false)} className="text-sm text-text-secondary">
取消
</button>
+47
View File
@@ -0,0 +1,47 @@
import { useMutation } from '@tanstack/react-query';
import { getLatestRelease } from '@/lib/api/app';
const CURRENT_VERSION = __APP_VERSION__;
/**
* README §8.7's "v0.25 · 有新版本" row. Confirmed live (2026-09-08): `platform:"web"` is REJECTED
* outright -- `400 MALFORMED_REQUEST {"details":{"field":"platform"}}` -- the real backend only
* accepts the doc's own example values (`ios`/`android`), no web support at all. Passing a wrong
* platform to get SOME answer would be dishonest (it'd be checking updates for the wrong client),
* so this shows an honest "not supported" state instead of pretending the check succeeded.
*/
export function AboutPage() {
const checkMutation = useMutation({ mutationFn: () => getLatestRelease(CURRENT_VERSION) });
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-4 text-xl font-semibold text-text-heading">关于 MeetingIM</h2>
<div className="rounded-xl bg-white text-sm">
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
<span className="text-text-body">当前版本</span>
<span className="text-text-tertiary-2">{CURRENT_VERSION}</span>
</div>
<button
type="button"
onClick={() => checkMutation.mutate()}
disabled={checkMutation.isPending}
className="flex w-full items-center justify-between px-7 py-4 text-left"
>
<span className="text-text-body">检查更新</span>
<span className="text-text-tertiary-2">
{checkMutation.isPending
? '检查中…'
: checkMutation.isError
? '暂不支持检查更新'
: checkMutation.data
? checkMutation.data.update_type === 'none'
? '已是最新版本'
: `发现新版本 ${checkMutation.data.latest_version}`
: '›'}
</span>
</button>
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import { useState } from 'react';
import { Toast } from '@/app/Toast';
/**
* README §8.7 mentions a "清空缓存「126 MB」" row -- confirmed no server endpoint touches cache at
* all (docs/IM-后端问题清单.md-adjacent research: "No backend endpoint clears cache/history -- both
* are pure client-side operations"). Computing a real, honest "just this app's cache" byte count is
* not reliably possible (`navigator.storage.estimate()` reports the WHOLE origin, not a
* per-feature breakdown) -- rather than show a fabricated-looking number, this omits the size and
* just performs and confirms the real clear (this app's own Cache Storage buckets, per the SW-
* caching behavior already relied on elsewhere in this app).
*/
export function GeneralSettingsPage() {
const [status, setStatus] = useState<'idle' | 'clearing' | 'done'>('idle');
const [error, setError] = useState('');
const clearCache = async () => {
if (!window.confirm('确定清除本地缓存吗?不会影响你的账号数据。')) return;
setStatus('clearing');
try {
if (typeof caches === 'undefined') throw new Error('此浏览器不支持缓存清除');
const keys = await caches.keys();
await Promise.all(keys.map((k) => caches.delete(k)));
setStatus('done');
} catch (err) {
setStatus('idle');
setError(err instanceof Error ? err.message : '清除缓存失败,请重试');
}
};
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-4 text-xl font-semibold text-text-heading">通用</h2>
<div className="rounded-xl bg-white text-sm">
<button type="button" onClick={clearCache} disabled={status === 'clearing'} className="flex w-full items-center justify-between px-7 py-4 text-left">
<span className="text-text-body">清空缓存</span>
<span className="text-text-tertiary-2">{status === 'clearing' ? '清除中…' : status === 'done' ? '已清除 ›' : '›'}</span>
</button>
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
@@ -0,0 +1,42 @@
import { useState } from 'react';
/**
* README §8.1's directory lists a dedicated "消息通知" section, but there is no per-type
* notification-preference REST endpoint anywhere (confirmed: grepped both protocol-doc copies for
* notification_settings/推送偏好, zero hits; the old app's own settings list has no such row
* either) -- the doc's own claim that "服务端只保存应用内通知偏好" has nothing backing it. Rather
* than build toggles that write nowhere, this only wires the one thing that's real and entirely
* client-side: the browser's own Notification permission.
*/
export function NotificationsSettingsPage() {
const [permission, setPermission] = useState<NotificationPermission>(typeof Notification !== 'undefined' ? Notification.permission : 'denied');
const request = async () => {
if (typeof Notification === 'undefined') return;
const result = await Notification.requestPermission();
setPermission(result);
};
const label = { granted: '已允许', denied: '已拒绝', default: '未设置' }[permission];
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-4 text-xl font-semibold text-text-heading">消息通知</h2>
<div className="rounded-xl bg-white text-sm">
<div className="flex items-center justify-between px-7 py-4">
<div className="flex flex-col">
<span className="text-text-body">桌面通知</span>
<span className="text-xs text-text-tertiary-3">由浏览器管理,用于在新消息到达时提醒你</span>
</div>
{permission === 'default' ? (
<button type="button" onClick={request} className="h-8 flex-none rounded-lg bg-btn px-3 text-xs font-medium text-white">
开启
</button>
) : (
<span className={`flex-none text-xs ${permission === 'granted' ? 'text-success' : 'text-text-tertiary-2'}`}>{label}</span>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,161 @@
import { 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 { getBlocks } from '@/lib/api/friends';
import { getMyPrivacy, updateMyPrivacy, type AddMeMethods } from '@/lib/api/profile';
const ADD_ME_LABELS: { key: keyof AddMeMethods; label: string }[] = [
{ key: 'account', label: '通过用户 ID' },
{ key: 'phone', label: '通过手机号' },
{ key: 'qr', label: '通过二维码' },
{ key: 'group', label: '通过群聊' },
];
const VISIBILITY_OPTIONS = [
{ value: 'everyone', label: '所有人' },
{ value: 'friends', label: '仅好友' },
{ value: 'nobody', label: '不展示' },
];
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>
);
}
/**
* screens/07-profile-settings.dc.html#1c: 添加我的方式 (4 toggles, ungrouped -- no dividers between
* them, just the section label above) sits in its own card; 黑名单 sits in a second card alone
* (that mockup also puts "加我为好友时需要验证" there, but that toggle has NO backing field anywhere
* in this response shape or the group-invite backend logic -- confirmed absent, not built). The
* visibility/read-receipts rows below have no mockup at all (added once the real backend fields were
* found during research) -- given their own third card rather than folding them into either of the
* above, so a real capability the design never anticipated doesn't look like it was crammed in.
*/
export function PrivacySettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = useState('');
const query = useQuery({ queryKey: ['my-privacy'], queryFn: getMyPrivacy });
const blocksQuery = useQuery({ queryKey: ['blocks'], queryFn: () => fetchAllPages((cursor) => getBlocks(cursor)) });
const mutation = useMutation({
mutationFn: (patch: Parameters<typeof updateMyPrivacy>[0]) => updateMyPrivacy(patch),
// Merge, don't replace -- the doc's own worked example for this exact endpoint (line 2494)
// returns a PARTIAL object (`{add_me_methods,read_receipts,version,updated_at}`, missing
// `profile_visibility`/`last_seen_visibility`/`editable`). A blind replace would erase those
// from the cache after every single save, most importantly `editable`: once it silently
// disappeared, `const editable = query.data.editable || {}` re-derives an EMPTY object, which
// re-enables every field a tenant policy had actually locked, until an unrelated refetch happens
// to restore it.
onSuccess: (data) => queryClient.setQueryData(['my-privacy'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data)),
onError: (err) => setError(err instanceof Error ? err.message : '设置失败'),
});
const patch = (partial: Partial<Omit<Parameters<typeof updateMyPrivacy>[0], 'version'>>) => {
if (!query.data) return;
mutation.mutate({ ...partial, version: query.data.version });
};
if (query.isLoading) return <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
// `isLoading` alone used to gate this: it turns false once the fetch SETTLES, including into an
// error, but `query.data` stays undefined either way -- the page was falling through to the same
// "加载中…" text forever on a genuine failure (a slow/dropped connection, not just "still loading"),
// with no retry affordance at all. Same fix already applied to SystemMessagesView's own query.
if (query.isError || !query.data) {
return (
<div className="flex flex-col items-center gap-2 p-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
);
}
const editable = query.data.editable || {};
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-5 text-xl font-semibold text-text-heading">隐私</h2>
<div className="flex flex-col gap-5">
<div className="rounded-xl bg-white text-sm">
<div className="px-7 pb-1.5 pt-4 text-[13px] text-text-tertiary-2">添加我的方式</div>
{ADD_ME_LABELS.map((item, i) => (
<div key={item.key} className={`flex items-center justify-between px-7 py-3 ${i === ADD_ME_LABELS.length - 1 ? 'pb-4.5' : ''}`}>
<span className="text-text-body">{item.label}</span>
<Toggle
checked={query.data.add_me_methods[item.key]}
disabled={editable.add_me_methods === false || mutation.isPending}
onChange={() => patch({ add_me_methods: { [item.key]: !query.data.add_me_methods[item.key] } })}
/>
</div>
))}
</div>
<button type="button" onClick={() => navigate('/contacts/blocklist')} className="flex w-full items-center justify-between rounded-xl bg-white px-7 py-4 text-left text-sm">
<span className="text-text-body">黑名单</span>
<span className="flex items-center gap-1 text-text-tertiary-2">
{blocksQuery.data?.length ?? 0} 人
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</span>
</button>
<div className="rounded-xl bg-white text-sm">
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
<span className="text-text-body">资料可见范围</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
{VISIBILITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
disabled={editable.profile_visibility === false || mutation.isPending}
onClick={() => patch({ profile_visibility: opt.value })}
className={`px-3 py-1.5 ${query.data.profile_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between border-b border-border-list-item px-7 py-4">
<span className="text-text-body">最近在线可见范围</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs">
{VISIBILITY_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
disabled={editable.last_seen_visibility === false || mutation.isPending}
onClick={() => patch({ last_seen_visibility: opt.value })}
className={`px-3 py-1.5 ${query.data.last_seen_visibility === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
>
{opt.label}
</button>
))}
</div>
</div>
<div className="flex items-center justify-between px-7 py-4">
<span className="text-text-body">已读回执</span>
<Toggle
checked={query.data.read_receipts}
disabled={editable.read_receipts === false || mutation.isPending}
onChange={() => patch({ read_receipts: !query.data.read_receipts })}
/>
</div>
</div>
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
@@ -0,0 +1,271 @@
import { forwardRef, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { QRCodeModal } from '@/features/contacts/QRCodeModal';
import { getMyProfile, updateMyProfile } from '@/lib/api/profile';
import { uploadFile } from '@/lib/api/upload';
import { useSessionStore } from '@/stores/session';
const GENDER_OPTIONS: { value: 'male' | 'female' | 'other'; label: string }[] = [
{ value: 'male', label: '男' },
{ value: 'female', label: '女' },
{ value: 'other', label: '保密' },
];
const DEFAULT_MAX_SIGNATURE_LENGTH = 60;
/** Plain-text-looking field: a real `<input>` with all box chrome stripped so it reads as the
* design's "label / value / edit-affordance-icon" row instead of a boxed form control, while
* staying genuinely editable (click anywhere in the value cell to type). */
const InlineField = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(function InlineField(props, ref) {
return (
<input
{...props}
ref={ref}
className={`w-full border-0 bg-transparent p-0 text-sm text-text-body outline-none [&::-webkit-calendar-picker-indicator]:hidden ${props.className || ''}`}
/>
);
});
/**
* README §8.2 / screens/07-profile-settings.dc.html#1a. Avatar upload reuses upload.ts's native
* `/v0/file/u/` channel, NOT `POST /v1/im/public/uploads?purpose=user_avatar` -- confirmed
* live-broken (docs/IM-后端问题清单.md 问题28: that endpoint 400s on `purpose=user_avatar`
* specifically), and the old app's own real avatar-upload code never goes through it either.
*/
export function ProfileSettingsPage() {
const queryClient = useQueryClient();
const myUserId = useSessionStore((s) => s.myUserId);
const query = useQuery({ queryKey: ['my-profile'], queryFn: getMyProfile });
const [draft, setDraft] = useState<{ nickname: string; gender: string; birth_date: string; region: string; signature: string } | null>(null);
const [error, setError] = useState('');
const [uploadingAvatar, setUploadingAvatar] = useState(false);
const [showQrCode, setShowQrCode] = useState(false);
const birthDateRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (query.data && !draft) {
setDraft({
nickname: query.data.nickname,
gender: query.data.gender || '',
birth_date: query.data.birth_date || '',
region: query.data.region || '',
signature: query.data.signature || '',
});
}
}, [query.data, draft]);
// saveMutation and avatarMutation both PATCH the same optimistic-locked resource. The disabled=
// props below on the avatar input/save button stop the two from ever being pending at the same
// time, closing the race where both could send the SAME stale `query.data.version` and whichever
// response resolves last would win the cache. Independent of that: EITHER mutation's own response
// can itself be a PARTIAL profile object -- the doc's own worked example for this endpoint (line
// 2492) returns only `{avatar_file_id,nickname,signature,version,updated_at}`, omitting
// `birth_date`/`gender`/`region`/`public_account`/`field_rules` entirely, and avatarMutation's own
// request body is deliberately partial (`{avatar_file_id,version}` only) so its response is the
// most likely of the two to come back partial too. A blind `setQueryData(key, data)` replace would
// erase those fields from the cache after a single avatar change -- merging onto the existing
// cached object (not just overwriting it) is what actually prevents that regardless of exactly how
// partial a given response turns out to be.
const saveMutation = useMutation({
mutationFn: () => {
if (!query.data || !draft) throw new Error('尚未加载');
return updateMyProfile({ ...draft, version: query.data.version });
},
onSuccess: (data) => {
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
queryClient.invalidateQueries({ queryKey: ['me'] });
},
onError: (err) => setError(err instanceof Error ? err.message : '保存失败'),
});
const avatarMutation = useMutation({
mutationFn: async (file: File) => {
const uploaded = await uploadFile(file);
const fileId = uploaded.url.split('/').pop() || '';
if (!query.data) throw new Error('尚未加载');
return updateMyProfile({ avatar_file_id: fileId, version: query.data.version });
},
onSuccess: (data) => {
queryClient.setQueryData(['my-profile'], (prev: typeof query.data) => (prev ? { ...prev, ...data } : data));
queryClient.invalidateQueries({ queryKey: ['me'] });
setUploadingAvatar(false);
},
onError: (err) => {
setError(err instanceof Error ? err.message : '头像上传失败');
setUploadingAvatar(false);
},
});
const handlePickAvatar = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (file.size > 5 * 1024 * 1024) {
setError('图片大小不能超过 5MB');
return;
}
setUploadingAvatar(true);
avatarMutation.mutate(file);
};
if (query.isLoading) return <div className="p-10 text-sm text-text-tertiary-4">加载中…</div>;
// Same gap as PrivacySettingsPage's own query: `draft` only ever gets set from a successful fetch
// (the effect above is gated on `query.data`), so a genuine fetch failure left `isLoading` false
// but `draft` permanently null -- falling through to the exact same "加载中…" text forever, with no
// way to retry.
if (query.isError || !draft) {
return (
<div className="flex flex-col items-center gap-2 p-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
);
}
const maxSignatureLength = query.data?.field_rules?.signature?.max_length || DEFAULT_MAX_SIGNATURE_LENGTH;
const dirty =
query.data &&
(draft.nickname !== query.data.nickname ||
draft.gender !== (query.data.gender || '') ||
draft.birth_date !== (query.data.birth_date || '') ||
draft.region !== (query.data.region || '') ||
draft.signature !== (query.data.signature || ''));
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-5 text-xl font-semibold text-text-heading">个人资料</h2>
<div className="flex flex-col gap-5">
<div className="flex items-center gap-5 rounded-xl bg-white px-7 py-6">
<div className="relative flex-none">
<Avatar id={myUserId || ''} name={draft.nickname} avatarFileId={query.data?.avatar_file_id} className="h-20 w-20 rounded-[20px] text-2xl" />
<label className="absolute -bottom-1 -right-1 grid h-[26px] w-[26px] cursor-pointer place-items-center rounded-full bg-white text-text-tertiary-1 shadow-btn-sm">
<Icon name="photo_camera" className="text-[15px]" />
<input
type="file"
accept="image/png,image/jpeg"
onChange={handlePickAvatar}
className="hidden"
disabled={uploadingAvatar || saveMutation.isPending}
/>
</label>
</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<strong className="truncate text-lg font-semibold text-text-heading">{draft.nickname}</strong>
<span className="truncate text-[13px] text-text-tertiary-2">
ID:{query.data?.public_account} · {uploadingAvatar ? '上传中…' : '支持 JPG/PNG,不超过 5MB'}
</span>
</div>
<button
type="button"
onClick={() => setShowQrCode(true)}
className="flex h-9 flex-none items-center gap-1.5 rounded-lg border border-[#d5d9df] px-2.5 text-[13px] text-text-tertiary-1 sm:px-3.5"
>
<Icon name="qr_code_2" className="text-[18px]" />
<span className="hidden sm:inline">我的二维码</span>
</button>
</div>
{showQrCode ? <QRCodeModal onClose={() => setShowQrCode(false)} /> : null}
<div className="rounded-xl bg-white text-sm">
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">昵称</span>
<InlineField
value={draft.nickname}
onChange={(e) => setDraft({ ...draft, nickname: e.target.value })}
maxLength={query.data?.field_rules?.nickname?.max_length || 32}
/>
<Icon name="edit" className="text-[20px] text-icon-arrow" />
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">性别</span>
<div className="flex overflow-hidden rounded-md border border-[#d5d9df] text-xs" style={{ width: 'fit-content' }}>
{GENDER_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => setDraft({ ...draft, gender: opt.value })}
className={`px-3.5 py-1.5 ${draft.gender === opt.value ? 'bg-accent text-white' : 'text-text-secondary'}`}
>
{opt.label}
</button>
))}
</div>
<span />
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">出生日期</span>
<InlineField
ref={birthDateRef}
type="date"
value={draft.birth_date}
max={new Date().toISOString().slice(0, 10)}
onChange={(e) => setDraft({ ...draft, birth_date: e.target.value })}
/>
<button type="button" onClick={() => birthDateRef.current?.showPicker?.()} className="grid place-items-center text-icon-arrow">
<Icon name="calendar_today" className="text-[20px]" />
</button>
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-center gap-x-2 border-b border-border-list-item px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="text-text-secondary">地区</span>
<InlineField value={draft.region} onChange={(e) => setDraft({ ...draft, region: e.target.value })} placeholder="未填写" />
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</div>
<div className="grid grid-cols-[80px_1fr_auto] items-start gap-x-2 px-7 py-4 md:grid-cols-[120px_1fr_auto]">
<span className="pt-2 text-text-secondary">个性签名</span>
<div className="relative">
<textarea
value={draft.signature}
onChange={(e) => setDraft({ ...draft, signature: e.target.value.slice(0, maxSignatureLength) })}
rows={3}
className="min-h-[60px] w-full resize-none rounded-lg bg-surface-input p-3 pr-14 text-sm outline-none"
/>
<span className="absolute bottom-2 right-3 text-xs text-text-tertiary-3">
{draft.signature.length}/{maxSignatureLength}
</span>
</div>
<span />
</div>
</div>
<div className="flex justify-end gap-2.5">
<button
type="button"
onClick={() =>
query.data &&
setDraft({
nickname: query.data.nickname,
gender: query.data.gender || '',
birth_date: query.data.birth_date || '',
region: query.data.region || '',
signature: query.data.signature || '',
})
}
disabled={!dirty}
className="h-9.5 rounded-lg border border-[#d5d9df] px-5 text-sm text-text-tertiary-1 disabled:opacity-50"
>
取消
</button>
<button
type="button"
onClick={() => saveMutation.mutate()}
disabled={!dirty || saveMutation.isPending || uploadingAvatar}
className="h-9.5 rounded-lg bg-btn px-5 text-sm font-medium text-white disabled:bg-btn-disabled"
>
{saveMutation.isPending ? '保存中…' : '保存'}
</button>
</div>
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
@@ -0,0 +1,182 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { getTinode, resetTinode } from '@/lib/tinode/connection';
import { useSessionStore } from '@/stores/session';
/**
* screens/07-profile-settings.dc.html#1b. That mockup's 手机号/登录设备 rows have no real data
* source and aren't built as decorative fakes: phone number display (no `GET my phone` endpoint
* anywhere -- the only place a phone surfaces is a masked hint as a SIDE EFFECT of starting an
* unrelated SMS challenge, not a passive read) and the logged-in-device list (only a kick-by-id
* DELETE exists, itself marked 待实现, with no GET list endpoint to populate rows from). Password
* change and account deletion both go through native Tinode `{acc}`/`{del}` calls (see
* tinode-sdk.d.ts's own comments) -- there is no REST path for either, confirmed across both doc
* copies and the backend problem list.
*/
export function SecuritySettingsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const tenant = useSessionStore((s) => s.tenant);
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const [changingPassword, setChangingPassword] = useState(false);
const [currentPassword, setCurrentPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [deletingAccount, setDeletingAccount] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
// Real per-tenant policy (docs/IM-前后端对接文档.md:534, fetched once at tenant-resolve time and
// stored on `tenant`) -- was hardcoded to 6 here, silently DIFFERENT from Register/ResetPassword's
// own (also-hardcoded, but at least matching each other) 8, so a password this page accepted could
// still get rejected by the backend's real policy.
const minPasswordLength = tenant?.passwordPolicy?.minLength ?? 8;
const passwordsMismatch = confirmPassword.length > 0 && newPassword !== confirmPassword;
const passwordMutation = useMutation({
// `updateAccountBasic`'s own type comment (tinode-sdk.d.ts) already confirms, from this
// engagement's earlier research into the old app's real working code: the server does NOT
// verify a "current password" for this call at all -- an already-authenticated WS session is
// by itself sufficient to change the password to anything. `currentPassword` below is therefore
// NOT sent anywhere and is NOT a real credential check -- it exists purely as a client-side "did
// you mean to do this" gate (the submit button stays disabled while it's empty), the same
// fat-finger/shared-device speed bump most password-change screens have, not a security boundary.
// A REAL re-verification would need the protocol's own `POST /security/challenges` sensitive-
// action flow, but the only confirmed `scene` values for that endpoint (`set_independent_password`/
// `close_youth_mode`) are both youth-mode-specific -- and youth mode was removed from this app
// entirely per an earlier explicit user request, so that path isn't available here either.
mutationFn: () => {
const tinode = getTinode();
return tinode.updateAccountBasic(null, tinode.getCurrentLogin(), newPassword);
},
onSuccess: () => {
setChangingPassword(false);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
setSuccess('密码已修改。');
},
onError: (err) => setError(err instanceof Error ? err.message : '密码修改失败'),
});
const deleteAccount = async () => {
if (deletingAccount) return;
if (!window.confirm('注销账号将永久删除你的账号和全部数据,此操作不可恢复。确定继续吗?')) return;
if (!window.confirm('请再次确认:真的要注销账号吗?此操作立即生效,无法撤销。')) return;
setDeletingAccount(true);
try {
await getTinode().delCurrentUser(true);
resetTinode();
sessionStorage.removeItem('im-session-token');
setMyUserId(null);
// Deliberately NOT clearTenant() -- see SettingsLayout.tsx's logout for why: it clears
// useSessionStore's `tenant`, and LoginPage's own tenant guard immediately bounces navigate(
// '/login') below back to the tenant-code entry screen instead of this tenant's login page.
//
// Same shared-device concern as SettingsLayout's logout -- a next login in this tab should
// never briefly render this now-deleted account's cached data.
queryClient.clear();
navigate('/login');
} catch (err) {
setError(err instanceof Error ? err.message : '注销失败');
setDeletingAccount(false);
}
};
return (
<div className="mx-auto w-full max-w-[720px] px-5 py-8 md:px-10">
<h2 className="mb-5 text-xl font-semibold text-text-heading">账号与安全</h2>
<div className="flex flex-col gap-5">
<div className="rounded-xl bg-white text-sm">
{changingPassword ? (
<div className="flex flex-col gap-3 px-7 py-4">
<span className="text-text-body">修改密码</span>
<input
type="password"
autoFocus
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="输入当前密码"
autoComplete="current-password"
className="rounded-md bg-surface-input px-3 py-2 outline-none"
/>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={`输入新密码(至少 ${minPasswordLength} 位)`}
autoComplete="new-password"
maxLength={tenant?.passwordPolicy?.maxLength}
className="rounded-md bg-surface-input px-3 py-2 outline-none"
/>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="再次输入新密码"
autoComplete="new-password"
maxLength={tenant?.passwordPolicy?.maxLength}
className={`rounded-md bg-surface-input px-3 py-2 outline-none ${passwordsMismatch ? 'border border-danger-text' : ''}`}
/>
{passwordsMismatch ? <span className="text-xs text-danger-border">两次输入的密码不一致</span> : null}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setChangingPassword(false);
setCurrentPassword('');
setNewPassword('');
setConfirmPassword('');
}}
className="h-8 rounded-lg border border-[#d5d9df] px-3 text-xs text-text-secondary"
>
取消
</button>
<button
type="button"
onClick={() => passwordMutation.mutate()}
disabled={
currentPassword.length === 0 ||
newPassword.length < minPasswordLength ||
passwordsMismatch ||
confirmPassword.length === 0 ||
passwordMutation.isPending
}
className="h-8 rounded-lg bg-btn px-3 text-xs font-medium text-white disabled:bg-btn-disabled"
>
{passwordMutation.isPending ? '保存中…' : '保存'}
</button>
</div>
</div>
) : (
<button type="button" onClick={() => setChangingPassword(true)} className="flex w-full items-center justify-between px-7 py-4 text-left">
<span className="text-text-body">修改密码</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
)}
</div>
<button
type="button"
onClick={deleteAccount}
disabled={deletingAccount}
className="flex w-full items-center justify-between rounded-xl bg-white px-7 py-4 text-left text-sm disabled:opacity-50"
>
<span className="flex flex-col gap-0.5">
<span className="text-text-body">注销账号</span>
<span className="text-xs text-text-tertiary-2">{deletingAccount ? '注销中…' : '注销后数据不可恢复'}</span>
</span>
<Icon name="chevron_right" className="text-[20px] text-icon-arrow" />
</button>
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
<Toast message={success} variant="success" onDone={() => setSuccess('')} />
</div>
);
}
@@ -0,0 +1,15 @@
import { ProfileSettingsPage } from '@/features/settings/ProfileSettingsPage';
import { useMediaQuery } from '@/lib/hooks';
/**
* `/settings` alone has no natural "empty" content the way contacts/messages do -- on desktop
* (directory + content docked side by side) the most-relevant first section fills the content pane
* directly. On narrow viewports this must NOT redirect there (an earlier version did): SettingsLayout
* only shows the directory list at this exact bare path, and a redirect away from it would bounce
* straight past that list before a phone-width visitor ever saw it -- so this renders nothing there,
* leaving the directory (rendered by the parent layout) as the whole screen.
*/
export function SettingsHome() {
const isDesktop = useMediaQuery('(min-width: 960px)');
return isDesktop ? <ProfileSettingsPage /> : null;
}
@@ -0,0 +1,243 @@
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { Avatar } from '@/app/Avatar';
import { Icon } from '@/app/Icon';
import { getMe } from '@/lib/api/profile';
import { resetTinode } from '@/lib/tinode/connection';
import { useSessionStore } from '@/stores/session';
const DIRECTORY = [
{ key: 'profile', to: '/settings/profile', icon: 'account_circle', label: '个人资料' },
{ key: 'security', to: '/settings/security', icon: 'security', label: '账号与安全' },
{ key: 'notifications', to: '/settings/notifications', icon: 'notifications', label: '消息通知' },
{ key: 'privacy', to: '/settings/privacy', icon: 'lock', label: '隐私' },
{ key: 'general', to: '/settings/general', icon: 'tune', label: '通用' },
{ key: 'about', to: '/settings/about', icon: 'info', label: '关于 MeetingIM' },
];
/**
* README §8.7 (mobile "我的" page): 我的收藏/文件助手/通话记录 are grouped ABOVE the regular
* settings directory. Desktop never needs this list -- those three already have their own
* permanent NavRail icons there -- but on a phone-width viewport (bottom tab bar instead of a rail)
* there is otherwise NO way to reach `/favorites` or `/calls` at all short of typing the URL
* directly (confirmed live: BottomTabBar.tsx only has 消息/通讯录/我的, unchanged since Phase 1-2
* when those two routes didn't exist yet) -- a real navigation gap this Phase 9 pass caught, not a
* cosmetic one. `/files` reuses the same `slf`-conversation redirect the desktop entry point does.
*/
const MOBILE_SHORTCUTS = [
{ key: 'favorites', to: '/favorites', icon: 'inventory_2', label: '我的收藏', colorClass: 'text-accent' },
{ key: 'files', to: '/files', icon: 'folder', label: '文件助手', colorClass: 'text-success' },
{ key: 'calls', to: '/calls', icon: 'phone', label: '通话记录', colorClass: 'text-warn' },
];
/**
* README §8.1 (3-column: Nav(global, settings 激活) / 目录 260px / 内容). Logout is entirely
* client-side -- confirmed live (docs/IM-后端问题清单.md 问题22) all three plausible logout
* endpoints 404, matching the old app's own real implementation (tinode-web.jsx:1995-2038), which
* never calls a server logout endpoint at all, just clears local token/session and disconnects.
*/
function notImplemented() {
window.alert('该功能暂未开放,如需联系客服。');
}
export function SettingsLayout() {
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const setMyUserId = useSessionStore((s) => s.setMyUserId);
const meQuery = useQuery({ queryKey: ['me'], queryFn: getMe });
const [loggingOut, setLoggingOut] = useState(false);
// README §8.7 draws TWO separate mobile screens (`#1d` 我的 hub, `#1e` 设置 sub-list) that don't
// map 1:1 onto the desktop directory -- this is which of the two the bare `/settings` mobile route
// currently shows. Irrelevant at `md`+ (desktop always shows the one real sidebar).
const [mobileSubView, setMobileSubView] = useState(false);
const logout = () => {
if (!window.confirm('确定退出登录吗?')) return;
setLoggingOut(true);
resetTinode();
sessionStorage.removeItem('im-session-token');
setMyUserId(null);
// Deliberately NOT clearTenant() -- that clears useSessionStore's `tenant`, and LoginPage's own
// `if (!tenant) return <Navigate to="/tenant" replace />` immediately bounces the navigate('/login')
// below straight back to the tenant-code entry screen instead of this tenant's actual login page
// (confirmed live: this used to be a real dead end, not a hypothetical). A plain logout should
// stay on the same enterprise's login screen, ready for the same or a different user to sign back
// in -- only the explicit "切换企业" link is supposed to clear the tenant.
//
// The QueryClient instance is module-scoped for the whole SPA lifetime (App.tsx) and survives a
// client-side route change -- without this, a second account logging in on the same tab/device
// (a shared kiosk machine, realistic for an enterprise client) would briefly render the FIRST
// account's still-cached profile/privacy/blocks data before the background refetch resolves.
queryClient.clear();
navigate('/login');
};
// Below the desktop breakpoint there's no room to dock both columns (confirmed live: without
// this, the 260px directory and the content column both try to render side by side on a phone-
// width viewport, squeezing the content into a sliver narrow enough that its own text wraps
// one character per line). Mirrors ContactsLayout's exact `showListOnNarrow` pattern: show the
// directory only at the bare `/settings` index, content only once a specific section is picked.
const showListOnNarrow = location.pathname === '/settings';
return (
<div className="flex h-full w-full">
<aside className={`h-full w-full flex-none flex-col bg-white md:flex md:w-[260px] md:border-r md:border-border-panel ${showListOnNarrow ? 'flex' : 'hidden'}`}>
{/* Desktop (README §8.1): one directory, matches ≥md exactly as before. */}
<div className="hidden h-full flex-1 flex-col 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="flex items-center gap-3 border-b border-border-list-item px-5 pb-4.5 pt-2">
<Avatar
id={meQuery.data?.user_id || ''}
name={meQuery.data?.profile.nickname}
avatarFileId={meQuery.data?.profile.avatar_file_id}
className="h-12 w-12 flex-none rounded-xl text-base"
/>
<span className="flex min-w-0 flex-col gap-0.5">
<strong className="truncate text-[15px] font-semibold text-text-body">{meQuery.data?.profile.nickname}</strong>
<span className="truncate text-xs text-text-tertiary-2">ID:{meQuery.data?.profile.public_account}</span>
</span>
</div>
<div className="flex flex-1 flex-col gap-0.5 overflow-y-auto px-3">
{DIRECTORY.map((item) => {
const active = location.pathname.startsWith(item.to);
return (
<Link
key={item.key}
to={item.to}
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm no-underline ${
active ? 'bg-accent-selected-bg text-accent' : 'text-text-body hover:bg-[#f6f8fb]'
}`}
>
<Icon name={item.icon} className="text-[18px]" />
<span className="flex-1">{item.label}</span>
</Link>
);
})}
</div>
<button
type="button"
onClick={logout}
disabled={loggingOut}
className="mx-3 my-4 h-10 flex-none rounded-lg border border-[#d5d9df] text-sm text-text-secondary disabled:opacity-50"
>
退出登录
</button>
</div>
{/* Mobile hub (README §8.7, mock `07/1d`) -- a "me" landing page, not a settings directory:
tappable profile card (-> /settings/profile), 我的收藏/文件助手/通话记录 (this app's only
way to reach those three on a narrow viewport, see MOBILE_SHORTCUTS' own comment), then
账号与安全/隐私 direct, and "设置" as its own second-level list rather than a 4th direct
link -- these two screens genuinely don't share one flat structure in the mock. */}
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'hidden' : 'flex'}`}>
<Link to="/settings/profile" className="flex items-center gap-3.5 bg-white px-5 py-5 no-underline">
<Avatar
id={meQuery.data?.user_id || ''}
name={meQuery.data?.profile.nickname}
avatarFileId={meQuery.data?.profile.avatar_file_id}
className="h-16 w-16 flex-none rounded-2xl text-2xl"
/>
<strong className="flex-1 truncate text-[19px] font-semibold text-text-heading">{meQuery.data?.profile.nickname}</strong>
<Icon name="qr_code_2" className="flex-none text-xl text-text-tertiary-3" />
<Icon name="chevron_right" className="flex-none text-xl text-icon-arrow" />
</Link>
<div className="flex flex-col gap-2.5 py-2.5">
<div className="flex flex-col bg-white">
{MOBILE_SHORTCUTS.map((item, i) => (
<Link
key={item.key}
to={item.to}
className={`flex items-center gap-3 px-5 py-3.5 text-base text-text-body no-underline ${i < MOBILE_SHORTCUTS.length - 1 ? 'border-b border-border-list-item' : ''}`}
>
<Icon name={item.icon} className={`text-xl ${item.colorClass}`} />
<span className="flex-1">{item.label}</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
))}
</div>
<div className="flex flex-col bg-white">
<Link to="/settings/security" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
<Icon name="security" className="text-xl text-[#7c5cf6]" />
<span className="flex-1">账号与安全</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
<Link to="/settings/privacy" className="flex items-center gap-3 border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
<Icon name="lock" className="text-xl text-[#e2537a]" />
<span className="flex-1">隐私</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
<button type="button" onClick={() => setMobileSubView(true)} className="flex items-center gap-3 px-5 py-3.5 text-left text-base text-text-body">
<Icon name="settings" className="text-xl text-text-tertiary-3" />
<span className="flex-1">设置</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</button>
</div>
</div>
</div>
{/* Mobile "设置" sub-list (mock `07/1e`) -- a second, DIFFERENT grouping from both the hub
above and the desktop directory: 清空缓存/意见反馈/关于 sit here directly, not nested
under 通用. 清空缓存 links through to 通用 rather than duplicating that action (and its
already-established "no fake byte count" honesty) in a second place; 意见反馈 has no
generic post-login feedback endpoint anywhere in this protocol (the only feedback API,
`/tenant-entry-feedback`, is unauthenticated and pre-login-only per its own name) so this
is a real, recorded backend gap, not a missed wire-up. 关于 omits the mock's red-dot
"有新版本" -- already confirmed elsewhere that state can never be real (the update-check
endpoint always rejects `platform=web`), showing it would be lying about system state. */}
<div className={`flex-1 flex-col overflow-y-auto md:hidden ${mobileSubView ? 'flex' : 'hidden'}`}>
<div className="grid h-12 flex-none grid-cols-[44px_1fr_44px] items-center bg-white px-1">
<button type="button" onClick={() => setMobileSubView(false)} className="grid h-9 w-9 place-items-center text-text-heading">
<Icon name="arrow_back_ios_new" className="text-[20px]" />
</button>
<strong className="text-center text-[17px] font-semibold text-text-heading">设置</strong>
<span />
</div>
<div className="flex flex-col gap-2.5 py-2.5">
<div className="flex flex-col bg-white">
{DIRECTORY.filter((item) => item.key !== 'profile' && item.key !== 'about').map((item, i, arr) => (
<Link
key={item.key}
to={item.to}
className={`flex items-center justify-between px-5 py-3.5 text-base text-text-body no-underline ${i < arr.length - 1 ? 'border-b border-border-list-item' : ''}`}
>
<span>{item.label}</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
))}
</div>
<div className="flex flex-col bg-white">
<Link to="/settings/general" className="flex items-center justify-between border-b border-border-list-item px-5 py-3.5 text-base text-text-body no-underline">
<span>清空缓存</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
<button type="button" onClick={notImplemented} className="flex items-center justify-between border-b border-border-list-item px-5 py-3.5 text-left text-base text-text-body">
<span>意见反馈</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</button>
<Link to="/settings/about" className="flex items-center justify-between px-5 py-3.5 text-base text-text-body no-underline">
<span>关于 MeetingIM</span>
<Icon name="chevron_right" className="text-xl text-icon-arrow" />
</Link>
</div>
<button type="button" onClick={logout} disabled={loggingOut} className="bg-white py-3.5 text-center text-base text-danger-text disabled:opacity-50">
退出登录
</button>
</div>
</div>
</aside>
<div className={`h-full min-w-0 flex-1 flex-col overflow-y-auto bg-[#f6f7f9] md:flex ${showListOnNarrow ? 'hidden' : 'flex'}`}>
<Link to="/settings" className="flex h-11 flex-none items-center gap-1 px-4 text-sm text-text-secondary no-underline md:hidden">
<Icon name="arrow_back" className="text-[18px]" />
设置
</Link>
<Outlet />
</div>
</div>
);
}
@@ -0,0 +1,231 @@
import { useState } from 'react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { Icon } from '@/app/Icon';
import { Toast } from '@/app/Toast';
import { useBootstrap } from '@/lib/api/hooks';
import { getSystemNotifications, markSystemNotificationRead, type SystemNotification } from '@/lib/api/notifications';
import { formatEventTimestamp } from '@/lib/messages/time';
/**
* Ported verbatim from the old app's message-center-view.jsx (lines ~420-455) -- the backend writes
* raw event codes into `summary` in several real cases instead of human text (confirmed live,
* docs/IM-后端问题清单.md 问题30), so this maps event/summary codes to display text rather than
* trusting `summary` blindly. Lookup order: NOTIFICATION_TEXT[summary] first (covers the bare
* "accepted"/"rejected"/"continue"/"cancel" codes), then NOTIFICATION_TEXT[event_type], then the
* raw summary (covers genuinely human text like a meeting title or report date), then a generic
* fallback -- never silently show nothing.
*/
const NOTIFICATION_TEXT: Record<string, string> = {
'group.created': '你创建了一个新群聊',
'group.join_request.created': '有人申请加入你管理的群聊',
'group.announcement.updated': '群公告有更新',
'group.member.joined': '有成员加入了群聊',
'group.member.left': '有成员退出了群聊',
'group.member.updated': '群成员资料有更新',
'group.mute.updated': '群禁言设置有变更',
'group.owner.transferred': '群主已转让',
'group.role.updated': '群成员角色有变更',
'group.settings.updated': '群设置有更新',
'group.join_request.accepted': '你的入群申请已通过',
'group.join_request.rejected': '你的入群申请被拒绝',
accepted: '对方已通过你的好友申请',
rejected: '对方拒绝了你的好友申请',
continue: '你确认继续了一次安全风险提示',
cancel: '你取消了一次风险操作确认',
};
function notificationText(item: SystemNotification): string {
return NOTIFICATION_TEXT[item.summary] || NOTIFICATION_TEXT[item.event_type] || item.summary || '您有一条新的系统通知。';
}
/** icon/color per event family -- matches the design's 新设备登录/好友申请/群聊动态/版本更新
* categories, generalized by event_type prefix since the real event set is broader than those 4. */
function iconForEvent(eventType: string): { icon: string; className: string } {
if (eventType.startsWith('friend.')) return { icon: 'person_add', className: 'bg-accent-soft-bg text-accent' };
if (eventType.startsWith('group.')) return { icon: 'groups', className: 'bg-success-bg text-success' };
if (eventType.startsWith('security.')) return { icon: 'gpp_maybe', className: 'bg-error-strip-bg text-unread' };
if (eventType.startsWith('meeting.')) return { icon: 'event', className: 'bg-warn-bg text-warn' };
return { icon: 'notifications', className: 'bg-warn-bg text-warn' };
}
/**
* Routes on `event_type`, NOT `action.type` -- confirmed live (docs/IM-后端问题清单.md 问题30) a
* real friend-request notification's `action` is just `{"user_id":"..."}`, no `type` key at all,
* contradicting this doc's own worked example. `action.device`/`action.ip` (new-device-login) are
* typed but confirmed NOT populated by any real response yet (新增12, still open) -- this simply
* won't render a link for that case until the backend actually implements it, rather than building
* a rich device/IP card around data that can't exist yet.
*/
function actionRoute(item: SystemNotification): { label: string; to: string } | null {
if (item.event_type === 'friend.request.created') return { label: '查看新朋友', to: '/contacts/requests' };
if (item.event_type.startsWith('group.') && typeof item.action?.topic === 'string' && item.action.topic.startsWith('grp')) {
return { label: '查看群聊', to: `/messages/${item.action.topic}` };
}
if (item.action?.device || item.action?.ip) return { label: '前往安全设置', to: '/settings/security' };
return null;
}
/** README §9.1 (four-column layout, no detail rail). No "mark all read" endpoint exists anywhere
* in the protocol doc (confirmed via a research pass) -- "全部标为已读" loops the single-item
* `POST .../read` over every currently-unread item instead of a batch call that doesn't exist. */
export function SystemMessagesView() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const bootstrap = useBootstrap();
const query = useInfiniteQuery({
queryKey: ['system-notifications'],
queryFn: ({ pageParam }) => getSystemNotifications(pageParam),
initialPageParam: '',
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
const unread = items.filter((i) => !i.read_at);
const [error, setError] = useState('');
const readMutation = useMutation({
mutationFn: (id: string) => markSystemNotificationRead(id),
onSuccess: (data, id) => {
queryClient.setQueryData(['system-notifications'], (prev: typeof query.data) =>
prev
? { ...prev, pages: prev.pages.map((p) => ({ ...p, items: p.items.map((i) => (i.id === id ? { ...i, read_at: data.read_at } : i)) })) }
: prev,
);
// AppShell's own NavRail/BottomTabBar unread badge trusts `bootstrap.entry_badges.
// system_unread_count` as its ONLY source for this topic (deliberately excluded from the
// live per-conversation sum) -- but that query is `staleTime: Infinity` and nothing anywhere
// else invalidates it, so without this the badge would never decrease within a session no
// matter how many notifications get marked read here.
queryClient.invalidateQueries({ queryKey: ['bootstrap'] });
},
onError: (err) => setError(err instanceof Error ? err.message : '标记已读失败'),
});
const [markingAll, setMarkingAll] = useState(false);
// `unread` only reflects PAGES ALREADY LOADED into this useInfiniteQuery -- looping just that set
// used to leave any unread notification on a page the user never scrolled to untouched, while the
// button itself still disappeared (since it's gated on the loaded-only `unread.length`), falsely
// implying everything had been cleared. Pages through every remaining page first, then marks the
// now-complete set, so "全部" is actually all of it.
const markAllRead = async () => {
setMarkingAll(true);
try {
// `query` is a snapshot from THIS render -- fetchNextPage() updates the cache and causes a
// re-render with a NEW query object, but this closure keeps holding the old one, so its
// `.hasNextPage` never changes and this loop would never exit for any account with more than
// one page of notifications (a real, confirmed-live-possible infinite hang, not just a stale
// comment claiming otherwise). Re-reading the cache's own `next_cursor` each iteration instead
// of trusting a captured object's field is what actually terminates.
let cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
while (cursor) {
await query.fetchNextPage();
cursor = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.at(-1)?.next_cursor;
}
const allItems = queryClient.getQueryData<typeof query.data>(['system-notifications'])?.pages.flatMap((p) => p.items) ?? [];
// Each item's own failure already surfaces via readMutation's onError above (mutateAsync fires
// it the same as mutate) -- this swallow only stops Promise.all's fail-fast rejection from
// ALSO becoming an unhandled rejection on top of that, it doesn't hide the error itself.
await Promise.all(allItems.filter((i) => !i.read_at).map((i) => readMutation.mutateAsync(i.id))).catch(() => {});
} finally {
setMarkingAll(false);
}
};
return (
<div className="flex h-full w-full flex-col overflow-y-auto bg-[#eef1f4]">
<header className="flex h-15 flex-none items-center justify-between border-b border-border-panel bg-white px-6">
<div className="flex items-center gap-3">
<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>
<span className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-warn-bg text-warn">
<Icon name="notifications" className="text-xl" />
</span>
<div className="flex flex-col">
<strong className="text-[15px] font-semibold text-text-heading">系统消息</strong>
<span className="text-xs text-text-tertiary-3">由 MeetingIM 安全团队发送,不可回复</span>
</div>
</div>
{/* `unread` only reflects pages already loaded into this view, but `markAllRead` itself
correctly walks every remaining page (see its own comment below) -- gating visibility on
`unread.length` alone defeats that fix by hiding the button the moment the FIRST page
happens to show zero unread, even while an older unread notification sits unreached on a
later page. `bootstrap.entry_badges.system_unread_count` is the same authoritative,
all-pages total NavRail's own badge already trusts (and now invalidated by readMutation's
onSuccess below, so it won't go stale either) -- preferred over the loaded-only count
whenever it's available. */}
{(bootstrap.data?.entry_badges?.system_unread_count ?? unread.length) ? (
<button type="button" onClick={markAllRead} disabled={markingAll} className="text-sm text-accent disabled:opacity-60">
{markingAll ? '标记中…' : '全部标为已读'}
</button>
) : null}
</header>
<div className="flex flex-col items-center gap-3 p-6">
{query.isLoading ? (
<div className="py-10 text-sm text-text-tertiary-4">加载中…</div>
) : query.isError ? (
<div className="flex flex-col items-center gap-2 py-10 text-sm text-danger-text">
<span>加载失败,请重试</span>
<button type="button" onClick={() => query.refetch()} className="text-accent">
重试
</button>
</div>
) : items.length === 0 ? (
<div className="py-10 text-sm text-text-tertiary-4">暂无系统消息</div>
) : (
items.map((item) => {
const { icon, className } = iconForEvent(item.event_type);
const action = actionRoute(item);
const isUnread = !item.read_at;
return (
<div key={item.id} className={`w-full flex-none rounded-xl bg-white p-[18px] md:w-[560px] ${isUnread ? '' : 'opacity-85'}`}>
<div className="text-xs text-text-tertiary-3">{formatEventTimestamp(new Date(item.created_at))}</div>
<div className="mt-2 flex items-start gap-3">
<span className={`grid h-9 w-9 flex-none place-items-center rounded-lg ${className}`}>
<Icon name={icon} className="text-xl" />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-center gap-1.5">
<strong className="flex-1 text-[15px] font-semibold text-text-heading">{item.title}</strong>
{isUnread ? <b className="flex-none rounded bg-unread px-1 text-[10px] leading-[14px] text-white">新</b> : null}
</div>
<p className="m-0 text-sm text-[#4a4f57]">{notificationText(item)}</p>
{action ? (
<button
type="button"
onClick={() => {
if (isUnread) readMutation.mutate(item.id);
navigate(action.to);
}}
className="self-start text-sm text-accent"
>
{action.label} ›
</button>
) : null}
</div>
</div>
{/* 08-system-files.dc.html lines 40/108: only the 安全 category gets this footer --
other categories' cards (好友申请/群聊动态/版本更新) have none. */}
{item.event_type.startsWith('security.') ? (
<footer className="mt-3 border-t border-border-row pt-2.5 text-xs text-text-tertiary-3">保持警惕 · MeetingIM 安全团队</footer>
) : null}
</div>
);
})
)}
{query.hasNextPage ? (
<button
type="button"
onClick={() => query.fetchNextPage()}
disabled={query.isFetchingNextPage}
className="py-2 text-sm text-accent disabled:opacity-60"
>
{query.isFetchingNextPage ? '加载中…' : '加载更多'}
</button>
) : null}
</div>
<Toast message={error} variant="error" onDone={() => setError('')} />
</div>
);
}
+14 -1
View File
@@ -103,7 +103,7 @@
/* Shadows */
--shadow-card: 0 8px 32px rgba(35, 74, 125, 0.10);
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.2);
--shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.18);
--shadow-menu: 0 12px 36px rgba(0, 0, 0, 0.16);
--shadow-bubble: 0 1px 2px rgba(0, 0, 0, 0.06);
/* screens/01-account.dc.html's 50px auth CTAs (登录/注册/提交...). */
@@ -114,6 +114,19 @@
--shadow-focus-ring: 0 0 0 3px rgba(22, 119, 255, 0.1);
}
/* MessageRow's jump-to-message flash (ConversationView, 查找聊天记录 result click) -- a brief
background pulse marking the message the user asked to find, fading back to transparent so it
doesn't look like a permanent selection state. */
@keyframes jump-highlight {
0%,
30% {
background-color: var(--color-accent-soft-bg);
}
100% {
background-color: transparent;
}
}
body {
margin: 0;
background: var(--color-surface-page);
+20
View File
@@ -0,0 +1,20 @@
import { imGet } from './client';
export interface AppRelease {
latest_version: string;
min_supported_version: string;
update_type: 'none' | 'optional' | 'required';
release_notes?: string;
store_url?: string;
package_url?: string;
checksum?: string;
published_at?: string;
}
/** docs/IM-前后端对接文档.md:2366-2367. `platform` here is `web`, matching this rewrite's own
* target -- not confirmed against a real response (the doc only shows ios/android in its own
* example), so `update_type` may come back differently for an unrecognized platform value. */
export function getLatestRelease(currentVersion: string, platform: 'web' | 'ios' | 'android' = 'web') {
const qs = new URLSearchParams({ platform, current_version: currentVersion, channel: 'stable' });
return imGet<AppRelease>(`/app/releases/latest?${qs.toString()}`);
}
-8
View File
@@ -6,13 +6,6 @@ export interface MessagePolicy {
recall_window_seconds?: number;
}
export interface RealnameStatus {
required: boolean;
allow_skip: boolean;
remind_after?: string;
status: 'unverified' | 'pending' | 'verified' | string;
}
/** README §2.1's "固定置顶两项" -- topic names are fixed/well-known on this backend (confirmed:
* docs/IM-前后端对接文档.md:909, `file_assistant.topic:"slf"`, `system_messages.topic:"sys"`), not
* per-user values the client has to discover another way. */
@@ -29,7 +22,6 @@ export interface BootstrapEntry {
export interface BootstrapConfig {
message_policy?: MessagePolicy;
limits?: { max_forward_targets?: number; [key: string]: unknown };
security?: { realname?: RealnameStatus };
features?: Record<string, boolean>;
permissions?: Record<string, boolean>;
modules?: unknown;
+70
View File
@@ -0,0 +1,70 @@
// Call REST endpoints (docs/IM-前后端对接文档.md §8.12.1, lines 2066-2076). Confirmed live
// (2026-09-08): `POST /v1/im/calls/rtc-join` is real and processes requests meaningfully (a probe
// with a made-up call_id returned `404 CALL_NOT_FOUND`, not a generic route-404 or a
// field-validation error) -- this is the Agora/声网 RTC path the doc's own closing paragraph
// describes for "新客户端" (§8.12.1), NOT the raw-WebRTC/SDP-over-{note} path §0.5.4.7 and §8.1-8.11
// otherwise describe throughout -- the old app's own real, shipped call code corroborates that the
// raw-WebRTC path is the one that's actually broken in production (its own comments say the
// call-invite pub is gated behind a feature flag that currently rejects it). The initial invite
// still goes through the SAME `{pub}` message as the raw-WebRTC story (confirmed by the doc's own
// sentence: "新客户端在发起方收到 ctrl.params.seq 后...分别调用 rtc-join" -- i.e. rtc-join happens
// AFTER and IN ADDITION TO the pub-based invite, not instead of it) -- see send.ts's call-invite
// helper. Ringing/accept/hang-up stay on Tinode `{note}` (lightweight state signals, not media
// negotiation); only the SDP/ICE exchange is replaced by Agora's own SDK doing media transport.
import { imGet, imPost } from './client';
export interface RtcJoinResult {
provider: string;
auth_mode: string;
app_id: string;
channel_name: string;
uid: string;
token: string;
token_expires_at: string;
call_id: string;
topic: string;
seq: number;
call_type: 'audio' | 'video';
role: 'caller' | 'callee';
}
export function joinRtcCall(topic: string, callId: string, seq: number, callType: 'audio' | 'video', role: 'caller' | 'callee') {
return imPost<RtcJoinResult>('/calls/rtc-join', { topic, call_id: callId, seq, call_type: callType, role });
}
/**
* Confirmed live (2026-09-08, real call `75728`) -- completely different from the doc's own minimal
* example: `direction` is a string enum (`"outgoing"`/presumably `"incoming"`), not an `incoming`
* boolean; there's no `peer` object at all, just a flat `peer_user_id` (a real `usr...` id, NOT the
* `p2p...`-hashed form `topic` itself comes back as -- so unlike favorites.ts's `source.topic`, THIS
* field can be resolved to a display name via the chat list); and there are four separate
* timestamps (`created_at`/`started_at`/`finished_at`/`updated_at`), no single `ts`.
*/
export interface CallHistoryItem {
topic: string;
seq: number;
call_id: string;
call_type: 'audio' | 'video';
direction: 'incoming' | 'outgoing' | string;
status: string;
result?: string;
end_reason?: string;
duration_ms: number;
caller_id: string;
callee_id: string;
peer_user_id: string;
created_at: string;
started_at?: string;
finished_at?: string;
updated_at?: string;
}
export function getCallHistory(cursor = '', limit = 20) {
const qs = new URLSearchParams({ limit: String(limit) });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: CallHistoryItem[]; next_cursor?: string }>(`/calls?${qs.toString()}`);
}
export function getTopicCallHistory(topic: string, cursor = '') {
return imGet<{ items: CallHistoryItem[]; next_cursor?: string }>(`/calls/${encodeURIComponent(topic)}${cursor ? `?cursor=${cursor}` : ''}`);
}
+46 -3
View File
@@ -39,6 +39,14 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
IDEMPOTENCY_CONFLICT: '内容已发生变化,请刷新后重试',
RESOURCE_NOT_FOUND: '内容不存在或已被删除',
PERMISSION_DENIED: '没有权限执行此操作',
// docs/IM-前后端对接文档.md:2629's own canonical "must implement" list -- distinct from
// PERMISSION_DENIED above (a separately-observed live reason, kept as-is). Missing this one meant
// imRequest's fallback chain (`payload.message || payload.reason || ...`) leaked the server's raw
// internal message or the bare reason string straight to the user for this exact reason code,
// which the doc explicitly says must never happen (§0.4/§12.4: "不得把服务端 message 原样展示给用户").
FORBIDDEN: '没有权限执行此操作',
// docs/IM-前后端对接文档.md:2312 -- ProfileSettingsPage's birth_date save can hit this.
PROFILE_FIELD_INVALID: '资料字段格式不正确,请检查后重试',
NOT_FOUND: '内容不存在或已被删除',
VERSION_CONFLICT: '内容已被修改,请刷新后重试',
MALFORMED_REQUEST: '请求内容有误,请稍后重试',
@@ -59,22 +67,28 @@ const REASON_MESSAGES: Record<string, string | ((details: unknown) => string | n
GROUP_ADMIN_LIMIT_EXCEEDED: '群管理员数量已达上限',
INVALID_CREDIT_CODE: '统一社会信用代码格式不正确',
INVALID_EMAIL: '邮箱格式不正确',
MAILBOX_UNAVAILABLE: '总经理信箱暂不可用',
APPLICATION_DUPLICATED: '该企业已有申请正在审核中,请耐心等待',
TOO_MANY_FILES: '文件数量超出限制',
FRIEND_RELATION_REQUIRED: '需要先添加对方为好友',
MESSAGE_NOT_ALLOWED: '当前状态无法发送消息',
GROUP_MUTED: '你已被禁言,无法发言',
GROUP_MEMBER_LIMIT_EXCEEDED: '群成员数量已达上限',
GROUP_VERSION_CONFLICT: '群信息已被修改,请刷新后重试',
INVITATION_EXPIRED: '邀请已过期',
TENANT_USER_QUOTA_EXCEEDED: '企业账号数已达上限',
TENANT_GROUP_QUOTA_EXCEEDED: '企业群组数已达上限',
FILE_TOO_LARGE: '文件大小超出限制',
FILE_FORBIDDEN: '没有权限访问此文件',
FILE_UNAVAILABLE: '文件暂不可用',
STORAGE_QUOTA_EXCEEDED: '存储空间已达上限',
REALNAME_REQUIRED: '需要先完成实名认证',
YOUTH_MODE_RESTRICTED: '青少年模式下无法使用此功能',
RISK_CONFIRMATION_REQUIRED: '需要先完成安全确认',
RISK_ACTION_BLOCKED: '该操作已被安全策略拦截',
QR_CODE_EXPIRED: '二维码已过期,请对方刷新后重新扫描',
QR_CODE_INVALID: '二维码无效,请重新扫描',
FRIEND_REQUEST_NOT_ALLOWED: '对方暂不允许添加好友',
USER_NOT_DISCOVERABLE: '未找到该用户',
REQUEST_ALREADY_HANDLED: '该申请已被处理,请刷新查看最新状态',
SEARCH_RATE_LIMITED: '搜索过于频繁,请稍后再试',
// Shared by two unrelated verify endpoints (independent-password PIN check and plain SMS
// verification-code verify) -- keep generic enough to be accurate for both.
CODE_INVALID: '验证码或独立密码不正确',
@@ -167,6 +181,11 @@ export async function imRequest<T = unknown>(
}
if (!response.ok || payload?.code) {
const known = translateReason(payload.reason, payload.details);
// A 401 on a request that DID carry a Bearer token means the server has stopped honoring that
// token (expired/revoked while a WS reconnect or a stale tab kept using it) -- nothing anywhere
// else in this client uniformly handled this before, so a session invalidated mid-use just made
// every subsequent call fail the same way forever with no path back to a real login.
if (auth && response.status === 401 && authToken()) forceReauth();
throw new ImApiError(
known || payload.message || payload.reason || response.statusText || '请求失败',
payload.reason,
@@ -177,6 +196,30 @@ export async function imRequest<T = unknown>(
return normalizeResponse(payload) as T;
}
/** Clears the invalidated session and hard-navigates to /login -- deliberately a full page load
* (not an in-SPA redirect) so every piece of in-memory state (the Tinode singleton, TanStack Query
* cache, any open modal's local state) resets from scratch rather than needing each of them to
* separately react correctly to "you were logged in a second ago, now you're not." Guarded so a
* burst of parallel requests failing at once (a realistic case: several queries in flight when the
* token expires) only navigates once. */
let reauthTriggered = false;
function forceReauth() {
if (reauthTriggered) return;
sessionStorage.removeItem('im-session-token');
useSessionStore.getState().setMyUserId(null);
// The latch only needs to survive until the hard reload below actually happens -- that reload is
// what resets it (a fresh module load starts with `reauthTriggered = false` again). Setting the
// latch UNCONDITIONALLY (the previous version did, before this check) meant a stray/background
// request 401-ing while already sitting on `/login` (reachable via an in-SPA `&lt;Navigate&gt;`, which
// never reloads) burned the latch with no reload to reset it -- silently disabling this function
// for the rest of that tab's life, including a genuine expiry after the user logs back in on the
// same SPA instance.
if (typeof window !== 'undefined' && window.location.pathname !== '/login') {
reauthTriggered = true;
window.location.href = '/login';
}
}
const idem = () => ({ 'Idempotency-Key': requestId() });
// `opts.headers` is merged ON TOP of idem() (not replaced by the outer `...opts` spread) so a
+103
View File
@@ -0,0 +1,103 @@
// Favorites REST endpoints (docs/IM-前后端对接文档.md ch.7.1-7.8). `favoriteMessages` (the create
// side) already lives in messages.ts since it's called from a message's context menu, not from
// here -- this file is the read/manage side (list, detail, delete, batch-delete, forward).
import type { DraftyContent } from 'tinode-sdk';
import { imDelete, imGet, imPost } from './client';
export type FavoriteType = 'text' | 'image' | 'video' | 'voice' | 'file' | 'link';
/**
* `snapshot`'s shape is polymorphic per `type`. Confirmed live (2026-09-08, real favorite
* `fav_I2yn5Rdux0E`) that a `type:"text"` snapshot is NOT the doc's minimal `{text}` example (line
* 1933) -- it's essentially the whole original message: `{content, from, head, seq, text, topic,
* ts, type, version}`, i.e. `text`/`content` are duplicates of each other and `seq`/`topic`/`type`
* duplicate the outer `source`/`type` fields. Genuinely useful new field: `from` -- a real sender
* user id, WITHOUT its `usr` prefix (confirmed: my own id is `usrH5dvZ3ckWxo`, this field came back
* as `H5dvZ3ckWxo`) -- a per-field prefix-stripping quirk not seen on any other endpoint in this
* app, so re-adding `usr` before matching it against `contacts[].topic` elsewhere is a real but
* fragile workaround, not done here yet (see the plan file's Phase 5 findings).
*
* Confirmed live (2026-09-08, data-rich test account) that this SAME "whole original message"
* shape applies to EVERY type, not just `text` -- `image`/`video`/`voice`/`file` all come back as
* `{content, from, head, seq, text, topic, ts, type, version}` too, where `content` is a real Drafty
* object (never the doc's flat `snapshot.file_id`/`duration_ms`/`snapshot.file{...}` shape). The
* actual per-type media data lives nested in `content.ent[].data`, keyed by Drafty entity type
* (`tp`): `IM` (image), `VD` (video), `AU` (voice), `EX` (file). Voice/image/video carry their
* payload INLINE as base64 in `data.val` (confirmed by decoding the base64 header: voice -> `RIFF`
* WAV, image -> PNG signature, video's `val` -> WebM/EBML signature); video ALSO carries a separate
* inline base64 JPEG poster frame in `data.preview` (distinct from `val`, which is the actual video
* bytes) -- there is no separate thumbnail for image since the image IS the thumbnail. `file` has no
* `val` at all, only an external `ref` (`/v0/file/s/{id}`), matching how `EX` already works in real
* chat messages. The old flat `snapshot.file_id`/`thumbnail_file_id`/`duration_ms`/`file{...}` fields
* below are kept as a defensive fallback (never observed live, but the doc's own worked examples
* disagree with its prose here too -- see next paragraph -- so it's cheap insurance, not dead code).
*
* The doc's own worked examples also disagree with its OWN prose for `type:"file"`: §7.3's text
* says file snapshots nest under `snapshot.file{name,size,mime,file_id}`, but the worked JSON
* example (line 1936) has `file_id`/`name` FLAT on `snapshot` directly, no nesting -- both shapes
* are typed as optional so either renders, trusting the concrete example over the prose.
*/
export interface Favorite {
favorite_id: string;
type: FavoriteType;
snapshot: {
text?: string;
content?: DraftyContent | string;
from?: string;
matches?: Array<{ at: number; len: number }>;
thumbnail_file_id?: string;
file_id?: string;
name?: string;
size?: number;
mime?: string;
file?: { name: string; size: number; mime: string; file_id: string };
duration_ms?: number;
waveform?: number[];
url?: string;
title?: string;
summary?: string;
risk_level?: string;
};
source?: { topic: string; seq: number };
collected_at: string;
availability: 'available' | 'removed' | string;
permissions?: { can_copy?: boolean };
}
/** Pulls one Drafty entity's `data` out of a favorite's `snapshot.content` by entity type (`tp`) --
* see the `Favorite` doc comment above for why this, not the flat `snapshot.*` fields, is where the
* real per-type media data for `image`/`video`/`voice`/`file` actually lives. */
export function favoriteEntityData(content: Favorite['snapshot']['content'], tp: string): Record<string, unknown> | undefined {
if (!content || typeof content === 'string') return undefined;
return content.ent?.find((e) => e.tp === tp)?.data;
}
export function getFavorites(type: FavoriteType | 'all' = 'all', query = '', cursor = '') {
const qs = new URLSearchParams({ type });
if (query) qs.set('q', query);
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: Favorite[]; next_cursor?: string }>(`/favorites?${qs.toString()}`);
}
export function getFavorite(favoriteId: string) {
return imGet<Favorite>(`/favorites/${encodeURIComponent(favoriteId)}`);
}
export function deleteFavorite(favoriteId: string) {
return imDelete<{ favorite_id: string; deleted: boolean }>(`/favorites/${encodeURIComponent(favoriteId)}`);
}
export function batchDeleteFavorites(favoriteIds: string[]) {
return imPost<{ items: Array<{ favorite_id: string; deleted: boolean }>; failed?: Array<{ favorite_id: string; reason: string }> }>(
'/favorites/batch-delete',
{ favorite_ids: favoriteIds },
);
}
export function forwardFavorites(favoriteIds: string[], targetTopics: string[], comment = '') {
return imPost<{
items: Array<{ favorite_id: string; target_topic: string; status: string; seq?: number }>;
failed?: Array<{ favorite_id: string; target_topic: string; reason: string }>;
}>('/favorites/forward', { favorite_ids: favoriteIds, target_topics: targetTopics, comment });
}
+58 -8
View File
@@ -3,16 +3,26 @@
// direct-message-view.jsx) -- verified there, not guessed.
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
/** Confirmed live shape (doc line 1417): `initial` is server-computed (handles pinyin-to-letter
* itself), so the contacts list groups by THIS field, not a client-side first-character guess like
* CreateGroupModal's friend picker had to fall back to (that one has no `initial` in its response). */
export interface Friend {
user_id: string;
display_name?: string;
remark?: string;
avatar_file_id?: string;
initial?: string;
topic?: string;
relationship?: string;
online_visibility?: string;
capabilities?: { can_send?: boolean; can_call?: boolean };
[key: string]: unknown;
}
export function getFriends(query = '', cursor = '') {
export function getFriends(query = '', cursor = '', initial = '') {
const qs = new URLSearchParams();
if (query) qs.set('query', query);
if (initial) qs.set('initial', initial);
if (cursor) qs.set('cursor', cursor);
const suffix = qs.toString();
return imGet<{ items: Friend[]; next_cursor?: string }>(`/friends${suffix ? `?${suffix}` : ''}`);
@@ -26,15 +36,22 @@ export function updateFriendRemark(userId: string, remark: string) {
return imPatch<void>(`/friends/${encodeURIComponent(userId)}`, { remark });
}
/** Confirmed live shape (doc line 1423): no `action` field as this file originally typed (there's
* nothing in the response describing what UI affordance to show -- that's derived client-side
* from `relationship`/`can_add` instead), and `avatar_file_id` is present. */
export interface UserSearchResult {
user_id: string;
display_name: string;
relationship: 'none' | 'friend' | 'pending' | string;
avatar_file_id?: string;
public_account?: string;
// docs/IM-前后端对接文档.md:1289 -- real enum is these 4 values, direction-specific, never a bare
// "pending" (a prior version of this type had that wrong, which meant no caller's `=== 'pending'`
// comparison could ever match either real pending state).
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
can_add: boolean;
action: string;
}
export function searchUsers(query: string, type: 'account' = 'account', cursor = '') {
export function searchUsers(query: string, type: 'account' | 'exact_account' | 'phone' = 'account', cursor = '') {
const qs = new URLSearchParams({ q: query, type });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: UserSearchResult[]; next_cursor?: string }>(`/users/search?${qs.toString()}`);
@@ -49,6 +66,9 @@ export function sendFriendRequest(targetUserId: string, message?: string, source
});
}
/** `source` and `created_at` (doc line 1419's worked example) were missing from this file's
* original type -- needed for the 新朋友 list's "今天 10:12 · 通过手机号搜索" line. `handled_at`
* is mentioned in the doc's prose but not its own worked example, kept optional either way. */
export interface FriendRequest {
request_id: string;
applicant_id: string;
@@ -56,12 +76,22 @@ export interface FriendRequest {
direction: 'inbound' | 'outbound';
status: string;
message?: string;
source?: string;
created_at: string;
handled_at?: string;
peer?: { user_id: string; display_name?: string };
topic: string;
topic?: string;
}
export function getFriendRequests() {
return imGet<{ items: FriendRequest[]; next_cursor?: string }>('/friend-requests');
/** Doc's confirmed query params (line 1277/1419) -- the original zero-arg version always hit the
* endpoint with no filters at all, whose default direction/status scope isn't documented; the
* design's 新朋友 list needs BOTH pending and already-handled inbound requests shown together
* (处理过的显示灰字"已通过/已拒绝"), so this defaults to exactly that rather than relying on
* whatever an unfiltered call happens to return. */
export function getFriendRequests(direction: 'inbound' | 'outbound' = 'inbound', status: 'pending' | 'all' = 'all', cursor = '') {
const qs = new URLSearchParams({ direction, status });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: FriendRequest[]; next_cursor?: string }>(`/friend-requests?${qs.toString()}`);
}
export function acceptFriendRequest(requestId: string, version?: number) {
@@ -72,6 +102,14 @@ export function rejectFriendRequest(requestId: string, version?: number) {
return imPost<void>(`/friend-requests/${encodeURIComponent(requestId)}/reject`, version !== undefined ? { version } : {});
}
/**
* Confirmed live (2026-09-08, real friend `usruF4Uil-jCKM`): `capabilities.can_add_friend`, not the
* doc's own `can_add` (line 1425) -- another confirmed field-name mismatch, not this file's guess.
* `remark` was also confirmed absent from a real friend's response even though the doc's worked
* example shows `"remark":""` present -- FriendProfileView.tsx's `profile.remark || profile.
* display_name` already degrades correctly either way, so this isn't a rendering bug, just a gap
* between the doc and what this deployment actually returns right now.
*/
export interface UserProfile {
user_id: string;
display_name: string;
@@ -81,8 +119,11 @@ export interface UserProfile {
region?: string;
public_account?: string;
signature?: string;
relationship: string;
// Same real enum as UserSearchResult's (docs/IM-前后端对接文档.md:1289) -- direction-specific
// pending states, never a bare "pending".
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
topic?: string;
capabilities?: { can_send?: boolean; can_call?: boolean; can_add_friend?: boolean };
}
/** `identifier` accepts `user_id`, `username`, or `public_account` -- P2P topic names are the peer's
@@ -114,6 +155,15 @@ export function unblockUser(userId: string) {
return imDelete<void>(`/blocks/${encodeURIComponent(userId)}`);
}
/** docs/IM-前后端对接文档.md:1380,1431 -- up to 100 ids per call, per-item result in `items[]` (this
* backend's usual batch-endpoint shape, see fetchAllPages's own header comment on the class of bug
* that comes from assuming a 200 means every item succeeded). */
export function batchUnblockUsers(userIds: string[]) {
return imPost<{ items: Array<{ user_id: string; blocked: boolean }>; failed?: Array<{ user_id: string; reason: string }> }>('/blocks/batch-delete', {
user_ids: userIds,
});
}
export interface BlockedUser {
user: { user_id: string; display_name?: string; avatar_file_id?: string };
blocked_at: string;
+431
View File
@@ -0,0 +1,431 @@
// Group REST endpoints (docs/IM-前后端对接文档.md ch.6). Path/field shapes ported from the old app's
// src/lib/im-api.js (lines 686-759), whose own header states they were verified directly against the
// deployed backend -- its inline comments flag three real doc/server mismatches this file follows
// instead of the doc where they conflict (see each function's own comment):
// - reject-join-request has no `reason` field (server 400s if sent, DisallowUnknownFields)
// - group-files search param is `q`, not `query`
// - addGroupFile needs more fields than the doc's minimal example, or it registers blank metadata
// Unlike P2P conversation settings, almost the entire group management surface is REST, not native
// Tinode WS -- confirmed against the old app's actually-shipped UI (group-management-view.jsx), which
// never calls topic.setMeta({desc}) or {set sub private} for groups despite the protocol doc showing
// WS equivalents exist on the wire. Only message send/receive/typing/receipts stay on WS (topics.ts).
import { fetchAllPages } from './client';
import { imDelete, imGet, imPatch, imPost, imPut } from './client';
export interface GroupPermissions {
can_edit_profile?: boolean;
can_invite_members?: boolean;
can_manage_members?: boolean;
can_manage_settings?: boolean;
can_leave?: boolean;
can_mention_all?: boolean;
}
export interface GroupSummaryItem {
topic: string;
name: string;
avatar_file_id?: string;
member_count: number;
my_role: GroupRole;
updated_at?: string;
}
export function getGroups(category: 'owned' | 'managed' | 'joined' | '' = '', query = '', cursor = '') {
const qs = new URLSearchParams();
if (category) qs.set('category', category);
if (query) qs.set('query', query);
if (cursor) qs.set('cursor', cursor);
const suffix = qs.toString();
return imGet<{ items: GroupSummaryItem[]; next_cursor?: string; has_more?: boolean }>(`/groups${suffix ? `?${suffix}` : ''}`);
}
export interface GroupProfile {
topic: string;
name: string;
avatar_file_id?: string;
description?: string;
owner?: string;
member_count: number;
version: number;
permissions?: GroupPermissions;
}
export function createGroup(body: { name: string; description?: string; member_user_ids: string[]; avatar_file_id?: string }) {
return imPost<GroupProfile>('/groups', body);
}
export function getGroup(topic: string) {
return imGet<GroupProfile>(`/groups/${encodeURIComponent(topic)}`);
}
export function updateGroup(topic: string, patch: { name?: string; avatar_file_id?: string; description?: string; version: number }) {
return imPatch<GroupProfile>(`/groups/${encodeURIComponent(topic)}`, patch);
}
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): each field's "can I edit this" flag is
* a flat `{field}_editable` sibling, NOT a nested `editable: {...}` object as the doc's phrasing
* ("每个字段返回 editable") could be read either way -- the doc itself never shows a worked
* example for this response, so this was a guess until checked against the real payload. */
export interface GroupGovernance {
join_approval: boolean;
join_approval_editable?: boolean;
mute_all: boolean;
mute_all_editable?: boolean;
member_upload_files: boolean;
member_upload_files_editable?: boolean;
member_private_chat: boolean;
member_private_chat_editable?: boolean;
member_invite: boolean;
member_invite_editable?: boolean;
version: number;
}
/**
* Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`) against three original guesses in this
* type: there is no `announcement` field at all (the summary endpoint carries that instead, see
* GroupDetailColumn.tsx/ConversationView.tsx's own comments); profile fields (`name`, `description`,
* `avatar_file_id`, `version`) come back FLAT on this object, not nested under a `profile` key; and
* `governance` is the FULL `GroupGovernance` shape (every `*_editable` flag and its own `version`),
* not the 3-field `Pick` this originally guessed. `owner`/`message_retention` are real fields this
* file hadn't typed at all until this pass.
*/
export interface GroupSettings {
topic: string;
name: string;
description?: string;
avatar_file_id?: string;
owner: string;
my_role: GroupRole;
permissions?: GroupPermissions;
member_count: number;
pinned: boolean;
mute_notifications: boolean;
message_retention?: string;
governance?: GroupGovernance;
version: number;
}
export function getGroupSettings(topic: string) {
return imGet<GroupSettings>(`/groups/${encodeURIComponent(topic)}/settings`);
}
export interface GroupMemberSettings {
pinned: boolean;
mute_notifications: boolean;
version: number;
updated_at: string;
}
/** No `version` in the request body -- confirmed live 2026-09-09: the backend struct for this
* endpoint only declares `pinned`/`mute_notifications`, and strict unknown-field JSON decoding
* 400s MALFORMED_REQUEST on anything else, `version` included. This endpoint doesn't do optimistic
* locking at all (the server bumps its own stored version unconditionally on every call); the
* `version` in the RESPONSE is read-only bookkeeping, not something the client should echo back.
* This one bug made every group's 置顶聊天/消息免打扰 toggle unusable, on every account/role tested. */
export function updateGroupMemberSettings(topic: string, patch: { pinned?: boolean; mute_notifications?: boolean }) {
return imPatch<GroupMemberSettings>(`/groups/${encodeURIComponent(topic)}/member-settings`, patch);
}
export function leaveGroup(topic: string) {
return imPost<{ topic: string; status: string }>(`/groups/${encodeURIComponent(topic)}/leave`, {});
}
/** doc-flagged 待实现 (not yet implemented server-side) -- kept typed and ready for when it lands. */
export function disbandGroup(topic: string) {
return imDelete<{ topic: string; deleted: boolean }>(`/groups/${encodeURIComponent(topic)}`);
}
export type GroupRole = 'owner' | 'admin' | 'member';
export interface GroupMember {
user_id: string;
display_name?: string;
avatar_file_id?: string;
role: GroupRole;
mentionable?: boolean;
eligible?: boolean;
/** Confirmed live (2026-09-08): present when `eligible:false`, e.g. `"owner"` -- not in the
* protocol doc's worked examples at all. Not yet surfaced in any UI (the admin/mute/owner-
* transfer pickers just filter ineligible candidates out rather than showing them grayed out
* with a reason), but real and worth keeping typed since it's cheap and already observed. */
ineligible_reason?: string;
acs?: { want?: string; given?: string; mode?: string };
}
/** `GET .../members` never returns `group_nickname` on any item (confirmed live by the old app) --
* only getGroupMemberProfile() does. Don't read nickname off this list. */
export function getGroupMembers(
topic: string,
opts: { query?: string; cursor?: string; role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
) {
const qs = new URLSearchParams();
if (opts.query) qs.set('query', opts.query);
if (opts.cursor) qs.set('cursor', opts.cursor);
if (opts.role) qs.set('role', opts.role);
if (opts.eligibleRole) qs.set('eligible_role', opts.eligibleRole);
if (opts.mentionable) qs.set('mentionable', 'true');
if (opts.muteEligible) qs.set('mute_eligible', 'true');
const suffix = qs.toString();
return imGet<{ items: GroupMember[]; next_cursor?: string; permissions?: { can_mention_all?: boolean } }>(
`/groups/${encodeURIComponent(topic)}/members${suffix ? `?${suffix}` : ''}`,
);
}
export function getAllGroupMembers(
topic: string,
opts: { role?: GroupRole; eligibleRole?: 'admin' | 'owner'; mentionable?: boolean; muteEligible?: boolean } = {},
) {
return fetchAllPages((cursor) => getGroupMembers(topic, { ...opts, cursor }));
}
export interface GroupMemberProfile {
topic: string;
user_id: string;
display_name?: string;
group_nickname?: string;
role: GroupRole;
relationship?: string;
capabilities?: { can_send?: boolean; can_call?: boolean };
}
/** `topic` must be the `grp...` id, never a `usr...` id -- confirmed by the protocol doc's own warning.
* Do NOT call this with your own user_id: the backend computes a self/other relationship context
* for this generic path that errors out when both sides are the same uid (confirmed live 2026-09-09,
* stable 400 MALFORMED_REQUEST). Use `getMyGroupProfile()` for your own group nickname instead --
* the backend added a dedicated `members/me/profile` route that sidesteps this entirely. */
export function getGroupMemberProfile(topic: string, userId: string) {
return imGet<GroupMemberProfile>(`/groups/${encodeURIComponent(topic)}/members/${encodeURIComponent(userId)}/profile`);
}
/** Dedicated self-profile route -- unlike getGroupMemberProfile(topic, myUserId), this one actually
* works (see that function's own warning) and, as a bonus, returns the real optimistic-lock `version`
* on every read, so callers no longer need to remember the last save's version between edits. */
export function getMyGroupProfile(topic: string) {
return imGet<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`);
}
export function updateMyGroupNickname(topic: string, nickname: string, version: number) {
return imPut<{ nickname: string; version: number; updated_at: string }>(`/groups/${encodeURIComponent(topic)}/members/me/profile`, {
nickname,
version,
});
}
export function getGroupRoleLimits(topic: string) {
return imGet<{ max_admin_count: number; current_admin_count: number }>(`/groups/${encodeURIComponent(topic)}/role-limits`);
}
export function addGroupAdmins(topic: string, userIds: string[], version: number) {
return imPost<{
succeeded: Array<{ user_id: string; role: GroupRole }>;
failed?: Array<{ user_id: string; reason: string }>;
admins: GroupMember[];
version: number;
}>(`/groups/${encodeURIComponent(topic)}/admins/batch`, { user_ids: userIds, version });
}
export function removeGroupAdmin(topic: string, userId: string, version: number) {
return imDelete<{ user_id: string; role: GroupRole; updated_at: string }>(
`/groups/${encodeURIComponent(topic)}/admins/${encodeURIComponent(userId)}`,
{ headers: { 'If-Match': String(version) } },
);
}
export interface GroupMute {
user: { user_id: string; display_name?: string };
muted_by?: string;
muted_at: string;
expires_at: string | null;
}
export function muteGroupMembers(topic: string, members: Array<{ user_id: string; expires_at: string | null }>, reason?: string) {
// NOT `/mutes/batch` -- confirmed against the real backend (im_be/server/im_http.go's routing
// switch has no such case, so that path 405s unconditionally) -- the real batch-mute endpoint is
// plain `POST .../mutes` (the same base path `getGroupMutes` GETs from), body shape unchanged.
return imPost<{ items: Array<{ user_id: string; muted_by: string; muted_at: string; expires_at?: string; reason?: string; version: number }> }>(
`/groups/${encodeURIComponent(topic)}/mutes`,
{ members, reason },
);
}
export function getGroupMutes(topic: string, cursor = '') {
return imGet<{ items: GroupMute[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/mutes${cursor ? `?cursor=${cursor}` : ''}`);
}
export function unmuteGroupMember(topic: string, userId: string) {
return imDelete<{ user_id: string; muted: boolean }>(`/groups/${encodeURIComponent(topic)}/mutes/${encodeURIComponent(userId)}`);
}
/** Needs `X-Action-Token` (two-factor-guarded write, docs/IM-前后端对接文档.md §6.26) -- not yet
* wired since no risk-challenge flow is built anywhere in this app yet; calling this will 409 until
* that's added. Kept typed so the group-management UI can be built ahead of that. */
export function transferGroupOwner(topic: string, targetUserId: string, version: number) {
return imPost<{ owner: string; previous_owner: string; version: number }>(`/groups/${encodeURIComponent(topic)}/owner-transfer`, {
target_user_id: targetUserId,
version,
});
}
export function getGroupGovernance(topic: string) {
return imGet<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`);
}
export function updateGroupGovernance(
topic: string,
patch: Partial<Pick<GroupGovernance, 'join_approval' | 'mute_all' | 'member_upload_files' | 'member_private_chat' | 'member_invite'>> & { version: number },
) {
return imPatch<GroupGovernance>(`/groups/${encodeURIComponent(topic)}/governance`, patch);
}
export function inviteGroupMembers(topic: string, userIds: string[], message?: string) {
return imPost<{ items: Array<{ user_id: string; status: string; invitation_id?: string }> }>(`/groups/${encodeURIComponent(topic)}/invitations`, {
user_ids: userIds,
message,
});
}
/** Confirmed LIVE (2026-09-09) this does NOT match either the protocol doc's own worked example
* (`docs/IM-前后端对接文档.md:1741`, which shows a nested `applicant:{user_id,display_name}`
* object) or this file's own previous type -- the real response is a FLAT `user_id` string with no
* display name anywhere on it at all, plus `inviter_id`/`message`/`handled_by` fields the doc's
* example doesn't show. Reading `req.applicant.user_id` against this real shape would throw
* (`applicant` is `undefined`) the first time this modal ever rendered a real request -- caught by
* live-calling `getGroupJoinRequests` directly, not by reading the doc or the mock. */
export interface GroupJoinRequest {
request_id: string;
user_id: string;
/** Non-empty only when `source` reflects an invite (mock's "王工 邀请" rows) -- empty string for
* a self-initiated request (search/QR). */
inviter_id: string;
/** The applicant's own join message, if any (mock: `"我是新来的测试同学"`). */
message?: string;
status: string;
source: string;
created_at: string;
handled_at?: string;
handled_by?: string;
version: number;
}
/** docs/IM-前后端对接文档.md:1591,1740 -- submitting a join request (the requester's own side; every
* other function in this block is the admin's reviewing side). `source` and `qr_token` are only
* relevant for the QR-scan path (GroupQRCodeModal/QRScannerModal's `join_group` purpose) -- a plain
* "request to join" with no QR code involved would omit both, though this app doesn't have another
* UI entry point that reaches a group's join-requests without already being a member (no public
* group directory/search exists anywhere in this codebase or its design handoff). */
export function submitGroupJoinRequest(topic: string, body: { message?: string; source?: 'qr' | string; qr_token?: string } = {}) {
return imPost<{ request_id: string; topic: string; status: string; created_at: string; version: number }>(
`/groups/${encodeURIComponent(topic)}/join-requests`,
body,
);
}
export function getGroupJoinRequests(topic: string, status: 'pending' | 'all' = 'pending', cursor = '') {
const qs = new URLSearchParams({ status });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: GroupJoinRequest[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/join-requests?${qs.toString()}`);
}
export function acceptGroupJoinRequest(topic: string, requestId: string, version: number) {
return imPost<{ request_id: string; status: string; version: number }>(
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/accept`,
{ version },
);
}
/** Body is `{version}` ONLY -- no `reason` field. The protocol doc's own example shows a `reason`
* key, but the old app's im-api.js confirmed live that the real server struct doesn't have one and
* every request body is validated with DisallowUnknownFields(), so sending `reason` 400s the call. */
export function rejectGroupJoinRequest(topic: string, requestId: string, version: number) {
return imPost<{ request_id: string; status: string; version: number }>(
`/groups/${encodeURIComponent(topic)}/join-requests/${encodeURIComponent(requestId)}/reject`,
{ version },
);
}
/** Confirmed live: `announcement` is always a present object, even with nothing published --
* every field just comes back zero-valued (`content:""` etc), never `null`. Check `content` for
* emptiness, not object existence -- same gotcha as `getLatestGroupAnnouncement` below. */
export function getGroupSummary(topic: string) {
return imGet<{
pending_join_request_count: number;
announcement?: { announcement_id: string; content: string; author: string; published_at: string; version: number } | null;
permissions?: GroupPermissions;
}>(`/groups/${encodeURIComponent(topic)}/summary`);
}
/** Confirmed live (2026-09-08, real group `grpvrdHqKqkCq0`): `author` is a flat `usr...` id, not
* the `published_by` object this file originally guessed -- resolve it against the group's member
* list for a display name/role, same as any other bare user id in this API. `read_count`/
* `member_count` are confirmed ABSENT from the real response (not just unconfirmed), and when
* nothing has been published yet the endpoint still returns `200` with every field zero-valued
* (`announcement_id:"", author:"", content:"", published_at:"", version:0`), never `404`/`null` --
* callers must check `content` for emptiness, not object existence. */
export interface GroupAnnouncement {
announcement_id: string;
topic: string;
content: string;
version: number;
published_at: string;
author: string;
permissions?: { can_edit?: boolean };
}
export function getLatestGroupAnnouncement(topic: string) {
return imGet<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/latest`);
}
export function updateCurrentGroupAnnouncement(topic: string, content: string, version: number) {
return imPut<GroupAnnouncement>(`/groups/${encodeURIComponent(topic)}/announcements/current`, { content, version });
}
export function readGroupAnnouncement(topic: string, announcementId: string) {
return imPost<{ announcement_id: string; read_at: string }>(
`/groups/${encodeURIComponent(topic)}/announcements/${encodeURIComponent(announcementId)}/read`,
{},
);
}
/** Confirmed live (2026-09-08, real registered file `gfile_PNof3SOlWz0`): fields are FLAT
* `file_name`/`file_size`/`mime_type` (not the doc-adjacent `name`/`size`/`mime` this type
* previously declared -- undetected until now because this list only ever rendered its own
* `暂无群文件` empty state, never a real row), `uploader_id` is a bare string (no nested `uploader`
* object, no `display_name` alongside it), and there is no `permissions` object at all -- gating a
* delete affordance needs a different signal (see GroupFilesModal's own comment on how it copes). */
export interface GroupFile {
group_file_id: string;
file_id: string;
file_name: string;
file_size: number;
mime_type: string;
uploader_id?: string;
created_at: string;
download_state?: string;
}
/** Search param is `q`, not `query` as the protocol doc's own example URL shows -- confirmed live by
* the old app that the real server reads `q` and silently no-ops a `query` param instead. */
export function getGroupFiles(topic: string, q = '', type = '', cursor = '') {
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (type) qs.set('type', type);
if (cursor) qs.set('cursor', cursor);
const suffix = qs.toString();
return imGet<{ items: GroupFile[]; next_cursor?: string }>(`/groups/${encodeURIComponent(topic)}/files${suffix ? `?${suffix}` : ''}`);
}
/**
* The doc's minimal `{file_id}` body registers a file with blank name/0 size, since every other
* field is server-side `omitempty` -- confirmed live by the old app, which always sends the full
* set below.
*/
export function addGroupFile(
topic: string,
file: { file_id: string; file_name: string; mime_type: string; file_type: string; file_size: number; source_topic?: string; source_seq?: number },
) {
return imPost<GroupFile>(`/groups/${encodeURIComponent(topic)}/files`, file);
}
export function deleteGroupFile(topic: string, groupFileId: string) {
return imDelete<void>(`/groups/${encodeURIComponent(topic)}/files/${encodeURIComponent(groupFileId)}`);
}
+8 -1
View File
@@ -1,6 +1,8 @@
// Message-level REST endpoints that sit alongside the native WS protocol. Recall goes through REST
// (not a Tinode WS operation) -- ported from the old app's context-menu.jsx recallOwnMessage, which
// calls this then hard-deletes the message from the local topic cache itself.
import type { DraftyContent } from 'tinode-sdk';
import { imGet, imPost } from './client';
export interface RecallResult {
@@ -57,7 +59,12 @@ export interface SearchHit {
matches?: Array<{ start: number; end: number }>;
snippet?: string;
snapshot?: {
content?: { txt?: string; fmt?: unknown[]; ent?: DraftyEntity[] };
// A plain, unformatted text message's `content` is confirmed LIVE to arrive as a bare string
// (not the `{txt,fmt,ent}` object shape this field was originally typed as) -- the same
// string-vs-object duality `renderDrafty` (lib/messages/drafty.tsx) already handles for every
// other Drafty consumer in this app, discovered here because a real search hit for a plain-text
// message rendered as blank space until this was fixed (see HistorySearchModal's SnapshotContent).
content?: DraftyContent | string;
};
}
+53
View File
@@ -0,0 +1,53 @@
// System notifications REST endpoints (docs/IM-前后端对接文档.md:890-912). Confirmed live field
// facts (via a research pass over docs/IM-后端问题清单*.md, 问题30/新增32/33) that diverge from
// this doc's own worked example -- see each item's comment below.
import { imGet, imPost } from './client';
export interface SystemNotification {
id: string;
/** Real values seen include the `friend.*`/`group.*`/`message.*`/`meeting.*`/`security.risk.*`
* family from the WS business-event table (doc lines 408-420) -- route on this, not `action.type`
* (see below). */
event_type: string;
title: string;
/** Confirmed live (问题30): this is sometimes a raw machine code (`"accepted"`,
* `"security.risk.cancel"`) instead of human text, and can be empty. Never render blindly --
* always run through NOTIFICATION_TEXT first. */
summary: string;
/**
* Confirmed live (问题30) the shape varies by source and does NOT reliably match this doc's own
* worked example (`{"type":"friend_request","request_id":"frq_1"}`) -- a real friend-request
* notification's `action` was just `{"user_id":"usr..."}`, no `type` key at all. Group events:
* `{topic}`. Meeting events: inconsistently either `{module,resource_id}` or `{meeting_id}`.
* New-device-login's documented `{device_name,platform,ip_masked,login_at,security_action}`
* shape (doc line 891) is confirmed NOT IMPLEMENTED server-side at all (新增12, still open) --
* never assume these fields are populated.
*/
action?: {
type?: string;
request_id?: string;
topic?: string;
user_id?: string;
device?: string;
ip?: string;
[key: string]: unknown;
};
read_at: string | null;
created_at: string;
}
export function getSystemNotifications(cursor = '', limit = 20) {
const qs = new URLSearchParams({ limit: String(limit) });
if (cursor) qs.set('cursor', cursor);
return imGet<{ items: SystemNotification[]; next_cursor?: string }>(`/system-notifications?${qs.toString()}`);
}
/**
* Confirmed live (新增32, still open): marking an ALREADY-read notification echoes back whatever
* `read_at` you send as if it succeeded, but the stored value stays frozen at the first real write
* -- the response lies about updating it. Never treat this response's `read_at` as authoritative;
* only use it to flip local `read_at` truthy/falsy, matching the old app's own workaround.
*/
export function markSystemNotificationRead(id: string, readAt = new Date().toISOString()) {
return imPost<{ status: string; id: string; read_at: string }>(`/system-notifications/${encodeURIComponent(id)}/read`, { read_at: readAt });
}
+80
View File
@@ -0,0 +1,80 @@
// Own-profile/privacy REST endpoints (docs/IM-前后端对接文档.md ch.10, lines 2281-2397). Confirmed
// against the doc's own worked examples (table lines 2490-2494) and, where the doc has no example
// at all, against the rewritten old app's real working code (im_webapp/src/views/personal-settings-
// view.jsx) -- flagged per-field below.
import { imGet, imPatch } from './client';
/** `GET /v1/im/me` -- thin summary for a home/header card. Distinct from `getMyProfile` below (the
* full profile-edit source) -- confirmed live these are two separate endpoints with different
* field sets, not one response read two ways. */
export interface MeSummary {
user_id: string;
profile: { public_account: string; nickname: string; avatar_file_id?: string; signature?: string; version: number };
profile_version: number;
}
export function getMe() {
return imGet<MeSummary>('/me');
}
export interface MyProfile {
avatar_file_id?: string;
nickname: string;
birth_date?: string;
signature?: string;
gender?: 'male' | 'female' | 'other' | string;
region?: string;
email?: string;
public_account: string;
version: number;
field_rules?: { nickname?: { min_length: number; max_length: number }; signature?: { max_length: number } };
}
export function getMyProfile() {
return imGet<MyProfile>('/me/profile');
}
/** doc.md:2492 only shows nickname/signature in its worked example; gender/region have no doc
* example at all but the old app's real, working code PATCHes them the identical way (`gender`
* enum confirmed `male|female|other`, personal-settings-view.jsx:214). */
export function updateMyProfile(patch: Partial<Pick<MyProfile, 'nickname' | 'birth_date' | 'signature' | 'gender' | 'region' | 'avatar_file_id' | 'email'>> & { version: number }) {
return imPatch<MyProfile>('/me/profile', patch);
}
export interface AddMeMethods {
account: boolean;
phone: boolean;
qr: boolean;
group: boolean;
}
/**
* `profile_visibility`/`last_seen_visibility` enum confirmed as `everyone|friends|nobody` only via
* the old app's own code comment (the doc's worked examples only ever show `"friends"`) -- the
* server's validator 400s `contacts`/`hidden` outright, don't offer those as options.
*
* There is NO "加我为好友时需要验证" field anywhere in this response -- confirmed absent both in
* the doc and in the old app's own `UserPrivacySettings` research (personal-settings-view.jsx:534-
* 538: the design reference shows a control for it, but neither the response shape nor the group-
* invite backend logic has anything to gate). Don't build that toggle.
*/
export interface PrivacySettings {
add_me_methods: AddMeMethods;
profile_visibility: 'everyone' | 'friends' | 'nobody' | string;
last_seen_visibility: 'everyone' | 'friends' | 'nobody' | string;
read_receipts: boolean;
version: number;
editable?: Partial<Record<keyof PrivacySettings, boolean>>;
}
export function getMyPrivacy() {
return imGet<PrivacySettings>('/me/privacy');
}
export function updateMyPrivacy(
patch: Partial<{ add_me_methods: Partial<AddMeMethods>; profile_visibility: string; last_seen_visibility: string; read_receipts: boolean }> & {
version: number;
},
) {
return imPatch<PrivacySettings>('/me/privacy', patch);
}
+94
View File
@@ -0,0 +1,94 @@
// QR code REST endpoints (docs/IM-前后端对接文档.md §5.15.1). Confirmed LIVE against the real
// deployment (2026-09-08, two fresh test accounts) -- `resolveQRCode`'s real response shape nests
// `relationship`/`can_add`/`action`/`public_account` all under `user`, unlike the doc's own worked
// example which shows them as siblings of `user` at the top level. Also confirmed live: resolving a
// code against the SAME account that minted it is rejected (`400 MALFORMED_REQUEST`, `field:"token"`,
// message "malformed request" -- distinct from a genuinely garbage token, which instead gets message
// "qr code invalid" with no `details.field` at all) -- cross-account resolution works correctly.
//
// The security rationale for why this exists at all instead of just encoding a raw user id (doc line
// 1299): "二维码只含不透明、签名、带租户和用途约束的短期 Token,不直接放手机号、用户 ID 或登录
// Token" -- the QR image payload is `qr_payload` (an `im://qr?token=...&purpose=...` URI), not
// `qr_token` alone, matching the old app's own `im://qr` scheme (im_webapp/src/views/tinode-web.jsx
// confirmed this exact prefix check before ever sending a scanned string to `resolve`).
import { imPost } from './client';
export interface QRCodeDisplayProfile {
user_id: string;
display_name: string;
public_account?: string;
}
export interface MyQRCode {
qr_token: string;
/** What actually gets encoded into the QR image -- `im://qr?token=...&purpose=...`. */
qr_payload: string;
expires_at: string;
display_profile: QRCodeDisplayProfile;
}
export function createMyQRCode(expiresInSeconds = 86400) {
return imPost<MyQRCode>('/me/qrcodes', { purpose: 'add_friend', expires_in_seconds: expiresInSeconds });
}
/** `identifier`: user_id/username/public_account -- viewing a specific friend's own QR card. */
export function createUserQRCode(identifier: string, expiresInSeconds = 86400) {
return imPost<MyQRCode>(`/users/${encodeURIComponent(identifier)}/qrcodes`, { purpose: 'add_friend', expires_in_seconds: expiresInSeconds });
}
export interface QRResolveUser {
user_id: string;
display_name: string;
public_account?: string;
// docs/IM-前后端对接文档.md:1289 -- same real direction-specific enum as friends.ts's
// UserSearchResult/UserProfile, never a bare "pending".
relationship: 'none' | 'friend' | 'pending_inbound' | 'pending_outbound' | string;
can_add: boolean;
action: string;
}
/** Confirmed LIVE (2026-09-08, same account that minted the code -- a real cross-account "scan a
* stranger's group code" resolve hasn't been exercised, this session only had one account free):
* a `purpose:"join_group"` resolve nests the group under `group` (NOT `group_snapshot`, despite
* the mint response using that key -- resolve and mint use different field names for the same
* shape), and carries a `status` this specific case's doc example (line 1737, mint-only) never
* showed: resolving your own group's code (i.e. already a member) comes back `status:"joined"`.
* What a genuinely-not-yet-a-member resolve returns for `status` is unconfirmed -- treated as
* "anything other than 'joined'" in QRScannerModal/AddFriendModal, i.e. the general case that still
* needs a real join-request submission. */
export interface QRResolveGroup {
topic: string;
name: string;
member_count: number;
}
export interface QRResolveResult {
purpose: 'add_friend' | 'join_group' | string;
source: string;
source_topic: string;
expires_at: string;
user?: QRResolveUser;
group?: QRResolveGroup;
group_snapshot?: QRResolveGroup;
/** join_group only -- `"joined"` means the scanning account is already a member (confirmed live);
* any other value (including absent) is treated as "not a member yet, submit a join request". */
status?: string;
}
export function resolveQRCode(token: string, purpose: 'add_friend' | 'join_group' | string = 'add_friend') {
return imPost<QRResolveResult>('/qrcodes/resolve', { token, purpose });
}
/** docs/IM-前后端对接文档.md:1590 -- 群管理员/群主展示的入群二维码 (mirrors createMyQRCode/
* createUserQRCode's shape, just scoped to a group and carrying `group_snapshot` instead of a
* personal `display_profile`). */
export interface GroupQRCode {
qr_token: string;
qr_payload: string;
expires_at: string;
group_snapshot: QRResolveGroup;
}
export function createGroupQRCode(topic: string, expiresInSeconds = 86400) {
return imPost<GroupQRCode>(`/groups/${encodeURIComponent(topic)}/qrcodes`, { expires_in_seconds: expiresInSeconds });
}
+8 -3
View File
@@ -12,11 +12,16 @@ import { API_KEY } from '@/lib/config';
import { authToken, fileBase } from './client';
import { getTinode } from '../tinode/connection';
/** Confirmed live: the real `ctrl.params` shape is `{file_id, url, expires}` -- no `name`/`size`/
* `mime` at all. Every existing caller (avatar upload, chat attachments) only ever reads `.url`, so
* this went unnoticed until a caller needing the other three (GroupFilesModal's addGroupFile
* registration) actually tried to read them and got `undefined` silently sent as `file_name`. Callers
* that need name/size/mime must read them off the original `File` object they already have, not
* expect the upload response to echo them back. */
export interface UploadedFile {
url: string;
name: string;
size: number;
mime: string;
file_id: string;
expires: string;
}
/**
+137
View File
@@ -0,0 +1,137 @@
import type { IAgoraRTCClient, ICameraVideoTrack, IMicrophoneAudioTrack, IRemoteVideoTrack } from 'agora-rtc-sdk-ng';
import type { RtcJoinResult } from '@/lib/api/calls';
/**
* Thin wrapper around the Agora Web SDK -- this app's actual media transport (see lib/api/calls.ts's
* header comment for why: confirmed live that `POST /v1/im/calls/rtc-join` is the real, working
* path, not raw WebRTC/SDP-over-Tinode-`{note}`). Kept as module-level state, not Zustand -- an
* `IAgoraRTCClient`/media-track instance isn't meaningful React state (nothing should re-render
* because a client object's internal fields changed), and playing a video track is an imperative
* `track.play(domElement)` call, not a declarative prop.
*
* The SDK itself (~1.6MB) is dynamically imported inside `joinCall`, not at module top level --
* loading it eagerly would put a large media SDK in every page's initial bundle even though most
* sessions never place a call.
*/
let client: IAgoraRTCClient | null = null;
let localAudioTrack: IMicrophoneAudioTrack | null = null;
let localVideoTrack: ICameraVideoTrack | null = null;
let remoteVideoTrack: IRemoteVideoTrack | null = null;
// `createCameraVideoTrack()` (see joinCall below) opens whatever camera the browser/OS defaults to
// without asking -- normally the front ("user") one, so this starts in sync with that assumption
// rather than guessing wrong on the very first flip.
let usingBackCamera = false;
export interface JoinedCall {
localVideoTrack: ICameraVideoTrack | null;
}
/** Joins the Agora channel named by the rtc-join response and publishes local mic (+ camera, for a
* video call) tracks. Requesting `getUserMedia`-equivalent access here, not earlier -- matches the
* old app's own real timing (captures media once the call is actually about to carry it, not on
* page load). */
export async function joinCall(join: RtcJoinResult, callType: 'audio' | 'video', onRemoteUser: (hasVideo: boolean) => void): Promise<JoinedCall> {
const { default: AgoraRTC } = await import('agora-rtc-sdk-ng');
client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });
client.on('user-published', async (user, mediaType) => {
await client!.subscribe(user, mediaType);
if (mediaType === 'video' && user.videoTrack) {
remoteVideoTrack = user.videoTrack;
onRemoteUser(true);
}
if (mediaType === 'audio' && user.audioTrack) {
user.audioTrack.play();
}
});
client.on('user-unpublished', (_user, mediaType) => {
if (mediaType === 'video') {
remoteVideoTrack = null;
onRemoteUser(false);
}
});
await client.join(join.app_id, join.channel_name, join.token, join.uid);
localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack();
const tracks: Array<IMicrophoneAudioTrack | ICameraVideoTrack> = [localAudioTrack];
if (callType === 'video') {
localVideoTrack = await AgoraRTC.createCameraVideoTrack();
tracks.push(localVideoTrack);
}
await client.publish(tracks);
return { localVideoTrack };
}
export function playRemoteVideo(el: HTMLElement) {
remoteVideoTrack?.play(el);
}
export function playLocalVideo(el: HTMLElement) {
localVideoTrack?.play(el);
}
// Was fire-and-forget (`.setEnabled()`'s own promise never returned) -- a rejection (track already
// closed, permission revoked mid-call) had nowhere to go but an unhandled rejection, and the caller
// (stores/calls.ts's toggleMute) had no way to know it failed and roll the button back. Made async +
// re-thrown so it can.
export async function setMuted(muted: boolean) {
if (!localAudioTrack) return;
await localAudioTrack.setEnabled(!muted);
}
export async function setCameraOff(off: boolean) {
if (!localVideoTrack) return;
await localVideoTrack.setEnabled(!off);
}
/** Front/back camera switch (06-calls.dc.html #1f's `flip_camera_ios` mobile header icon --
* previously a `notImplemented()` placeholder, see Part G's requirements-diff findings). Uses
* `setDevice({facingMode})` rather than enumerating `AgoraRTC.getCameras()` and guessing which
* device is "the other one" by its (unstandardized, often-empty-until-permission-granted) label --
* `facingMode` is the browser's own signal for which physical camera to use, no guessing needed.
* NOT verified against a real device: this sandboxed environment has no camera, the same
* documented limitation that's kept full 2-way video verification out of reach for this whole
* feature (see the plan file's calls section) -- correct per the SDK's own typed API, unverified
* live. */
export async function switchCamera(): Promise<void> {
if (!localVideoTrack) return;
const next = !usingBackCamera;
usingBackCamera = next;
try {
await localVideoTrack.setDevice({ facingMode: next ? 'environment' : 'user' });
} catch (err) {
// Roll the optimistic flip back and rethrow -- matches setMuted/setCameraOff's own contract
// (this file stays a thin, rethrowing wrapper; stores/calls.ts's own toggleMute/toggleCamera
// are what catch and surface `mediaError`, and switchCamera's caller needs the same treatment,
// not a second, inconsistent error-handling convention just for this one control).
usingBackCamera = !next;
throw err;
}
}
export function hasRemoteVideo(): boolean {
return !!remoteVideoTrack;
}
/** Tears down everything -- call on hang-up from either side, or on error. Safe to call more than
* once (every step is guarded by the ref still being set). */
export async function leaveCall() {
localAudioTrack?.close();
localAudioTrack = null;
localVideoTrack?.close();
localVideoTrack = null;
remoteVideoTrack = null;
usingBackCamera = false;
// Capture and null out the module-level ref BEFORE awaiting -- a rapid hang-up-then-redial can let
// a new joinCall() reassign `client` to a fresh instance while this leave() is still in flight;
// without capturing it first, the two lines after the await would operate on the NEW call's
// client instead of the one actually being left.
const c = client;
client = null;
if (c) {
await c.leave().catch(() => {});
c.removeAllListeners();
}
}
+4 -3
View File
@@ -2,7 +2,7 @@
// must match the backend (server/*.go) -- they are not stylistic choices, so the numbers/strings
// themselves carry over unchanged even though this is a from-scratch rewrite.
/** Version-independent product name (Firebase app name, youth-mode copy, etc.). */
/** Version-independent product name (Firebase app name, in-app copy, etc.). */
export const BASE_APP_NAME = 'MeetingIM';
/** Name used in the Tinode client's User-Agent string. */
@@ -62,7 +62,8 @@ export const VIDEO_THUMBNAIL_WIDTH = 48;
export const MAX_ONLINE_IN_TOPIC = 4;
export const MAX_TITLE_LENGTH = 60;
export const MAX_TOPIC_DESCRIPTION_LENGTH = 360;
/** docs/IM-前后端对接文档.md:1445 -- "description 可省略或传空字符串,最长 200 字符". */
export const MAX_TOPIC_DESCRIPTION_LENGTH = 200;
export const MAX_PEER_TITLE_LENGTH = 24;
export const MESSAGE_PREVIEW_LENGTH = 80;
@@ -82,4 +83,4 @@ export const QRCODE_SIZE = 128;
export const WAKE_UP_TIMEOUT = 80_000;
export const WAKE_UP_TICK = 1_000;
export const TOAST_DURATION = 3_000;
export const TOAST_DURATION = 2_500;
+60 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { fileAuthHeaders, fileDownloadUrl } from '@/lib/api/upload';
@@ -65,14 +65,67 @@ export function useMediaQuery(query: string): boolean {
return matches;
}
interface EscapeStackEntry {
cb: () => void;
}
const escapeStack: EscapeStackEntry[] = [];
let escapeListenerAttached = false;
function handleGlobalEscape(e: KeyboardEvent) {
if (e.key !== 'Escape') return;
// Only the TOPMOST (most-recently-mounted) overlay closes -- without this, opening a QR scanner
// modal from inside AddFriendModal meant BOTH modals' independent useEscapeKey listeners fired on
// the same keydown, so pressing Escape to back out of just the scanner closed the whole add-friend
// flow too (the backdrop-click equivalent of this was already fixed via Fragment-sibling nesting,
// but that DOM-structural fix does nothing for a global keydown listener with no concept of nesting).
escapeStack[escapeStack.length - 1]?.cb();
}
/** Closes an overlay (modal/menu/popover) on Escape -- every one of this app's own overlays already
* closes on backdrop click, but none had a keyboard equivalent. */
* closes on backdrop click, but none had a keyboard equivalent. Stack-aware: when overlays are
* nested (a modal opened from inside another modal), Escape closes only the innermost one. */
export function useEscapeKey(onEscape: () => void): void {
const entryRef = useRef<EscapeStackEntry>({ cb: onEscape });
// Keeps the stack entry's callback fresh without re-running the mount/unmount effect below on
// every render -- an effect (not a render-time ref write) so it runs after commit, not during render.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') onEscape();
entryRef.current.cb = onEscape;
});
useEffect(() => {
const entry = entryRef.current;
escapeStack.push(entry);
if (!escapeListenerAttached) {
window.addEventListener('keydown', handleGlobalEscape);
escapeListenerAttached = true;
}
return () => {
const idx = escapeStack.indexOf(entry);
if (idx !== -1) escapeStack.splice(idx, 1);
if (escapeStack.length === 0) {
window.removeEventListener('keydown', handleGlobalEscape);
escapeListenerAttached = false;
}
};
}, []);
}
/**
* Closes a toggle-button-triggered popover (emoji panel, @mention picker, ...) on any click outside
* `ref`'s subtree -- `ref` must wrap BOTH the toggle button and the popover itself, so a click on the
* toggle button counts as "inside" and is left to the button's own onClick (which already flips the
* open state); only listens while `active` so re-clicking the toggle to open doesn't immediately
* attach a listener that fires for that same click's bubble. Only active-state gating (not `onClose`)
* is in the deps list, matching MessageContextMenu.tsx's own click-outside pattern -- an inline
* `onClose` arrow function is expected to be a new reference every render.
*/
export function useClickOutside(ref: React.RefObject<HTMLElement | null>, onClose: () => void, active: boolean): void {
useEffect(() => {
if (!active) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) onClose();
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [onEscape]);
window.addEventListener('click', handler);
return () => window.removeEventListener('click', handler);
}, [active]);
}
+70
View File
@@ -1,5 +1,7 @@
import type { StoredMessage } from 'tinode-sdk';
import { callContentOf, callDurationMsOf, callStateOf } from '@/lib/tinode/calls';
/**
* Two different "message is gone" protocol paths render differently (confirmed against the old
* app's chat-message.jsx, a real, previously-hit gap): `msg.hi` is the server's own soft-delete
@@ -35,3 +37,71 @@ export function systemNoticeText(msg: StoredMessage): string | null {
if (headType !== 'system_notice' && headType !== 'system_action') return null;
return isSystemNoticeContent(msg.content) ? msg.content.text : null;
}
export interface ContactCardContent {
user_id: string;
display_name?: string;
avatar_file_id?: string;
public_account?: string;
}
function isContactCardContent(content: unknown): content is ContactCardContent {
return !!content && typeof content === 'object' && typeof (content as ContactCardContent).user_id === 'string';
}
/**
* README §3.3's 名片 composer button has no send path anywhere in this app (no client here ever
* constructs one -- see AddFriendModal/ConversationView's own `notImplemented()` for that button),
* but a contact-card message CAN still arrive from another client. Confirmed real shape via
* docs/IM-前后端对接文档.md:311's worked example -- a backend-custom `head['x-im-type']:
* "contact_card"` with a FLAT `{user_id,display_name,avatar_file_id,public_account}` content, NOT a
* Drafty entity. This app's own `drafty.tsx` used to assume (per a stale header comment inherited
* from the SDK's generic ambient type list) that contact cards would show up as a Drafty `TC`-tagged
* entity the same way images/audio do -- they don't, so `renderDrafty` was silently fed a
* non-Drafty-shaped object for this message type and rendered nothing. This is dispatched the same
* way `systemNoticeText`/`callResultDisplay` are: by `head['x-im-type']`, checked BEFORE ever calling
* `renderDrafty` on `msg.content`.
*/
export function contactCardContentOf(msg: StoredMessage): ContactCardContent | null {
if (msg.head?.['x-im-type'] !== 'contact_card') return null;
return isContactCardContent(msg.content) ? msg.content : null;
}
export function formatCallDuration(ms: number): string {
const totalSeconds = Math.round(ms / 1000);
return `${String(Math.floor(totalSeconds / 60)).padStart(2, '0')}:${String(totalSeconds % 60).padStart(2, '0')}`;
}
/**
* README §7's "通话结果消息" -- icon + text mapped from `head.webrtc`, confirmed live (doc line
* 1953/1954): `missed` alone covers both "未接听" and "已取消" (a callee who let it ring out and a
* caller who cancelled before pickup both replace to the SAME wire value) -- direction picks the
* text, not the wire value, using `mine` (was the ORIGINAL invite sent by me, i.e. was I the
* caller) exactly the way this app already threads that prop through MessageRow. No `busy` value
* exists in the confirmed real enum (only accepted/finished/disconnected/missed/declined) despite
* the old app's own UI vocabulary having one -- not rendered here since nothing legitimate can
* produce it yet.
*/
export function callResultDisplay(msg: StoredMessage, mine: boolean): { icon: string; text: string; danger: boolean } | null {
const content = callContentOf(msg);
const state = callStateOf(msg);
if (!content || !state) return null;
const icon = content.callType === 'video' ? 'videocam' : 'call';
switch (state) {
case 'finished': {
const duration = callDurationMsOf(msg);
return { icon, danger: false, text: duration > 0 ? `通话时长 ${formatCallDuration(duration)}` : '已结束' };
}
case 'missed':
return { icon: 'phone_missed', danger: true, text: mine ? '已取消' : '未接听' };
case 'declined':
return { icon: 'phone_missed', danger: true, text: '已拒绝' };
case 'disconnected':
return { icon: 'phone_missed', danger: true, text: '异常结束' };
case 'accepted':
case 'started':
return { icon, danger: false, text: '通话中' };
default:
return null;
}
}
+111 -63
View File
@@ -2,7 +2,8 @@ import { Fragment, type ReactNode } from 'react';
import { Drafty, type DraftyContent } from 'tinode-sdk';
import { AudioAttachment, FileAttachment, ImageAttachment } from '@/features/messages/AttachmentPreview';
import { AudioAttachment, FileAttachment, ImageAttachment, VideoAttachment } from '@/features/messages/AttachmentPreview';
import { SafeLink } from '@/features/messages/SafeLink';
/**
* Bubble content renderer for the Drafty style tags this app currently sends/receives. Grown
@@ -15,70 +16,117 @@ import { AudioAttachment, FileAttachment, ImageAttachment } from '@/features/mes
* not an oversight (confirmed against the old app's formatters.js:122-128, which does hash-color
* mentions the same way avatars are colored).
*/
function formatNode(
style: string | undefined,
data: Record<string, unknown> | undefined,
values: ReactNode[] | null,
key: number | string,
): ReactNode {
const children = values ?? undefined;
switch (style) {
case 'ST':
return <b key={key}>{children}</b>;
case 'EM':
return <i key={key}>{children}</i>;
case 'DL':
return <s key={key}>{children}</s>;
case 'CO':
return (
<code key={key} className="rounded bg-surface-input px-1 py-0.5 font-mono text-[0.9em]">
{children}
</code>
);
case 'BR':
return <br key={key} />;
case 'MN':
return (
<span key={key} className="text-accent">
{children}
</span>
);
case 'HL':
return (
<mark key={key} className="bg-warn-bg">
{children}
</mark>
);
case 'LN': {
const url = typeof data?.url === 'string' ? data.url : undefined;
return (
<a key={key} href={url} target="_blank" rel="noreferrer" className="text-accent underline">
{children}
</a>
);
}
case 'IM':
return <ImageAttachment key={key} data={data || {}} />;
case 'EX':
return <FileAttachment key={key} data={data || {}} />;
case 'AU':
return <AudioAttachment key={key} data={data || {}} />;
case 'QQ':
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain style
// span wrapping mention(header,uid) + linebreak + body, all already rendered into `children`.
return (
<div key={key} className="mb-1.5 rounded border-l-2 border-accent bg-white/50 px-2 py-0.5 text-xs text-[#4a4f57]">
{children}
</div>
);
default:
return <Fragment key={key}>{children}</Fragment>;
/** README §6.3 (mock `05/1e`/`1f`): search-result keyword matches render as `<mark style="background:
* transparent; color:#1677ff; font-weight:500">`, not a yellow highlight -- a plain-text style, not
* the SDK's own unrelated `HL` Drafty tag (that one is `bg-warn-bg`, a different, currently-unused
* feature for user-authored highlight formatting, not search-match highlighting). Global,
* case-insensitive, one `<mark>` per match. */
function highlightMatches(text: string, query: string, keyPrefix: string | number): ReactNode[] {
const needle = query.trim().toLowerCase();
if (!needle) return [text];
const haystack = text.toLowerCase();
const parts: ReactNode[] = [];
let cursor = 0;
let idx = haystack.indexOf(needle, cursor);
if (idx === -1) return [text];
let n = 0;
while (idx !== -1) {
if (idx > cursor) parts.push(text.slice(cursor, idx));
parts.push(
<mark key={`${keyPrefix}-hl-${n++}`} className="bg-transparent font-medium text-accent">
{text.slice(idx, idx + query.trim().length)}
</mark>,
);
cursor = idx + query.trim().length;
idx = haystack.indexOf(needle, cursor);
}
if (cursor < text.length) parts.push(text.slice(cursor));
return parts;
}
export function renderDrafty(content: DraftyContent | string | null | undefined): ReactNode {
/** Curried on `highlightQuery` rather than taking it as a plain 5th parameter -- `Drafty.format`'s
* own callback type has a REAL 5th parameter (`stack: unknown[]`, see treeBottomUp's actual call),
* so a `formatNode` typed to accept `highlightQuery` in that same slot would stop structurally
* matching the formatter type `Drafty.format` expects wherever it's passed directly by reference. */
function makeFormatNode(highlightQuery: string | undefined) {
return function formatNode(
style: string | undefined,
data: Record<string, unknown> | undefined,
values: ReactNode[] | null,
key: number | string,
): ReactNode {
const children = values ?? undefined;
switch (style) {
case 'ST':
return <b key={key}>{children}</b>;
case 'EM':
return <i key={key}>{children}</i>;
case 'DL':
return <s key={key}>{children}</s>;
case 'CO':
return (
<code key={key} className="rounded bg-surface-input px-1 py-0.5 font-mono text-[0.9em]">
{children}
</code>
);
case 'BR':
return <br key={key} />;
case 'MN':
return (
<span key={key} className="text-accent">
{children}
</span>
);
case 'HL':
return (
<mark key={key} className="bg-warn-bg">
{children}
</mark>
);
case 'LN': {
const url = typeof data?.url === 'string' ? data.url : undefined;
return (
<SafeLink key={key} href={url}>
{children}
</SafeLink>
);
}
case 'IM':
return <ImageAttachment key={key} data={data || {}} />;
case 'EX':
return <FileAttachment key={key} data={data || {}} />;
case 'AU':
return <AudioAttachment key={key} data={data || {}} />;
case 'VD':
return <VideoAttachment key={key} data={data || {}} />;
case 'QQ':
// Drafty.quote() has no `key`/`data` of its own (tinode.dev.js:2160-2168) -- it's a plain
// style span wrapping mention(header,uid) + linebreak + body, already in `children`.
return (
<div key={key} className="mb-1.5 rounded border-l-2 border-accent bg-white/50 px-2 py-0.5 text-xs text-[#4a4f57]">
{children}
</div>
);
default:
// A genuine plain-text leaf (per treeBottomUp: no children, so `values` collapses to the
// node's own raw `[text]`) is the only shape safe to slice up for highlighting -- anything
// else (an already-formatted child array from a styled ancestor) is left exactly as before.
if (highlightQuery && values?.length === 1 && typeof values[0] === 'string') {
return <Fragment key={key}>{highlightMatches(values[0], highlightQuery, key)}</Fragment>;
}
return <Fragment key={key}>{children}</Fragment>;
}
};
}
/** `highlightQuery` is opt-in and additive -- every existing call site that omits it renders exactly
* as before; only HistorySearchModal's search results pass one. */
export function renderDrafty(content: DraftyContent | string | null | undefined, highlightQuery?: string): ReactNode {
if (content == null) return null;
if (typeof content === 'string') return content;
if (Drafty.isPlainText(content)) return Drafty.toPlainText(content);
return Drafty.format(content, formatNode);
if (typeof content === 'string') return highlightQuery ? <>{highlightMatches(content, highlightQuery, 'plain')}</> : content;
if (Drafty.isPlainText(content)) {
const text = Drafty.toPlainText(content);
return highlightQuery ? <>{highlightMatches(text, highlightQuery, 'plain')}</> : text;
}
return Drafty.format(content, makeFormatNode(highlightQuery));
}
+94
View File
@@ -0,0 +1,94 @@
import { Drafty, type DraftyContent } from 'tinode-sdk';
export interface PendingMention {
userId: string;
/** Display text WITHOUT the leading '@' -- what actually appears in the composed plain text
* (e.g. "张三", or "所有人" for the mention-all virtual entry). */
label: string;
}
/**
* `Drafty.mention(name, uid)` (tinode.dev.js:2169) never validates `uid` -- it's just stored as
* the MN entity's `data.val`, opaque to the SDK and to this app's own renderer (drafty.tsx's `MN`
* case renders `children` regardless of who it is). There's no real per-tenant user id for
* "everyone", so this is this client's own made-up placeholder, not a protocol value -- fine since
* nothing on the receive side needs to resolve it back to an actual user.
*/
export const MENTION_ALL_SENTINEL = 'all';
/**
* Composes the final Drafty content for a group message that may contain @ mentions. The composer
* is a plain `<textarea>` (not rich/contenteditable), so a picked mention is inserted as literal
* `@label ` text in the draft and tracked separately in `mentions` -- this walks the final text
* once at send time, finds each tracked mention's `@label` occurrence (left to right, never
* re-matching a range an earlier mention already claimed, so pinging the same person twice doesn't
* collapse onto one occurrence), and splices in real `Drafty.mention()` entities around plain-text
* runs via `Drafty.append` -- the same stitching pattern send.ts's `sendReply` already uses for
* quote blocks. Falls back to the plain `sendText` parse path when there's nothing to splice
* (no mentions, or none of them still appear in the edited text).
*/
export function buildMentionedContent(text: string, mentions: PendingMention[]): DraftyContent | string {
const toPlain = () => {
const parsed = Drafty.parse(text);
return Drafty.isPlainText(parsed) ? text : parsed;
};
if (mentions.length === 0) return toPlain();
const matches: Array<{ start: number; end: number; mention: PendingMention }> = [];
let searchFrom = 0;
for (const mention of mentions) {
const needle = `@${mention.label}`;
const idx = text.indexOf(needle, searchFrom);
if (idx === -1) continue;
matches.push({ start: idx, end: idx + needle.length, mention });
searchFrom = idx + needle.length;
}
if (matches.length === 0) return toPlain();
matches.sort((a, b) => a.start - b.start);
let content: DraftyContent | undefined;
const appendPlain = (slice: string) => {
if (!slice) return;
const plain = Drafty.parse(slice);
content = content ? Drafty.append(content, plain) : plain;
};
let cursor = 0;
for (const m of matches) {
appendPlain(text.slice(cursor, m.start));
// `Drafty.mention(name, uid)` (tinode.dev.js:2169-2176) stores `name` VERBATIM as the run's
// plain text with no automatic '@' -- passing the bare label rendered as "ixp355087" instead
// of "@ixp355087" (confirmed live: a sent mention's `MN` span text had no '@' at all), so the
// '@' has to be included here to end up inside the styled span, matching the design's own
// `<span style="color:#1677ff;">@我</span>`.
const mn = Drafty.mention(`@${m.mention.label}`, m.mention.userId);
content = content ? Drafty.append(content, mn) : mn;
cursor = m.end;
}
appendPlain(text.slice(cursor));
return content ?? toPlain();
}
export interface MentionTrigger {
/** Index of the triggering '@' itself within `text`. */
start: number;
/** Text typed so far between the '@' and the cursor, used to filter the picker's candidate list. */
query: string;
}
/**
* Detects an in-progress `@mention` at the cursor: the nearest unclosed '@' before `cursorPos`,
* closed by any whitespace typed between it and the cursor (same as most chat composers). Doesn't
* require the '@' itself to be preceded by whitespace -- CJK sentences routinely run straight into
* it ("大家注意@张三", no space before the '@', matching the design reference image) unlike English
* "foo@bar", so gating on that would silently break the exact reference case this is built for.
* A stray "name@example.com" typed in a group chat will float the picker uninvasively until the
* next space closes it -- an acceptable trade for not breaking the primary CJK use case.
*/
export function detectMentionTrigger(text: string, cursorPos: number): MentionTrigger | null {
const uptoCursor = text.slice(0, cursorPos);
const at = uptoCursor.lastIndexOf('@');
if (at === -1) return null;
const query = uptoCursor.slice(at + 1);
if (/\s/.test(query)) return null;
return { start: at, query };
}
+14 -8
View File
@@ -1,12 +1,10 @@
import { Drafty, type DraftyContent, type StoredMessage } from 'tinode-sdk';
import { isServerDeleted } from '@/lib/messages/dispatch';
import { callResultDisplay, contactCardContentOf, isServerDeleted, systemNoticeText } from '@/lib/messages/dispatch';
/**
* One-line chat-list preview text (README §2.1): plain text renders as-is, everything else falls
* back to a bracketed type label. Own messages get a "我:" prefix. Grown incrementally -- VC (call)
* duration/answered-state isn't modeled yet (Phase 6 territory), so call messages get a plain label
* for now rather than guessing at a content shape no research has confirmed.
* back to a bracketed type label. Own messages get a "我:" prefix.
*/
export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: string | null): string {
if (!msg) return '';
@@ -16,6 +14,18 @@ export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: s
// app's own equivalent renderer does the same, since a hi-flagged message can still carry
// non-null content that must NOT leak into this preview.
if (isServerDeleted(msg) || content == null) return `${prefix}[消息已删除]`;
// System notices/contact cards/call-result messages are dispatched by `head` (`x-im-type`/
// `webrtc`), not by Drafty content shape -- confirmed in dispatch.ts's own header comments, same
// order MessageBubble.tsx already checks these in. Their `content` is a flat, non-Drafty object
// (`{text}` / `{user_id,...}` / `{callId,...}`), so skipping this check falls through to the
// Drafty-entity branches below, which read a `.ent` array none of these objects have -- this is
// exactly how the chat list's "undefined" preview bug happened (`Drafty.preview()` fed an object
// it doesn't recognize, `Drafty.toPlainText()` on its result stringifying to "undefined").
const noticeText = systemNoticeText(msg);
if (noticeText != null) return noticeText;
if (contactCardContentOf(msg)) return `${prefix}[名片]`;
const callResult = callResultDisplay(msg, msg.from === myUserId);
if (callResult) return `${prefix}${callResult.text}`;
if (typeof content === 'string' || Drafty.isPlainText(content)) {
return prefix + Drafty.toPlainText(content);
}
@@ -32,10 +42,6 @@ export function formatMessagePreview(msg: StoredMessage | undefined, myUserId: s
}
case 'EX':
return `${prefix}[文件] ${typeof firstEnt.data?.name === 'string' ? firstEnt.data.name : ''}`;
case 'TC':
return `${prefix}[名片]`;
case 'VC':
return `${prefix}[语音/视频通话]`;
default:
return prefix + Drafty.toPlainText(Drafty.preview(content, 40));
}
+4 -3
View File
@@ -2,6 +2,7 @@ import { Drafty, type Topic } from 'tinode-sdk';
import { uploadFile } from '@/lib/api/upload';
import { MAX_INBAND_ATTACHMENT_SIZE } from '@/lib/config';
import { newClientMid } from '@/lib/tinode/send';
function fileToBase64(file: File): Promise<string> {
return new Promise((resolve, reject) => {
@@ -49,7 +50,7 @@ export async function sendImage(topic: Topic, file: File): Promise<unknown> {
size: file.size,
});
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image' });
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'image', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
}
@@ -59,7 +60,7 @@ export async function sendFile(topic: Topic, file: File): Promise<unknown> {
? Drafty.attachFile(undefined, { mime: file.type, data: await fileToBase64(file), filename: file.name, size: file.size })
: Drafty.attachFile(undefined, { mime: file.type, refurl: (await uploadFile(file, topic.name)).url, filename: file.name, size: file.size });
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file' });
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'file', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
}
@@ -84,6 +85,6 @@ export async function sendVoice(topic: Topic, blob: Blob, durationMs: number): P
filename: file.name,
});
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice' });
msg.head = Object.assign(msg.head || {}, { 'x-im-type': 'voice', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
}
+79
View File
@@ -0,0 +1,79 @@
import type { DraftyContent, StoredMessage, Topic } from 'tinode-sdk';
import { newClientMid } from '@/lib/tinode/send';
/**
* Call signaling send-side helpers. Confirmed shape (docs/IM-前后端对接文档.md §0.5.4.7/§8, lines
* 424-435/1945-2076): the invite is a plain-JSON `{pub}` (NOT Drafty -- `topic.createMessage` only
* auto-Drafty-wraps string/Drafty-shaped content, a plain `{callId,...}` object passes through as
* literal `content`), `head:{mime:"application/json", webrtc:"started", "x-im-type":"call"}`.
* Ringing/accept/hang-up then go over `topic.videoCall()` (thin wrapper around the SDK's native
* `{note what:"call"}`, see tinode-sdk.d.ts's own comment) -- NOT offer/answer/ice-candidate, which
* this app doesn't use (see calls.ts's own header comment in lib/api on the Agora/rtc-join media
* path this backend actually implements instead of raw SDP/ICE over the IM WebSocket).
*/
export interface CallContent {
callId: string;
callType: 'audio' | 'video';
audio: boolean;
video: boolean;
status?: string;
}
/** `head.webrtc` values a call message cycles through, replacing the SAME `seq` in place (doc line
* 1953) -- `started` is the live invite; the rest are terminal results the server writes over it. */
export type CallWebrtcState = 'started' | 'accepted' | 'finished' | 'disconnected' | 'missed' | 'declined';
export function callContentOf(msg: StoredMessage): CallContent | null {
const webrtc = msg.head?.webrtc;
if (typeof webrtc !== 'string' || !msg.content || typeof msg.content !== 'object') return null;
const content = msg.content as unknown as CallContent;
if (typeof content.callId !== 'string') return null;
return content;
}
export function callStateOf(msg: StoredMessage): CallWebrtcState | null {
const webrtc = msg.head?.webrtc;
return typeof webrtc === 'string' ? (webrtc as CallWebrtcState) : null;
}
export function callDurationMsOf(msg: StoredMessage): number {
const duration = msg.head?.['webrtc-duration'];
return typeof duration === 'number' ? duration : 0;
}
/**
* Sends the call invite. `noEcho: false` (matches send.ts's sendText) so the caller's own UI gets an
* immediate optimistic bubble via `publishDraft`, same as any other message.
*
* Confirmed live: an audio call's outbound packet never actually carries `video:false` on the wire
* -- the SDK's own `#send()` runs every outbound packet through a generic `simplify()` (tinode.dev.
* js:5881-5903) that recursively deletes any falsy field, with no way to mark one as intentional.
* This isn't fixable from the call site (it strips ANY false-valued content field this app might
* ever send this way, not just this one) and doesn't matter in practice here -- neither this file's
* own `callContentOf` nor CallRoot.tsx reads `content.audio`/`content.video`, only `content.
* callType` (always a non-empty string, never stripped), so a missing boolean never surfaces as a
* real bug -- but a future call site that DID need to trust an explicit `false` over "field absent"
* would silently lose that distinction.
*/
export function sendCallInvite(topic: Topic, callId: string, callType: 'audio' | 'video'): Promise<{ code: number; params?: { seq: number } }> {
const content: CallContent = { callId, callType, audio: true, video: callType === 'video' };
// `createMessage`'s own type only admits Drafty content, but its real implementation (tinode.dev.
// js:7170-7182) passes any non-string content through untouched -- verified reading that source,
// not guessed. This cast is deliberately lying to the type system for that one confirmed reason.
const msg = topic.createMessage(content as unknown as DraftyContent, false);
msg.head = Object.assign(msg.head || {}, { mime: 'application/json', webrtc: 'started', 'x-im-type': 'call', 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve()) as Promise<{ code: number; params?: { seq: number } }>;
}
export function sendCallRinging(topic: Topic, seq: number, callId: string) {
return topic.videoCall('ringing', seq, { callId });
}
export function sendCallAccept(topic: Topic, seq: number, callId: string) {
return topic.videoCall('accept', seq, { callId });
}
export function sendCallHangup(topic: Topic, seq: number, callId: string, reason?: string) {
return topic.videoCall('hang-up', seq, reason ? { callId, reason } : { callId });
}
+94 -5
View File
@@ -10,6 +10,7 @@
import { Tinode, type LoginCtrl } from 'tinode-sdk';
import { API_KEY, APP_NAME, DEFAULT_HOST, LOGGING_ENABLED } from '@/lib/config';
import { isTenantMismatch } from '@/lib/tinode/errors';
import { useSessionStore } from '@/stores/session';
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'authenticated';
@@ -17,9 +18,30 @@ export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'au
let tinode: Tinode | null = null;
let status: ConnectionStatus = 'disconnected';
const listeners = new Set<() => void>();
// Whether this connection has EVER reached 'authenticated' (survives the 'disconnected' state a
// reconnect passes through, so onConnect below can tell "first connect, nothing to restore yet"
// apart from "was logged in, the socket just dropped and came back"). Reset in resetTinode() only.
let wasAuthenticated = false;
/**
* Fired once a post-reconnect restoreSession() succeeds (see onConnect below). A plain listener
* set rather than this module importing topics.ts directly -- confirmed by reading tinode.dev.js
* that Tinode's own #disconnected() handler resets EVERY cached topic's `_attached` flag to false
* on any drop (including the `me` topic and every contact's one-shot preview subscription, not just
* the one topic a user happens to have open), so a bare reconnect+relogin leaves the whole app's
* subscriptions dead even though the "已重连同步" banner says otherwise -- but deciding what
* "resume subscriptions" means is topics.ts's job, not this connection-layer module's.
*/
const reconnectAuthListeners = new Set<() => void>();
export function onReauthenticated(callback: () => void): () => void {
reconnectAuthListeners.add(callback);
return () => reconnectAuthListeners.delete(callback);
}
function setStatus(next: ConnectionStatus) {
if (status === next) return;
if (next === 'authenticated') wasAuthenticated = true;
status = next;
listeners.forEach((cb) => cb());
}
@@ -149,6 +171,30 @@ function createTinode(): Tinode {
const wasReconnecting = reconnectState.retryInSeconds > 0 || reconnectState.attemptInFlight;
setStatus('connected');
stopReconnectRetry();
// The SDK's own auto-backoff (and the manual "立即重试") only re-establish the WebSocket --
// confirmed by reading the SDK source that reconnecting never resends {login}. The server
// treats every fresh connection as unauthenticated until told otherwise, so without this, a
// reconnect after a transient drop (WiFi handoff, laptop sleep/wake, VPN blip -- routine on the
// kind of enterprise network this app runs on) would leave every subsequent request silently
// failing while the UI's "已重连同步" banner tells the user everything is fine.
if (wasAuthenticated) {
const token = sessionStorage.getItem('im-session-token');
if (token) {
restoreSession(token)
.then(() => reconnectAuthListeners.forEach((cb) => cb()))
.catch((err) => {
// Token no longer valid (revoked/expired server-side while we were disconnected) -- can't
// silently recover, so fall back to the same "force a real login" outcome a failed
// restore-on-page-load already gets via RequireAuth.
sessionStorage.removeItem('im-session-token');
useSessionStore.getState().setMyUserId(null);
// Same doc-prescribed recovery RequireAuth's own restore-failure path applies: a token
// that's valid for a DIFFERENT tenant than the one this connection is against must also
// drop the tenant, or the next /login mount reuses it and repeats the same mismatch.
if (isTenantMismatch(err)) useSessionStore.getState().clearTenant();
});
}
}
if (wasReconnecting) {
setReconnectState({ justReconnected: true });
if (justReconnectedTimer) clearTimeout(justReconnectedTimer);
@@ -203,6 +249,7 @@ export function resetTinode() {
tinode = null;
connectPromise = null;
restorePromise = null;
wasAuthenticated = false;
setStatus('disconnected');
}
@@ -251,6 +298,27 @@ export function restoreSession(token: string): Promise<string | null> {
return restorePromise;
}
/**
* Deduped the same way connect()/restoreSession() are above, for the identical reason: LoginPage's
* own `submitting` state guard only takes effect once React re-renders with the submit button
* disabled, which is a moment too late for a genuine fast double-click/double-Enter to be stopped by
* -- every OTHER non-idempotent SDK call in this module (connect/restoreSession/registerAccount) was
* already deduped against exactly this race, `loginBasic` was the one left calling straight through.
*/
let loginPromise: Promise<LoginCtrl> | null = null;
export function loginBasic(login: string, password: string): Promise<LoginCtrl> {
if (loginPromise) return loginPromise;
loginPromise = (async () => {
const t = getTinode();
if (!t.isConnected()) await connect();
return t.loginBasic(login, password);
})().finally(() => {
loginPromise = null;
});
return loginPromise;
}
export function getConnectionStatus(): ConnectionStatus {
return status;
}
@@ -274,9 +342,10 @@ export interface RegisterParams {
}
/**
* Unified username-or-phone registration (product Ch.1.4). Two DIFFERENT phone representations are
* required and mixing them up throws a 422 "policy violation" with no further detail (confirmed live
* against the real backend, then traced to server/user.go's resolveRegistrationAccount):
* Unified username-or-phone registration (product Ch.1.4). THREE DIFFERENT phone representations
* are involved in one `{acc}` packet and mixing them up throws a 422 "policy violation" with no
* further detail (confirmed live against the real backend, then traced to server/user.go's
* resolveRegistrationAccount):
* - `login_` (createAccountBasic's first arg, becomes the secret's account half) must equal what
* resolveRegistrationAccount computes as secretAccount -- for phone registration (and no
* username typed) that's the E.164-normalized phone, e.g. "+8613900005232"; if a username was
@@ -286,6 +355,12 @@ export interface RegisterParams {
* - the injected `acc.phone` extra field is checked *before* any E.164 normalization happens
* (`len(phone) != 11 || !asciiRegistrationDigits(phone)`) -- it must be the bare 11-digit number
* with no "+86" prefix.
* - `cred[].val` (the `tel` credential itself) is ALSO the bare 11-digit number, not E.164 -- every
* one of the doc's own worked examples (docs/IM-前后端对接文档.md, both the 手机号验证方式 and
* 仅手机号注册方式 blocks) shows `cred.val` identical to the bare `phone` field even in the exact
* same JSON object where `secret` DOES carry the `+86`-prefixed form -- the doc is deliberately
* inconsistent between the two, not sloppy. A missed 3rd case: an earlier version of this
* function got the first two representations right but still E.164-prefixed this one.
*
* On the username-only path (no phone at all) `cred` must be left `undefined`, never a literal
* `null` -- tinode-sdk's `Tinode.credential()` does `typeof meth == 'object'` to sniff its argument
@@ -293,7 +368,21 @@ export interface RegisterParams {
* "Cannot destructure property 'val' of 'meth' as it is null" instead of sending the {acc} message
* at all (confirmed by reading the old app's own history of this exact bug).
*/
export async function registerAccount({
let registerPromise: Promise<LoginCtrl> | null = null;
/** Deduped the same way connect()/restoreSession() are above, for the same class of reason:
* createAccountBasic() is non-idempotent (creates a new account per call), so two overlapping
* invocations -- e.g. a slow network letting a double-click on "注册" land before the submit
* button's own pending-state disables it -- would otherwise send two `{acc}` packets. */
export function registerAccount(params: RegisterParams): Promise<LoginCtrl> {
if (registerPromise) return registerPromise;
registerPromise = registerAccountImpl(params).finally(() => {
registerPromise = null;
});
return registerPromise;
}
async function registerAccountImpl({
username,
phone,
password,
@@ -311,7 +400,7 @@ export async function registerAccount({
const secretAccount = usingPhone ? username || `+86${phone}` : username;
const cred =
usingPhone && verificationToken && challengeId
? Tinode.credential({ meth: 'tel', val: `+86${phone}`, resp: verificationToken, params: { challenge_id: challengeId } })
? Tinode.credential({ meth: 'tel', val: phone, resp: verificationToken, params: { challenge_id: challengeId } })
: undefined;
const trimmedName = username.trim();
+19
View File
@@ -11,6 +11,15 @@ const CTRL_ERROR_TEXT_CN: Record<string, string> = {
'policy violation': '当前操作不符合安全策略要求',
'permission denied': '没有权限执行此操作,请重新登录',
'internal error': '服务器繁忙,请稍后重试',
// Confirmed live 2026-09-09: registering with a username or phone number that's already taken
// returns this exact raw SDK text ("duplicate credential (409)") -- without this entry,
// registerErrorMessage()'s fallback showed the raw English straight to a Chinese-language user.
'duplicate credential': '该用户名或手机号已被注册,请更换后重试',
// docs/IM-前后端对接文档.md's own worked example (`{"login":...,"secret":"其他租户 token"}` ->
// `403 tenant mismatch`) -- without this entry, loginErrorMessage's generic `code===403` fallback
// below shows "账号或密码错误" for what's actually a stale-token-from-a-different-tenant case, which
// is a materially different, more confusing message (the password wasn't wrong at all).
'tenant mismatch': '登录状态异常,请重新进入企业码',
};
function translateCtrlError(err: unknown): string | null {
@@ -20,6 +29,16 @@ function translateCtrlError(err: unknown): string | null {
return CTRL_ERROR_TEXT_CN[normalized] || null;
}
/** docs/IM-前后端对接文档.md: this specific reason means the client is holding a session token
* issued by a DIFFERENT tenant than the one it's currently connected to -- the doc's own prescribed
* recovery is "清 Token 并回企业码页" (clear the token and return to the tenant-code entry screen),
* not just show an error and let the user retry the same broken state. */
export function isTenantMismatch(err: unknown): boolean {
const raw = (err as Error)?.message;
if (!raw) return false;
return raw.replace(/\s*\(\d+\)\s*$/, '').trim().toLowerCase() === 'tenant mismatch';
}
export function loginErrorMessage(err: unknown): string {
const known = translateCtrlError(err);
if (known) return known;
+46 -4
View File
@@ -7,6 +7,27 @@ function toDraftyContent(content: DraftyContent | string | null | undefined): Dr
return typeof content === 'string' ? Drafty.init(content) : content;
}
/**
* docs/IM-前后端对接文档.md:486/952/1128 -- every `{pub}` must carry a client-generated
* `x-im-client-mid` (scoped to "tenant + sender"), and a disconnect/failure retry MUST resend the
* SAME value rather than mint a new one -- that's the field's entire purpose (line 315's own worked
* example: resending an identical client_mid is expected to return the FIRST send's seq, not create
* a second message). Every send helper below was missing this entirely, meaning a retry after an
* ambiguous failure (the `{pub}` reached the server and was applied, but the `{ctrl}` ack itself
* got lost on the way back) had no way to be deduplicated server-side and could double-post.
* `crypto.randomUUID()` (the same primitive client.ts's own requestId() already relies on for
* REST's Idempotency-Key) satisfies the doc's "ULID/UUID" requirement; kept local to this file
* rather than importing requestId() across the REST/WS module boundary for what's a WS-protocol
* concern here. Exported for attachments.ts/calls.ts's own `{pub}` sends (image/file/voice/call
* invite), which need the identical field for the identical reason -- one source of truth for how
* this app generates the id, not a copy in every file that happens to publish a message.
*/
export function newClientMid(): string {
return typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `mid_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`;
}
/**
* Plain-text send: createMessage + publishDraft (not the simpler `.publish()`), matching the old
* app's own choice -- publishDraft inserts the message into the local cache and fires onData
@@ -16,7 +37,20 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
const parsed = Drafty.parse(text);
const content: DraftyContent | string = Drafty.isPlainText(parsed) ? text : parsed;
const msg = topic.createMessage(content, false);
if (head) msg.head = Object.assign(msg.head || {}, head);
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
}
/**
* Same createMessage+publishDraft mechanics as sendText, but for content already assembled by the
* caller (mentions.ts's `buildMentionedContent`, which splices real `Drafty.mention()` entities
* into the plain-text draft) rather than a fresh `Drafty.parse` of raw text. Kept separate from
* sendText rather than adding an optional param there, so every other existing call site (plain
* P2P/group sends) is untouched.
*/
export function sendPrebuiltContent(topic: Topic, content: DraftyContent | string, head?: Record<string, unknown>): Promise<unknown> {
const msg = topic.createMessage(content, false);
msg.head = Object.assign(msg.head || {}, head, { 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
}
@@ -25,12 +59,20 @@ export function sendText(topic: Topic, text: string, head?: Record<string, unkno
* the same content/head -- ported from the old app's context-menu "重试" handler (context-menu.jsx),
* not a simple re-publish of the same object. Returns undefined if `seq` is no longer in the local
* cache (already flushed, or never existed).
*
* Reuses the ORIGINAL failed message's own `x-im-client-mid` rather than minting a fresh one -- see
* newClientMid()'s own comment above for why a retry specifically must not get a new id. Falls back
* to a fresh one only for a locally-cached failed message from before this field existed at all
* (there's no original id to recover in that one case).
*/
export function retryMessage(topic: Topic, seq: number): Promise<unknown> | undefined {
const failed = topic.flushMessage(seq);
if (!failed) return undefined;
const msg = topic.createMessage((failed.content as DraftyContent | string) ?? '', false);
if (failed.head) msg.head = Object.assign(msg.head || {}, failed.head);
const existingMid = failed.head?.['x-im-client-mid'];
msg.head = Object.assign(msg.head || {}, failed.head, {
'x-im-client-mid': typeof existingMid === 'string' ? existingMid : newClientMid(),
});
return topic.publishDraft(msg, Promise.resolve());
}
@@ -52,7 +94,7 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quo
const sanitized = Drafty.sanitizeEntities(JSON.parse(JSON.stringify(quoteBlock)) as DraftyContent);
const finalContent = Drafty.append(sanitized, Drafty.parse(text));
const msg = topic.createMessage(finalContent, false);
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq) });
msg.head = Object.assign(msg.head || {}, { reply: String(quoted.seq), 'x-im-client-mid': newClientMid() });
return topic.publishDraft(msg, Promise.resolve());
}
@@ -66,6 +108,6 @@ export function sendReply(topic: Topic, text: string, quoted: StoredMessage, quo
export function forwardMessage(sourceTopic: string, msg: StoredMessage, target: Topic): Promise<unknown> {
const content = Drafty.forwardedContent(toDraftyContent(msg.content));
const newMsg = target.createMessage(content, false);
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}` });
newMsg.head = Object.assign(newMsg.head || {}, { forwarded: `${sourceTopic}:${msg.seq}`, 'x-im-client-mid': newClientMid() });
return target.publishDraft(newMsg, Promise.resolve());
}
+36 -1
View File
@@ -73,6 +73,11 @@ declare module 'tinode-sdk' {
export interface TopicDescPrivate {
comment?: string;
pinned?: boolean;
/** Mirrored onto this same `private` object by the same `{set sub private:{...}}` write that
* sets `pinned` (docs/IM-前后端对接文档.md:366's own worked example sets both fields together
* in one packet) -- free to read off `me.contacts()` for the same reason `pinned` already is,
* no extra per-topic settings fetch needed. */
mute_notifications?: boolean;
[key: string]: unknown;
}
@@ -222,10 +227,24 @@ declare module 'tinode-sdk' {
delMessagesAll(hardDel?: boolean): Promise<unknown>;
delMessagesList(list: number[], hardDel?: boolean): Promise<unknown>;
/**
* Thin wrapper around `Tinode.videoCall` (tinode.dev.js:5074-5079, 7343-7350) -- builds and sends
* a `{note topic, seq, what:"call", event, payload}` packet. Guards `!this._attached` client-side
* EXCEPT for `ringing`/`hang-up`, which the SDK allows even before the topic is subscribed (a
* callee receiving a fresh invite hasn't necessarily subscribed to the caller's P2P topic yet).
* `event` is the doc's confirmed whitelist (§0.5.4.7): `ringing|accept|hang-up` for this app
* (offer/answer/ice-candidate are NOT used -- see calls.ts's own header comment on why this app
* uses the Agora/rtc-join media path instead of raw SDP/ICE over this channel). Always pass a
* real `{callId, ...}` payload -- confirmed live the doc's protocol requires it on every note,
* unlike the old app's real code (which the SDK lets send `undefined`, but the new backend
* doesn't accept that).
*/
videoCall(event: 'ringing' | 'accept' | 'hang-up', seq: number, payload: Record<string, unknown>): Promise<unknown> | undefined;
/** Single-slot callbacks the app wires fresh per "currently open" topic and tears down on leave. */
onData?: (data?: StoredMessage) => void;
onAllMessagesReceived?: (count: number) => void;
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string }) => void;
onInfo?: (info: { what: 'read' | 'recv' | 'kp' | 'call'; seq?: number; from?: string; event?: string; payload?: Record<string, unknown> }) => void;
onMetaDesc?: (desc: unknown) => void;
onSubsUpdated?: (subs: string[], count?: number) => void;
onPres?: (pres: unknown) => void;
@@ -263,6 +282,10 @@ declare module 'tinode-sdk' {
isConnected(): boolean;
isAuthenticated(): boolean;
getCurrentUserID(): string;
/** The login credential username (not the display nickname) -- needed as the unchanged
* passthrough value when calling `updateAccountBasic` to change ONLY the password (confirmed
* against the old app's real working code, tinode-web.jsx:1964). */
getCurrentLogin(): string;
login(scheme: string, secret: string, cred?: unknown): Promise<LoginCtrl>;
loginBasic(username: string, password: string, cred?: unknown): Promise<LoginCtrl>;
@@ -278,6 +301,18 @@ declare module 'tinode-sdk' {
password: string,
params: { public?: TopicDescPublic; tags?: string[]; cred?: CredentialParam[]; attachments?: string[] },
): Promise<LoginCtrl>;
/** Native `{acc}` account update -- there is no REST endpoint for changing password while
* logged in (confirmed: doc only has the logged-out SMS reset flow), so this is the only real
* path. Pass `uid: null` for "myself" and `username` unchanged (`getCurrentLogin()`) to change
* only the password -- confirmed against the old app's real working call site. Server does NOT
* verify any "current password" (confirmed: personal-settings-view.jsx's own research comment;
* a client-side "原密码" field is a fat-finger speed bump only, not a real credential check). */
updateAccountBasic(uid: string | null, username: string | null, password: string, params?: unknown): Promise<unknown>;
/** Native `{del what:"user" hard:true}` -- there is no REST endpoint for account deletion
* (confirmed absent from both doc copies and the backend problem list). No cooldown/soft-
* delete and no password re-check server-side (confirmed against server/user.go per the old
* app's own research comment) -- the client-side confirm dialog is the only safety net. */
delCurrentUser(hard: boolean): Promise<void>;
/** Wraps a single {meth,val,resp,params} credential into the array shape {acc}/createAccountBasic expects. */
static credential(cred: CredentialInput): CredentialParam[] | null;
+55 -2
View File
@@ -14,7 +14,7 @@ import type { Contact, StoredMessage, Topic } from 'tinode-sdk';
import { MESSAGES_PAGE } from '@/lib/config';
import { getTinode } from './connection';
import { getTinode, onReauthenticated } from './connection';
const topicListeners = new Map<string, Set<() => void>>();
const anyTopicListeners = new Set<() => void>();
@@ -45,11 +45,33 @@ export function subscribeAnyTopicChange(callback: () => void): () => void {
return () => anyTopicListeners.delete(callback);
}
export interface RawTopicInfo {
what: 'read' | 'recv' | 'kp' | 'call';
seq?: number;
from?: string;
event?: string;
payload?: Record<string, unknown>;
}
const infoListeners = new Set<(topicName: string, info: RawTopicInfo) => void>();
/** Raw `{info}` payloads (read/recv/kp/call), forwarded verbatim -- unlike `subscribeAnyTopicChange`/
* `subscribeTopicChanges`, which only signal "something changed, re-read the snapshot" for the
* chat-list/message-list hooks, calls.ts needs the actual `event`/`payload` fields off a `what:
* "call"` info to drive its own ringing/accept/hang-up state machine -- those aren't reconstructable
* from a message snapshot re-read the way a new chat message is. */
export function subscribeTopicInfo(callback: (topicName: string, info: RawTopicInfo) => void): () => void {
infoListeners.add(callback);
return () => infoListeners.delete(callback);
}
function wireTopicCallbacks(topic: Topic) {
if (wiredTopics.has(topic)) return;
const notify = () => notifyTopic(topic.name);
topic.onData = notify;
topic.onInfo = notify;
topic.onInfo = (info) => {
notify();
if (info) infoListeners.forEach((cb) => cb(topic.name, info));
};
topic.onMetaDesc = notify;
topic.onSubsUpdated = notify;
topic.onPres = notify;
@@ -220,3 +242,34 @@ function invalidateChatListSnapshot() {
chatListSnapshot = null;
}
anyTopicListeners.add(invalidateChatListSnapshot);
/**
* Re-fires the `me` topic subscribe and every contact's preview subscribe once a WS reconnect has
* successfully re-authenticated (connection.ts's onReauthenticated). Bypasses subscribeMeTopic's/
* prefetchPreview's own "already done" guards on purpose -- those guards exist to stop a second
* *concurrent* call from firing a redundant `{sub}` while one is already in flight, not to reflect
* whether the topic is still genuinely attached, and after any disconnect it never is (the SDK
* resets `_attached` on every cached topic, confirmed by reading tinode.dev.js's #disconnected()).
* `Topic.subscribe()` itself only short-circuits when `_attached` is still true, so calling it again
* here for an already-detached topic genuinely resends `{sub}`, not a silent no-op.
*
* Deliberately scoped to topics this module already knows about (the `me` topic + everything
* prefetchPreview has ever touched) rather than tracking "the one conversation currently open" as
* separate state -- in practice the chat list and an open conversation are mounted together in this
* app's layout, so the open topic's contact row has already gone through prefetchPreview by the time
* anyone could be looking at it. A conversation reached by a deep link that never rendered the chat
* list at all is the one case this doesn't cover; also out of scope: backfilling any messages that
* were sent by the peer during the drop itself -- this only resumes *live* updates going forward,
* it doesn't gap-fill what was missed while disconnected.
*/
onReauthenticated(() => {
const me = getTinode().getMeTopic();
wireTopicCallbacks(me);
me.onContactUpdate = () => notifyTopic(me.name);
me.subscribe(me.startMetaQuery().withLaterSub().withDesc().withTags().withCred().build()).catch(() => {});
for (const topicName of previewPrefetched) {
const topic = getTinode().getTopic(topicName);
wireTopicCallbacks(topic);
topic.subscribe(topic.startMetaQuery().withLaterData(1).build()).catch(() => {});
}
});
+339
View File
@@ -0,0 +1,339 @@
import { create } from 'zustand';
import { joinRtcCall } from '@/lib/api/calls';
import {
joinCall,
leaveCall,
setCameraOff as agoraSetCameraOff,
setMuted as agoraSetMuted,
switchCamera as agoraSwitchCamera,
} from '@/lib/calls/agora-client';
import { callContentOf, callStateOf, sendCallAccept, sendCallHangup, sendCallInvite, sendCallRinging, type CallWebrtcState } from '@/lib/tinode/calls';
import { getTinode } from '@/lib/tinode/connection';
import { getMessagesSnapshot, subscribeAnyTopicChange, subscribeTopicInfo } from '@/lib/tinode/topics';
import { useSessionStore } from '@/stores/session';
export type CallPhase = 'idle' | 'outgoing' | 'incoming' | 'connected' | 'ended';
/** Generous local ring-timeout fallback -- see startCall's own comment on why this exists. */
const RING_TIMEOUT_MS = 60_000;
interface CallState {
phase: CallPhase;
topic: string | null;
callId: string | null;
seq: number | null;
callType: 'audio' | 'video' | null;
peerName: string;
minimized: boolean;
muted: boolean;
cameraOff: boolean;
connectedAt: number | null;
hasRemoteVideo: boolean;
// Set once joinCall() actually resolves with a local camera track -- CallRoot's self-preview
// effect used to fire once on mount, when the async join chain (invite/accept -> rtc-join REST ->
// dynamic Agora SDK import -> client.join() -> createCameraVideoTrack()) hadn't resolved yet, and
// never re-fire once it did, leaving the local preview box permanently blank for the whole call.
hasLocalVideo: boolean;
// `'busy'` is never a real PERSISTED `head.webrtc` value (confirmed live, see dispatch.ts's own
// comment) so it's deliberately NOT part of `CallWebrtcState` -- but it IS a real value this app's
// own `sendCallHangup(..., 'busy')` puts on the LIVE, ephemeral `{info hang-up}` signal a second
// caller receives, which flows straight into this same field.
endReason: CallWebrtcState | 'busy' | null;
/** Was THIS session the one that placed the call (via startCall), not the one that received it --
* needed to pick "已取消" vs "未接听" for the same wire value the same way dispatch.ts's
* callResultDisplay already does for the permanent chat-bubble record of the same call. */
wasCaller: boolean;
/** docs/IM-前后端对接文档.md §8.6: "切换失败恢复真实按钮状态并给本地提示" -- toggleMute/
* toggleCamera set this on a real Agora-level failure, after rolling the optimistic flip back. */
mediaError: string | null;
startCall: (topic: string, peerName: string, callType: 'audio' | 'video') => Promise<void>;
acceptCall: () => Promise<void>;
rejectCall: () => void;
hangUp: () => void;
toggleMinimize: () => void;
toggleMute: () => Promise<void>;
toggleCamera: () => Promise<void>;
switchCamera: () => Promise<void>;
setHasRemoteVideo: (v: boolean) => void;
_reset: () => void;
_endWithReason: (reason: CallWebrtcState | 'busy') => void;
}
const IDLE = {
phase: 'idle' as const,
topic: null,
callId: null,
seq: null,
callType: null,
peerName: '',
minimized: false,
muted: false,
cameraOff: false,
connectedAt: null,
hasRemoteVideo: false,
hasLocalVideo: false,
endReason: null,
wasCaller: false,
mediaError: null,
};
export const useCallStore = create<CallState>((set, get) => ({
...IDLE,
startCall: async (topic, peerName, callType) => {
const tinodeTopic = getTinode().getTopic(topic);
const callId = getTinode().getNextUniqueId();
set({ ...IDLE, phase: 'outgoing', topic, callId, callType, peerName, wasCaller: true });
try {
const ctrl = await sendCallInvite(tinodeTopic, callId, callType);
const seq = ctrl?.params?.seq;
if (!seq || get().callId !== callId) return;
set({ seq });
// Safety net, not the primary mechanism: the protocol doc only confirms the server marks an
// unanswered call `missed` in the persisted MESSAGE, never confirmed to also push a live
// `{info}` event to the caller for that specific case (unlike accept/hang-up, which do). If
// that push genuinely never comes, nothing else here would ever leave 'outgoing' -- this timer
// is a no-op (cleared by the time it'd fire) on every path that already ends the call some
// other way, and only actually does something on that one unconfirmed gap.
window.setTimeout(() => {
if (get().callId === callId && get().phase === 'outgoing') get().hangUp();
}, RING_TIMEOUT_MS);
} catch {
// The invite itself failed to send -- nothing to ring, don't strand the "正在等待对方接听…"
// overlay forever with no way out but a manual hang-up click.
if (get().callId === callId) get()._reset();
}
// Media is NOT captured/joined here -- docs/IM-前后端对接文档.md §8.7: "接听前媒体轨默认不发给
// 对方". The actual RTC join happens once the peer's 'accept' event arrives (see the
// subscribeTopicInfo handler in initCallSignaling below), mirroring acceptCall()'s own timing on
// the callee side. Previously this joined+published immediately after the invite ack, meaning
// the caller's mic (and camera, for a video call) were live and transmitting for the entire
// ringing period -- before the callee had done anything at all.
},
acceptCall: async () => {
const { topic, callId, seq, callType } = get();
if (!topic || !callId || seq == null || !callType) return;
const tinodeTopic = getTinode().getTopic(topic);
sendCallAccept(tinodeTopic, seq, callId);
set({ phase: 'connected', connectedAt: Date.now() });
try {
const join = await joinRtcCall(topic, callId, seq, callType, 'callee');
if (get().callId !== callId) return;
await joinCall(join, callType, (v) => get().setHasRemoteVideo(v));
if (get().callId === callId) set({ hasLocalVideo: callType === 'video' });
} catch {
// Same guard the structurally-identical caller-side accept handler below already has, for the
// identical reason: `callId` here is the ORIGINAL id this whole acceptCall() call was for,
// captured before the async join even started. Without re-checking it, a hang-up (either
// side) that lands WHILE joinRtcCall()/joinCall() is still in flight -- easily reachable,
// since `phase` already flipped to 'connected' above before the RTC join even begins, making
// the hang-up button clickable during this whole window -- let this catch unconditionally
// clobber whatever CORRECT end-reason that hang-up had already set (or a brand-new
// subsequent call's state) with a false "连接异常". A media/RTC join failure is still a
// distinct outcome from a normal hang-up -- routing it through hangUp() would have mislabeled
// it as "已结束"/"已取消" depending on connectedAt, instead of the honest "连接异常" this is --
// just only when THIS call is still the live one.
if (get().callId !== callId) return;
const { topic: currentTopic, seq: currentSeq } = get();
if (currentTopic && currentSeq != null) sendCallHangup(getTinode().getTopic(currentTopic), currentSeq, callId);
leaveCall();
get()._endWithReason('disconnected');
}
},
rejectCall: () => {
const { topic, seq, callId } = get();
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId, 'declined');
get()._endWithReason('declined');
},
hangUp: () => {
const { topic, seq, callId, connectedAt } = get();
if (topic && seq != null && callId) sendCallHangup(getTinode().getTopic(topic), seq, callId);
leaveCall();
get()._endWithReason(connectedAt ? 'finished' : 'missed');
},
toggleMinimize: () => set((s) => ({ minimized: !s.minimized })),
// Both used to flip the local flag in the exact same tick as firing (unawaited, uncaught)
// agoraSetMuted/agoraSetCameraOff -- a real Agora-level failure (permission revoked mid-call,
// track already closed) left the button showing the wrong state forever with no rollback and no
// user-facing error, contradicting docs/IM-前后端对接文档.md §8.6's explicit requirement.
toggleMute: async () => {
const next = !get().muted;
set({ muted: next, mediaError: null });
try {
await agoraSetMuted(next);
} catch {
set({ muted: !next, mediaError: '切换静音失败,请重试' });
}
},
toggleCamera: async () => {
const next = !get().cameraOff;
set({ cameraOff: next, mediaError: null });
try {
await agoraSetCameraOff(next);
} catch {
set({ cameraOff: !next, mediaError: '切换摄像头失败,请重试' });
}
},
// switchCamera() (front/back) previously had no error handling at all anywhere in its call chain
// -- agora-client.ts's own version now rolls its internal flag back and rethrows on failure (a
// device with no rear camera, or a browser that doesn't support `facingMode`), but nothing
// surfaced that to the user; CallRoot.tsx's own click handler discarded the promise outright with
// no `.catch()`. Routed through the same mediaError channel as toggleMute/toggleCamera above for
// one consistent user-facing failure convention, not a second bespoke one.
switchCamera: async () => {
try {
await agoraSwitchCamera();
} catch {
set({ mediaError: '切换摄像头失败,请重试' });
}
},
setHasRemoteVideo: (v) => set({ hasRemoteVideo: v }),
_reset: () => set({ ...IDLE }),
// `'ended'` briefly shows a result overlay (CallRoot) before settling back to idle -- previously
// every end-of-call path went straight to `{...IDLE, endReason: reason}` in one `set()`, so `phase`
// was ALREADY 'idle' the instant `endReason` was set; CallRoot's top-level render switch only shows
// something for 'incoming'/'outgoing'/'connected', so the whole result (icon/text) was set but
// never actually rendered by anything, the entire 3s window it existed for.
_endWithReason: (reason) => {
// Preserve exactly the fields EndedCallOverlay needs to render (who/what/how-long) -- everything
// else genuinely IS done (media already torn down by the caller before this runs) and correctly
// resets to its idle default. An earlier version of this spread `...IDLE` over everything, which
// wiped peerName/topic/callType/wasCaller too, leaving the overlay rendering a blank peer name.
const { topic, peerName, callType, connectedAt, wasCaller } = get();
set({ ...IDLE, phase: 'ended', endReason: reason, topic, peerName, callType, connectedAt, wasCaller });
window.setTimeout(() => {
if (useCallStore.getState().phase === 'ended') useCallStore.setState({ ...IDLE });
}, 3000);
},
}));
const seenCallIds = new Set<string>();
let listenersWired = false;
/**
* Global call-signal wiring -- call once from AppShell so incoming calls surface regardless of
* which page is open. `subscribeAnyTopicChange` fires on ANY topic change (new messages, presence,
* etc, not just calls), so this re-scans every contact's latest message on each firing and dedupes
* by `callId` (a plain "did this seq change" check isn't enough: the SAME invite message can
* legitimately re-notify, e.g. once for `onData` and again for an unrelated `onPres` on the same
* topic bumping `notifyTopic`). Auto-declines a second incoming call while one is already active --
* matches the old app's own real behavior (tinode-web.jsx's busy-auto-hangup), not a new policy.
*/
export function initCallSignaling() {
if (listenersWired) return;
listenersWired = true;
subscribeAnyTopicChange(() => {
const myUserId = useSessionStore.getState().myUserId;
if (!myUserId) return;
const peerNames = new Map<string, string>();
getTinode()
.getMeTopic()
.contacts((c) => {
if (c.topic) peerNames.set(c.topic, c.public?.fn || c.topic);
});
for (const topicName of peerNames.keys()) {
const messages = getMessagesSnapshot(topicName);
const latest = messages[messages.length - 1];
if (!latest || latest.from === myUserId || typeof latest.seq !== 'number') continue;
const content = callContentOf(latest);
if (!content || callStateOf(latest) !== 'started' || seenCallIds.has(content.callId)) continue;
seenCallIds.add(content.callId);
// `'ended'` is a terminal display-only phase (_endWithReason's own 3s result-overlay window,
// see below) -- treating it the same as a genuinely live call meant a real new incoming call
// arriving in the 3 seconds right after ANY previous call ended (from anyone, unrelated) got
// auto-declined as "busy" even though nothing was actually in progress anymore.
const currentPhase = useCallStore.getState().phase;
if (currentPhase !== 'idle' && currentPhase !== 'ended') {
// Busy -- auto-decline without ever showing the incoming-call UI for this second call.
sendCallHangup(getTinode().getTopic(topicName), latest.seq, content.callId, 'busy');
continue;
}
useCallStore.setState({
...IDLE,
phase: 'incoming',
topic: topicName,
callId: content.callId,
seq: latest.seq,
callType: content.callType,
peerName: peerNames.get(topicName) || topicName,
});
sendCallRinging(getTinode().getTopic(topicName), latest.seq, content.callId);
// Mirrors startCall()'s own safety-net timer (same RING_TIMEOUT_MS, same reasoning: the doc
// never confirms a live push specifically for "caller's device vanished without ever sending
// an explicit hang-up"). Without this, the callee side had NO timeout at all -- if the
// caller's client crashed/lost network/went to sleep mid-ring, this incoming-call UI stayed up
// forever with only a manual decline as an escape. `hangUp()` (not rejectCall()) is the right
// call here, not just for symmetry: it resolves locally to 'missed' (correct -- this ring was
// never actually declined, it just went unanswered) rather than 'declined', and still notifies
// the caller so their own UI resolves too instead of relying on their side's timeout as well.
const ringingCallId = content.callId;
window.setTimeout(() => {
if (useCallStore.getState().callId === ringingCallId && useCallStore.getState().phase === 'incoming') {
useCallStore.getState().hangUp();
}
}, RING_TIMEOUT_MS);
}
});
subscribeTopicInfo((topicName, info) => {
if (info.what !== 'call' || typeof info.event !== 'string') return;
const store = useCallStore.getState();
const payloadCallId = info.payload?.callId;
if (store.topic !== topicName || (payloadCallId && payloadCallId !== store.callId)) return;
if (info.event === 'ringing' && store.phase === 'outgoing') {
// Purely a UI cue (ring-back tone) -- no state transition needed beyond what's already shown.
return;
}
if (info.event === 'accept' && store.phase === 'outgoing') {
useCallStore.setState({ phase: 'connected', connectedAt: Date.now() });
const { topic: joinTopic, callId, seq, callType } = store;
if (joinTopic && callId && seq != null && callType) {
joinRtcCall(joinTopic, callId, seq, callType, 'caller')
.then(async (join) => {
if (useCallStore.getState().callId !== callId) return;
await joinCall(join, callType, (v) => useCallStore.getState().setHasRemoteVideo(v));
if (useCallStore.getState().callId === callId) useCallStore.setState({ hasLocalVideo: callType === 'video' });
})
.catch(() => {
// Same reasoning as acceptCall()'s own catch branch: `connectedAt` was already set (right
// above, before this join attempt) the instant `accept` came in, so routing this through
// the generic hangUp() would read that truthy `connectedAt` and misreport a real "通话时长"
// for a call that never actually connected media at all -- go straight to the honest
// 'disconnected' outcome instead, and still tell the peer so their UI doesn't hang.
if (useCallStore.getState().callId !== callId) return;
if (joinTopic && seq != null) sendCallHangup(getTinode().getTopic(joinTopic), seq, callId);
leaveCall();
useCallStore.getState()._endWithReason('disconnected');
});
}
return;
}
if (info.event === 'hang-up') {
const reason = info.payload?.reason;
const connectedAt = useCallStore.getState().connectedAt;
leaveCall();
// Collapsing every reason except 'busy' into 'finished' meant a callee who clicked 拒绝 made
// the CALLER's overlay wrongly show "通话已结束" instead of "对方已拒绝" (rejectCall() has
// already been sending a real `reason:'declined'` all along, just never read back out here),
// and a caller who cancelled before pickup made the CALLEE's overlay wrongly show "通话已结束"
// instead of "未接听" -- mirrors the SAME connectedAt-based finished-vs-missed distinction the
// local hangUp() above already makes for its own no-reason-given case.
useCallStore.getState()._endWithReason(reason === 'busy' ? 'busy' : reason === 'declined' ? 'declined' : connectedAt ? 'finished' : 'missed');
}
});
}
+25
View File
@@ -0,0 +1,25 @@
import { create } from 'zustand';
/**
* Transient cross-component instruction: HistorySearchModal (owned by MessagesLayout, a sibling of
* ConversationView, not an ancestor of it) sets this when a search result is clicked; ConversationView
* consumes it and clears it. Matches this codebase's own established pattern for this exact category
* of problem (stores/calls.ts's `useCallStore`), rather than widening ConversationView's Outlet
* context (`onOpenHistory` etc.) with a second, differently-shaped callback, or inventing a URL-param
* convention this app doesn't otherwise use anywhere.
*
* A search result is always for the SAME topic already open behind the modal (HistorySearchModal has
* no cross-conversation search), so `topic` here is mostly a defensive consistency check, not a real
* "jump to a different conversation" feature.
*/
interface MessageJumpState {
target: { topic: string; seq: number } | null;
requestJump: (topic: string, seq: number) => void;
clear: () => void;
}
export const useMessageJumpStore = create<MessageJumpState>((set) => ({
target: null,
requestJump: (topic, seq) => set({ target: { topic, seq } }),
clear: () => set({ target: null }),
}));
+5
View File
@@ -8,6 +8,11 @@ export interface TenantContext {
/** Real Tinode WS host + scheme, parsed from tenants/resolve's `endpoints.websocket`. */
host: string;
secure: boolean;
/** `tenants/resolve`'s own `password_policy` (docs/IM-前后端对接文档.md:534, a real per-tenant
* field, not a guess) -- kept here so every password-entry page (Register/ResetPassword/
* SecuritySettings) reads ONE real source instead of each hardcoding its own guessed minimum
* (they'd drifted to two different numbers, 6 and 8, before this existed). */
passwordPolicy?: { minLength: number; maxLength: number };
}
interface SessionState {