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:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user