The previous fix listened for a documented-but-wrong event name (group.profile.updated); live instrumentation of a real rename showed the actual wire event is group.settings.updated. Fixing only the name still didn't work: the refresh mechanism it triggered (getMeta(startMetaQuery().withDesc().build())) was itself unreliable against this backend -- live-confirmed to either time out (504) on a brand-new topic or return stale pre-rename data on an established one, which also explains the "second rename flips back to the original name" symptom (a late, stale response from an earlier call clobbering the correct value). Replace it with the already-reliable REST getGroup() response applied locally via the SDK's own _processMetaDesc (same internal method the SDK calls when a real meta response arrives; confirmed to reach me.contacts(), which the sidebar/header read). Also fixes a second, independently-discovered bug found while verifying the above: notifyTopic's requestAnimationFrame-based notification batching could get permanently stuck if the callback was scheduled while the page wasn't compositing frames, silently freezing all future topic-list updates (not just renames) until a full reload. Falls back to setTimeout when the document is hidden, since that is never dropped the way the rAF was. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
538 lines
30 KiB
TypeScript
538 lines
30 KiB
TypeScript
import { useLayoutEffect, 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 { 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,
|
||
updateGroup,
|
||
updateGroupMemberSettings,
|
||
updateMyGroupNickname,
|
||
type GroupMember,
|
||
type GroupProfile,
|
||
type GroupSettings,
|
||
} from '@/lib/api/groups';
|
||
import type { MediaType } from '@/lib/api/messages';
|
||
import { MAX_TITLE_LENGTH } from '@/lib/config';
|
||
import { useTinode } from '@/lib/tinode/hooks';
|
||
import { forgetTopic } from '@/lib/tinode/topics';
|
||
import { requestConfirm } from '@/stores/confirmDialog';
|
||
import { notify } from '@/stores/globalToast';
|
||
import { requestActionToken } from '@/stores/securityChallenge';
|
||
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('');
|
||
// `updateGroup` (lib/api/groups.ts) has always supported renaming a group -- confirmed live no UI
|
||
// anywhere ever called it (grep across the whole repo: zero call sites), so a group's name was
|
||
// fixed forever at whatever was typed into CreateGroupModal's input at creation time, with no way
|
||
// to fix a typo or rename it later. Owner-only, matching this file's other high-consequence group-
|
||
// level (as opposed to per-member) actions.
|
||
const [nameEditing, setNameEditing] = useState(false);
|
||
const [nameDraft, setNameDraft] = useState('');
|
||
const [errorToast, setErrorToast] = useState('');
|
||
const [openModal, setOpenModal] = useState<'members' | 'invite' | '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('');
|
||
setNameEditing(false);
|
||
setNameDraft('');
|
||
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;
|
||
// Same gap as `canInvite` right above (same governance toggle pattern, same root cause: the
|
||
// "群文件上传权限" toggle in GroupAdminModal writes `member_upload_files` but nothing ever read it
|
||
// back) -- `GroupFilesModal`'s upload button was unconditionally visible/clickable regardless of
|
||
// this setting.
|
||
const canUpload = isAdmin || settingsQuery.data?.governance?.member_upload_files === 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 nameMutation = useMutation({
|
||
mutationFn: (name: string) => updateGroup(topicId, { name, version: groupQuery.data?.version ?? 1 }),
|
||
// Merge, not replace -- same defensive reasoning as updateSettings above, in case this endpoint
|
||
// ever turns out to return a partial object like member-settings did.
|
||
onSuccess: (data) => {
|
||
queryClient.setQueryData(['group', topicId], (prev: GroupProfile | undefined) => (prev ? { ...prev, ...data } : data));
|
||
setNameEditing(false);
|
||
// The renamer's OWN sidebar/header (`contact.public?.fn`, native SDK state) doesn't update from
|
||
// this REST call either -- only from the `group.settings.updated` push topics.ts listens for,
|
||
// which this account won't receive for its own action. `data` here is already the fresh REST
|
||
// response, so apply it locally via `_processMetaDesc` (live-confirmed to reach `me.contacts()`,
|
||
// see topics.ts's comment on the same call) instead of an extra `getMeta(...withDesc())` round
|
||
// trip -- that SDK-sanctioned path was live-instrumented and found to reliably time out (504) or
|
||
// return stale data against this backend, so it's not used anywhere in this fix anymore.
|
||
tinode.getTopic(topicId)._processMetaDesc({ public: { fn: data.name, name: data.name } });
|
||
},
|
||
onError: (err) => showError(err, '群名保存失败'),
|
||
});
|
||
|
||
// Both mutations call forgetTopic() the instant their own REST call succeeds, rather than waiting
|
||
// on the async native/`group.dissolved` gone-signal that normally follows a group actually going
|
||
// away -- real, user-reported bugs fixed by this: (1) the chat list kept showing a broken stale
|
||
// stub for this group after leaving/disbanding it (same lingering-stub symptom `forgetTopic` was
|
||
// originally built to fix for the refresh/404 case, just reached via a third path); (2) that same
|
||
// late signal, left unguarded, used to re-fire ConversationView's generic "该群聊已解散" toast on
|
||
// top of this action's own navigate -- wrong wording specifically for 退出群聊 (leaving yourself
|
||
// isn't the group being dissolved). `markTopicGoneAndNotify`'s own guard (topics.ts) now skips that
|
||
// late signal entirely once `forgetTopic` has already marked this name gone here.
|
||
const leaveMutation = useMutation({
|
||
mutationFn: () => leaveGroup(topicId),
|
||
onSuccess: () => {
|
||
forgetTopic(topicId);
|
||
navigate('/messages');
|
||
},
|
||
onError: (err) => showError(err, '退出群聊失败'),
|
||
});
|
||
|
||
const disbandMutation = useMutation({
|
||
mutationFn: (actionToken: string) => disbandGroup(topicId, actionToken),
|
||
onSuccess: () => {
|
||
forgetTopic(topicId);
|
||
navigate('/messages');
|
||
},
|
||
onError: (err) => showError(err, '解散群聊失败'),
|
||
});
|
||
|
||
const clearHistory = async () => {
|
||
if (!(await requestConfirm({ message: '确定清空本群的聊天记录吗?此操作仅清除本地记录,不影响其他成员。', danger: true }))) return;
|
||
try {
|
||
await tinode.getTopic(topicId).delMessagesAll(false);
|
||
} catch (err) {
|
||
showError(err, '清空聊天记录失败,请重试');
|
||
}
|
||
};
|
||
|
||
const reportAbuse = () => {
|
||
notify('该功能暂未开放,如需投诉举报请联系客服。');
|
||
};
|
||
|
||
const startLeaveOrDisband = async () => {
|
||
if (isOwner) {
|
||
if (!(await requestConfirm({ message: '解散群聊后,所有成员将无法再查看和发送消息,此操作不可恢复。确定解散吗?', danger: true }))) return;
|
||
const actionToken = await requestActionToken({
|
||
scene: 'group_dissolve',
|
||
actionDescription: `解散群聊「${groupQuery.data?.name || peerName}」`,
|
||
});
|
||
if (!actionToken) return;
|
||
disbandMutation.mutate(actionToken);
|
||
} else {
|
||
if (!(await requestConfirm({ message: '确定退出本群聊吗?', danger: true }))) 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">
|
||
<Avatar
|
||
id={topicId}
|
||
name={groupQuery.data?.name || peerName}
|
||
avatarFileId={groupQuery.data?.avatar_file_id}
|
||
className="h-13 w-13 flex-none rounded-xl text-xl"
|
||
/>
|
||
<div className="flex min-w-0 flex-1 flex-col">
|
||
{nameEditing ? (
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
autoFocus
|
||
value={nameDraft}
|
||
onChange={(e) => setNameDraft(e.target.value.slice(0, MAX_TITLE_LENGTH))}
|
||
onKeyDown={(e) => e.key === 'Enter' && nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
|
||
className="min-w-0 flex-1 rounded-md bg-surface-input px-2 py-1 text-sm outline-none"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => nameDraft.trim() && nameMutation.mutate(nameDraft.trim())}
|
||
disabled={!nameDraft.trim() || nameMutation.isPending}
|
||
className="flex-none text-sm text-accent disabled:opacity-40"
|
||
>
|
||
保存
|
||
</button>
|
||
<button type="button" onClick={() => setNameEditing(false)} className="flex-none text-sm text-text-tertiary-2">
|
||
取消
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<span className="flex min-w-0 items-center gap-1">
|
||
<strong className="truncate text-base font-semibold text-text-heading">{groupQuery.data?.name || peerName}</strong>
|
||
{/* Owner-only -- renaming a group is a group-level action, not a per-member one
|
||
like the 我在本群的昵称 row further down, so this follows this file's other
|
||
owner-gated actions (转让群主/解散群聊) rather than opening it to admins too. */}
|
||
{isOwner ? (
|
||
<button
|
||
type="button"
|
||
aria-label="修改群名"
|
||
onClick={() => {
|
||
setNameDraft(groupQuery.data?.name || '');
|
||
setNameEditing(true);
|
||
}}
|
||
className="flex-none text-text-tertiary-3"
|
||
>
|
||
<Icon name="edit" className="text-[16px]" />
|
||
</button>
|
||
) : null}
|
||
</span>
|
||
)}
|
||
<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). */}
|
||
<Avatar
|
||
id={m.user_id}
|
||
name={m.display_name}
|
||
avatarFileId={m.avatar_file_id}
|
||
className="h-11 w-11 rounded-lg text-sm md:h-10 md:w-10"
|
||
/>
|
||
<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 ? (
|
||
// Goes straight to the invite-candidates view (GroupMembersModal's `initialView=
|
||
// "invite"`), not the member list -- this dedicated "+邀请" tile landing on the member
|
||
// list and requiring a SECOND click on an in-modal "邀请" button was a real, reported
|
||
// UX complaint ("点击邀请那个按钮弹出的并不是直接到邀请,而是显示的成员列表,还要再次
|
||
// 点击邀请"). "查看全部"/"查看全部成员" right below/above this still correctly open the
|
||
// plain member list -- only THIS tile's whole purpose is inviting.
|
||
<button type="button" onClick={() => setOpenModal('invite')} 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' || openModal === 'invite' ? (
|
||
<GroupMembersModal
|
||
topicId={topicId}
|
||
canInvite={canInvite}
|
||
initialView={openModal === 'invite' ? 'invite' : 'members'}
|
||
onClose={() => setOpenModal(null)}
|
||
/>
|
||
) : null}
|
||
{openModal === 'announcement' ? (
|
||
<GroupAnnouncementModal topicId={topicId} canEdit={isAdmin} onClose={() => setOpenModal(null)} />
|
||
) : null}
|
||
{openModal === 'files' ? <GroupFilesModal topicId={topicId} isAdmin={isAdmin} canUpload={canUpload} onClose={() => setOpenModal(null)} /> : null}
|
||
{openModal === 'admin' ? <GroupAdminModal topicId={topicId} onClose={() => setOpenModal(null)} /> : null}
|
||
{openModal === 'qrcode' ? <QRCodeModal groupTopic={topicId} onClose={() => setOpenModal(null)} /> : null}
|
||
</aside>
|
||
);
|
||
}
|