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
@@ -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>
);
}