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