import React from 'react';
import QRCode from 'qrcodejs';
import { pinyin } from 'pinyin-pro';
import HashNavigation from '../lib/navigation.js';
import {
getGroups, createGroup, getGroup, updateGroup, getGroupSettings, updateGroupMemberSettings,
updateMyGroupProfile, getMyGroupProfile, leaveGroup, getGroupMembers, createGroupQRCode, getGroupJoinRequests,
acceptGroupJoinRequest, rejectGroupJoinRequest, getLatestGroupAnnouncement,
updateCurrentGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins,
deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner,
getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile
} from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
// Carries the group `topic` (and anything else already in the hash) across navigation, the same way
// contact-management-view.jsx threads `uid` -- these pages all need to know *which* group.
const navigate = (view, topic) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic');
HashNavigation.navigateTo(hash);
};
// 'group-member-profile' is contact-management-view.jsx's Profile component (Ch.5) reused as-is for
// viewing a group member who isn't necessarily a friend -- that route keys off `uid`, not `topic`, so it
// can't go through this file's own `navigate` helper above without clobbering the wrong hash param.
const navigateToProfile = userId => HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
export const GROUP_MANAGEMENT_VIEWS = [
'group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests',
'group-new-request', 'group-announcement', 'group-summary', 'group-announcement-detail',
'group-settings', 'group-profile', 'group-avatar', 'group-nickname', 'group-description',
'group-my-nickname', 'group-role-message', 'group-qr', 'group-governance', 'group-admins',
'group-admin-add', 'group-admin-success', 'group-admin-partial', 'group-admin-removed',
'group-mute-add', 'group-muted', 'group-owner-select', 'group-owner-confirm', 'group-files',
'group-file-upload'
];
// Same first-pinyin-letter grouping as contact-management-view.jsx's alphaInitial -- duplicated rather
// than shared, since that file's copy is unexported and this one is the only other place that needs it.
function alphaInitial(name) {
const ch = (name || '').trim()[0] || '#';
if (/[A-Za-z]/.test(ch)) {
return ch.toUpperCase();
}
if (/[一-龥]/.test(ch)) {
const letter = pinyin(ch, {pattern: 'first', toneType: 'none'});
return /[a-z]/i.test(letter) ? letter.toUpperCase() : '#';
}
return '#';
}
function groupBy(list, keyFn) {
const groups = {};
list.forEach(item => {
const key = keyFn(item);
(groups[key] || (groups[key] = [])).push(item);
});
return Object.keys(groups).sort().map(key => [key, groups[key]]);
}
function shortDate(iso) {
if (!iso) return '';
const d = new Date(iso);
return `${d.getMonth() + 1}-${String(d.getDate()).padStart(2, '0')}`;
}
// -- Demo-only mock data below this line: still used by the untouched GroupChat/ChatHeader/SystemEvent
// -- trio (see comment above GroupChat). Those views are a standalone design reference showing message-
// -- timeline states -- the real group chat timeline is messages-view.jsx, not this file (same treatment
// -- Ch.3 gave direct-message-view.jsx). Nothing else in this file reads these constants any more.
const MEMBERS = [
{id: 'annie', name: '安妮', role: '管理员', avatar: 'img/bkg/p09.jpg'},
{id: 'lizi', name: '栗子来了', role: '成员', avatar: 'img/bkg/p15.jpg'},
{id: 'wang', name: '王珊珊', role: '成员', avatar: 'img/bkg/p14.jpg'},
{id: 'sun', name: '孙光洪', role: '成员', avatar: 'img/bkg/p03.jpg'},
{id: 'lin', name: '林间风', role: '成员', avatar: 'img/bkg/p18.jpg'}
];
const GROUPS_DEMO = {
owned: [{id: 'product', name: '产品研发群', count: 18, avatar: 'img/bkg/p22.jpg'}]
};
function Icon({children}) { return {children}; }
function Header({title, back = 'group-list', backTopic, right, onRight}) {
return
;
}
// 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 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, 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) => {
updateGroupGovernance(topic, {[key]: value}).then(data => { writeCachedData('group-governance:' + topic, data); setGov(data); })
.catch(err => onToast(err.message || '设置失败'));
};
const dissolve = () => {
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
{loadError ? '群组管理加载失败' : '加载中…'}
;
return
{GOVERNANCE_FIELDS.map(([key, label, desc]) =>
{label}{desc} setField(key, v)} />
)}
;
}
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.
getGroupMembers(topic).then(data => {
const items = data.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(topic, userId).then(() => { setToast('管理员已移除'); load(); })
.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);
getGroupMembers(topic).then(data => {
const items = data.items || [];
writeCachedData('group-members:' + topic, items);
const me = items.find(m => m.user_id === myUserId);
const role = me ? me.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-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('')} />>;
}