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 (
);
}
/** README §4.1's owner/admin corner tags on the 5-col member grid. */
function RoleCorner({ role }: { role: GroupMember['role'] }) {
if (role === 'owner') return 主;
if (role === 'admin') return 管;
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 (
);
}