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