;
}
// GroupChat/ChatHeader/SystemEvent: standalone design-reference demo (docs/images/内容沟通对话*.png), same
// treatment as Ch.3's direct-message-view.jsx -- the real group timeline is messages-view.jsx, which now
// has its own real relationship/announcement/join-request/role-badge wiring. Left unchanged, not rewired.
function ChatHeader() { return
;
}
function JoinRequests({topic, fromChat, onToast}) {
// A persistent in-chat banner (messages-view.jsx's handleShowAnnouncement/handleShowJoinRequests)
// is a second entry point besides group-settings, and this whole feature remounts on every
// navigation step (see GroupManagementView's `key={view}` note) -- so an admin actively working
// through requests re-triggers this blank "加载中" flash on essentially every accept/reject.
// `fromChat`/`backToChat` mirror Announcement's handling of the exact same two-entry-point
// ambiguity above -- without it, returning from a click on the in-chat banner always landed on
// "group-settings" (closing the open conversation) instead of back to the chat itself.
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
}, [topic]);
const [items, setItems] = React.useState(() => (topic && readCachedData('group-join-requests:' + topic)) || null);
const [profiles, setProfiles] = React.useState({});
const [loadError, setLoadError] = React.useState(false);
// Tracked in a ref (not state) so `load` doesn't need `profiles` in its dependency array --
// depending on `profiles` here would make `load` a new function every time a profile fetch
// resolves, which would re-trigger the `useEffect(() => load(), [load])` below and re-fetch
// the whole join-request list in a cascade, not just skip already-known profiles.
const fetchedIds = React.useRef(new Set());
const load = React.useCallback(() => {
if (!topic) return;
getGroupJoinRequests(topic, 'all').then(data => {
const list = data.items || [];
writeCachedData('group-join-requests:' + topic, list);
setItems(list);
const ids = new Set();
list.forEach(r => { ids.add(r.user_id); if (r.inviter_id) ids.add(r.inviter_id); });
ids.forEach(id => {
if (fetchedIds.current.has(id)) return;
fetchedIds.current.add(id);
getUserProfile(id).then(p => setProfiles(v => ({...v, [id]: p}))).catch(() => { fetchedIds.current.delete(id); });
});
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
React.useEffect(() => { setItems((topic && readCachedData('group-join-requests:' + topic)) || null); load(); }, [topic, load]);
const handle = (req, accept) => {
const call = accept ? acceptGroupJoinRequest(topic, req.request_id, req.version) : rejectGroupJoinRequest(topic, req.request_id, req.version);
call.then(() => { onToast(accept ? '已同意进群申请' : '已拒绝进群申请'); load(); })
.catch(err => onToast(err.message || '操作失败'));
};
const nameOf = id => (profiles[id] && profiles[id].display_name) || id;
const describe = req => req.source === 'qr' ? `${nameOf(req.user_id)}通过二维码分享进群` :
req.inviter_id ? `${nameOf(req.inviter_id)}邀请${nameOf(req.user_id)}进群` : `${nameOf(req.user_id)}申请加入群聊`;
// Exact resolved/rejected label wording isn't confirmed against the live status enum yet -- falls back
// to the raw status string for anything unrecognized so a mismatch is visible instead of silently wrong.
const statusLabel = {accepted: '已确定', approved: '已确定', rejected: '已拒绝', expired: '已过期'};
return
;
}
function GroupProfile({topic, myUserId, onToast}) {
// Shares 'group:'+topic with the nickname/description editors' own getGroup(topic) calls -- opening
// this once warms the cache for either of those too.
const [group, setGroup] = React.useState(() => (topic && readCachedData('group:' + topic)) || null);
const [loadError, setLoadError] = React.useState(false);
// "群昵称" here means "my nickname within this group" (see MyNicknameEditor below), not the group's
// own name -- confirmed live this preview row was showing group.name instead, since it's easy to
// conflate the two. GET .../members/me/profile is the only endpoint that actually returns this --
// confirmed live that the member list endpoint (getGroupMembers) never includes group_nickname on
// any item, so a version of this fix that read it from there (like MyNicknameEditor below does)
// would always show "未设置" even right after a successful save.
const [myNickname, setMyNickname] = React.useState('');
React.useEffect(() => {
if (!topic) return;
setGroup(readCachedData('group:' + topic) || null);
getGroup(topic).then(g => { writeCachedData('group:' + topic, g); setGroup(g); }).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
React.useEffect(() => {
if (!topic) return;
getMyGroupProfile(topic).then(data => {
setMyNickname(data.nickname || '');
}).catch(() => {});
}, [topic]);
if (!group) return
;
}
// The five backend-persisted governance fields (server/im_http.go imGroupGovernancePatchRequest) --
// docs/images/群组管理.png showed a 4th toggle "群成员修改昵称" that doesn't correspond to any real
// governance field (its own description text also doesn't match member_upload_files' actual meaning,
// which a *different*, buggier draft screenshot happened to label correctly). Shipping a toggle with
// nothing to persist would be worse than a label mismatch, so this uses the mock's original 5-key
// structure -- which lines up exactly with the 5 real backend fields -- with corrected descriptions.
const GOVERNANCE_FIELDS = [
['join_approval', '进群验证', '开启后,他人申请加入群聊需群主或管理员同意'],
['mute_all', '全体禁言', '开启后,仅群主和管理员可以发言'],
['member_upload_files', '群成员文件上传', '关闭后,普通成员将无法上传群文件'],
['member_invite', '群成员邀请', '关闭后,仅群主和管理员可以邀请新成员进群'],
['member_private_chat', '群成员私信权限', '关闭后,不允许发送名片消息,且普通成员在群聊界面、群组信息内点击头像无反应。']
];
function Governance({topic, myRole, tinode, onToast}) {
// Router page for AdminList/Muted/OwnerSelect/the avatar editor -- remounts (per GroupManagementView's
// `key={view}` note) and reflashes its 5 toggles every time the user finishes one of those sub-tasks
// and returns, so this is one of the highest-traffic pages in the file.
const [gov, setGov] = React.useState(() => (topic && readCachedData('group-governance:' + topic)) || null);
const [dissolving, setDissolving] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
setGov(readCachedData('group-governance:' + topic) || null);
getGroupGovernance(topic).then(d => { writeCachedData('group-governance:' + topic, d); setGov(d); }).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
const setField = (key, value) => {
// GET .../governance does return a real `version` (confirmed live) -- omitting it here isn't
// just "no version sent", the server-side UPDATE for this endpoint skips its optimistic-lock
// WHERE clause entirely whenever version<=0, so two admins toggling different switches at
// nearly the same time would have the second write silently clobber the first with no conflict
// signal. Sending the version this page already has on hand costs nothing and closes that gap.
updateGroupGovernance(topic, {[key]: value, version: gov ? gov.version : 0}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })
.catch(err => onToast(err.message || '设置失败'));
};
const dissolve = () => {
// Backend already refuses to actually destroy the group for a non-owner (the same native
// {del hard:true} call it silently downgrades into "remove me from this group" instead) --
// but that response is indistinguishable from a real success, so without this gate a member
// who somehow lands on this page got told "群聊已解散" when they'd really just left it.
if (myRole !== 'owner') return;
if (!window.confirm('解散后群聊将无法恢复,确定要解散该群聊吗?')) return;
const t = tinode ? tinode.getTopic(topic) : null;
if (!t) return;
setDissolving(true);
t.delTopic(true).then(() => { onToast('群聊已解散'); navigate('group-list'); })
.catch(err => onToast(err.message || '解散失败')).finally(() => setDissolving(false));
};
if (!gov) return
;
}
function deriveOwnerAndAdmins(items) {
return {owner: items.find(m => m.role === 'owner') || null, admins: items.filter(m => m.role === 'admin')};
}
function AdminList({topic, onToast}) {
// Shares the same 'group-members:'+topic cache entry as GroupSettings' member strip and
// GroupManagementView's `myRole` lookup -- all three call the identical unfiltered getGroupMembers.
const cachedMembers = topic && readCachedData('group-members:' + topic);
const seed = cachedMembers ? deriveOwnerAndAdmins(cachedMembers) : {owner: null, admins: null};
const [owner, setOwner] = React.useState(() => seed.owner);
const [admins, setAdmins] = React.useState(() => seed.admins);
const [toast, setToast] = React.useState('');
const [loadError, setLoadError] = React.useState(false);
const load = React.useCallback(() => {
if (!topic) return;
const cached = readCachedData('group-members:' + topic);
if (cached) { const d = deriveOwnerAndAdmins(cached); setOwner(d.owner); setAdmins(d.admins); }
// Fetch the unfiltered member list rather than {role:'admin'} + a separate per-user profile lookup
// for the owner -- GET /users/{id}/profile 400s ("malformed request", field "user_id") when {id} is
// the caller's own uid, which broke this whenever the viewer is themselves the group's owner.
// Members cap out at 30/page against a 128-member group limit -- an admin sitting past page 1
// used to vanish from this list entirely rather than the list just being incomplete.
fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => {
writeCachedData('group-members:' + topic, items);
const d = deriveOwnerAndAdmins(items);
setOwner(d.owner);
setAdmins(d.admins);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
React.useEffect(() => { load(); }, [load]);
const remove = userId => {
// deleteGroupAdmin succeeding doesn't mean a follow-up load() will show it -- getGroupMembers
// is backed by a server-side cache that this exact query shape can keep serving stale for a
// while (confirmed live: an unfiltered re-query right after removal still showed the removed
// admin, while a query with different params the session hadn't used yet returned correctly).
// Patch the already-known-correct result into local state and the shared cache directly
// instead of trusting a re-fetch to reflect what was just confirmed to have happened.
deleteGroupAdmin(topic, userId).then(() => {
setToast('管理员已移除');
setAdmins(prev => prev ? prev.filter(a => a.user_id !== userId) : prev);
const cached = readCachedData('group-members:' + topic);
if (cached) writeCachedData('group-members:' + topic, cached.map(m => m.user_id === userId ? {...m, role: 'member'} : m));
}).catch(err => onToast(err.message || '操作失败'));
};
return
;
}
// "我在本群的昵称" has no dedicated reference screenshot (see plan notes) -- placed in GroupSettings,
// governed by the real member_invite... no, by the group_nickname field on groupMemberPayload, gated by
// the "群成员修改昵称" concept the images described (server-side enforcement, not re-checked client-side).
function MyNicknameEditor({topic, onToast}) {
const [value, setValue] = React.useState('');
// getGroupMembers' list items never include group_nickname (confirmed live) -- only
// GET .../members/me/profile actually returns it, same fix as GroupProfile's preview row above.
React.useEffect(() => {
if (!topic) return;
getMyGroupProfile(topic).then(data => setValue(data.nickname || '')).catch(() => {});
}, [topic]);
const save = () => {
updateMyGroupProfile(topic, value.trim(), 0).then(() => {
onToast('保存成功'); window.setTimeout(() => navigate('group-settings', topic), 600);
}).catch(err => onToast(err.message || '保存失败'));
};
return
setValue(e.target.value)} />群昵称仅在当前群内显示。
;
}
export default function GroupManagementView({view, hashParams, myUserId, tinode, onTopicSelected}) {
const [toast, setToast] = React.useState('');
const topic = hashParams && hashParams.topic;
// This container remounts on every navigation step inside the feature (see `key={view}` in
// sidepanel-view.jsx), and Announcement's edit/publish UI is gated on this role -- uncached, that
// gate visibly flickers off and back on every time the announcement page (or any sibling page) is
// opened. Also warms the same shared 'group-members:'+topic entry GroupSettings/AdminList use.
const roleCacheKey = topic && myUserId ? `group-my-role:${topic}:${myUserId}` : null;
const [myRole, setMyRole] = React.useState(() => (roleCacheKey && readCachedData(roleCacheKey)) || null);
React.useEffect(() => {
if (!topic || !myUserId) { setMyRole(null); return; }
const key = `group-my-role:${topic}:${myUserId}`;
setMyRole(readCachedData(key) || null);
// Used to look this up by paginating the full member list and finding myUserId in it (30/page
// against a 128-member cap) -- a real owner/admin sitting past page 1 read back as `null`,
// silently hiding every governance affordance gated on this value. getGroup's own `my_role`
// (already relied on elsewhere, e.g. the disband-group guard above) isn't paginated at all --
// it's always exactly "me", so this no longer needs the member list at all.
getGroup(topic).then(group => {
const role = group ? group.my_role : null;
writeCachedData(key, role);
setMyRole(role);
}).catch(() => {});
}, [topic, myUserId]);
let content;
if (view === 'group-list' || view === 'group-empty') content = ;
else if (['group-invite', 'group-events', 'group-new-request', 'group-announcement', 'group-summary', 'group-role-message'].includes(view)) content = ;
else if (view === 'group-join-requests') content = ;
else if (view === 'group-announcement-detail') content = ;
else if (view === 'group-settings') content = ;
else if (view === 'group-profile') content = ;
else if (view === 'group-avatar') content = ;
else if (view === 'group-nickname') content = ;
else if (view === 'group-description') content = ;
else if (view === 'group-my-nickname') content = ;
else if (view === 'group-members') content = ;
else if (view === 'group-qr') content = ;
else if (view === 'group-governance') content = ;
else if (['group-admins', 'group-admin-success', 'group-admin-partial', 'group-admin-removed'].includes(view)) content = ;
else if (view === 'group-admin-add') content = ;
else if (view === 'group-mute-add') content = ;
else if (view === 'group-muted') content = ;
else if (['group-owner-select', 'group-owner-confirm'].includes(view)) content = ;
else content = ;
return <>{content} setToast('')} />>;
}