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,
getGroupMemberProfile
} from '../lib/im-api.js';
import { readCachedData, writeCachedData, useDebouncedValue } 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.
// 除了跳转,还必须先把已知资料写进 'profile-seed:' 缓存 —— 目标页(第5章的 Profile)会先读这个种子,
// 因为 GET /users/{id}/profile 对非好友按隐私默认直接 403(imPrivacyAllowsProfile)。群成员里
// 大部分不是好友,不 seed 的话点进去就是"资料加载失败,请重试"。第5章自己的入口
// (contact-management-view.jsx 的 seedProfile)一直是这么做的,这里漏了。
const navigateToProfile = (topic, userId, member) => {
// 先用群成员列表里已有的字段摆一个立即可用的种子(避免导航瞬间空白),
// 但这份种子缺 capabilities/relationship——GET /groups/{topic}/members 从不返回这两个字段
// (已实测确认),Profile 组件正是靠它们判断"要不要显示添加好友按钮"和"该显示哪句失败文案"。
// 缺了它们,非好友的群成员主页会落到 cannotAddReason 的默认分支——不管真实关系是什么,
// 一律显示"好友申请正在等待验证",添加好友按钮永远不出现。
if (member && userId) {
writeCachedData('profile-seed:' + userId, {
user_id: userId,
display_name: member.group_nickname || member.display_name || userId,
avatar_file_id: member.avatar_file_id,
...(member.signature ? {signature: member.signature} : {}),
...(member.region ? {region: member.region} : {}),
...(member.gender ? {gender: member.gender} : {})
});
}
HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
// 群场景下真正该用的是 GET /groups/{topic}/members/{user_id}/profile——群上下文里权限判断更
// 宽松,实测对方哪怕不是好友也能拿到 {capabilities, relationship}(Profile 组件目标页读的
// GET /users/{id}/profile 对非好友是 403,只能回退用种子)。异步补种:请求回来后如果用户还
// 停在这个人的资料页(没有连点跳到别处),用真实数据覆盖种子并强制该页面重新读取。
if (topic && userId) {
getGroupMemberProfile(topic, userId).then(real => {
const current = readCachedData('profile-seed:' + userId) || {};
writeCachedData('profile-seed:' + userId, {...current, ...real});
window.dispatchEvent(new CustomEvent('profile-seed-updated', {detail: {uid: userId}}));
}).catch(() => {});
}
};
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]]);
}
// GET /v1/im/groups 的 name/member_count 目前不可信:handler 用的是 store.Users.GetSubs,而 store 自己的
// 注释就写着它 "Does not load Public/Trusted or Private"(store.go:834-835),可 groupListItemPayload
// 偏偏去读 sub.GetPublic() 取名字、sub.GetSubCnt() 取人数——于是每个群都返回自己的 topic id 当名字、
// 0 当人数(应该用 GetTopics,它会把 Public 拷到 subscription 上)。已记入后端问题清单。
// 在后端修好之前,用本地 Tinode 会话缓存兜底——消息首页的群名一直是从这里取的,是准的。
function groupDisplayName(tinode, group) {
if (group.name && group.name !== group.topic) return group.name;
const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null;
const pub = t && t.public;
// 群的 public 用的是 `name`(实测本地缓存就是 {name:"扫码测试群", version:1}),后端自己的
// groupName() 也是按 name/display_name/fn 这个顺序找的,保持一致。
const local = pub && (pub.name || pub.display_name || pub.fn);
return local || group.name || group.topic;
}
function groupMemberCount(tinode, group) {
if (group.member_count) return group.member_count;
const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null;
return (t && t.subcnt) || 0;
}
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} ; }
// onBack:少数页面的返回目标不是"另一个侧栏视图",而是要关掉侧栏回到某个会话(需要写 path[1],
// navigate 辅助函数只会写 query 参数),这类情况传一个自己的回调进来。
function Header({title, back = 'group-list', backTopic, onBack, right, onRight}) {
return
{ if (!HashNavigation.goBack()) { if (onBack) onBack(); else navigate(back, backTopic); } }} aria-label="返回">arrow_back_ios_new
{title}
{right ? {right} : }
;
}
function Avatar({item, icon}) {
const fileId = item && item.avatar_file_id;
const [url, setUrl] = React.useState(null);
React.useEffect(() => {
if (!fileId) { setUrl(null); return undefined; }
let live = true;
getFileUrl(fileId).then(u => { if (live) setUrl(u); }).catch(() => {});
return () => { live = false; };
}, [fileId]);
return {url ? : {icon || 'group'} } ;
}
function Toast({message, onDone}) {
React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 2000); return () => window.clearTimeout(timer); }, [message, onDone]);
return message ?
{message}
: null;
}
// Centered overlay confirmation, docs/images/添加管理员-设置成功.png -- distinct from the bottom Toast
// above (used for lighter "已同意进群申请"-style feedback that doesn't need a heavier interrupt).
function CenterToast({message, onDone}) {
React.useEffect(() => { if (!message) return undefined; const timer = window.setTimeout(onDone, 1400); return () => window.clearTimeout(timer); }, [message, onDone]);
return message ? check_circle {message}
: null;
}
function Search({value, onChange, placeholder = '搜索'}) {
return search onChange(event.target.value)} placeholder={placeholder} /> ;
}
function Toggle({checked, onChange, disabled}) {
return !disabled && onChange(!checked)}> ;
}
function GroupList({tinode, onToast}) {
// Only the unfiltered (query === '') result is worth caching -- caching a search result under the
// same key would show stale search results instead of the real list on the next plain visit.
const [lists, setLists] = React.useState(() => readCachedData('groups') || {owned: null, managed: null, joined: null});
const [loadErrors, setLoadErrors] = React.useState({owned: false, managed: false, joined: false});
const [category, setCategory] = React.useState('owned');
// 这里比别处更值得防抖:reload 对 owned/managed/joined 三个分类各发一次请求,
// 每敲一个字符就是 3 个 getGroups。
const [queryInput, setQueryInput] = React.useState('');
const query = useDebouncedValue(queryInput, 300);
const [creating, setCreating] = React.useState(false);
const [name, setName] = React.useState('');
const [submitting, setSubmitting] = React.useState(false);
const reload = React.useCallback(() => {
['owned', 'managed', 'joined'].forEach(cat => {
getGroups({category: cat, query}).then(data => {
const items = data.items || [];
setLists(v => {
const next = {...v, [cat]: items};
if (!query) writeCachedData('groups', next);
return next;
});
}).catch(() => { onToast('加载失败'); setLoadErrors(v => ({...v, [cat]: true})); });
});
}, [query]);
React.useEffect(() => { reload(); }, [reload]);
const current = lists[category];
const submit = () => {
if (!name.trim() || submitting) return;
setSubmitting(true);
createGroup({name: name.trim()}).then(() => {
setCreating(false); setName(''); onToast('群聊创建成功'); reload();
}).catch(err => onToast(err.message || '创建失败')).finally(() => setSubmitting(false));
};
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 navigate('group-list')}>arrow_back_ios_new
产品研发群 18人 navigate('group-settings')}>more_vert ; }
function SystemEvent({children}) { return {children}
; }
function GroupChat({mode, onToast}) {
const [joined, setJoined] = React.useState(false);
return {['group-new-request', 'group-summary'].includes(mode) ?
navigate('group-join-requests')}>person_add_alt 新的进群申请 2 条申请等待处理 chevron_right : null}{['group-announcement', 'group-summary'].includes(mode) ?
navigate('group-announcement-detail')}>campaign 群公告 下午三点进行版本评审,请准时参加 chevron_right : null}
今天 10:24
{mode === 'group-invite' ? 一起参与新版产品的设计与研发讨论
{setJoined(true); onToast('已加入产品研发群');}}>{joined ? '已加入' : '立即加入'} : <>安妮邀请栗子来了加入群聊 管理员通过了王珊珊的进群申请 >}record_voice_over sentiment_satisfied_alt add_circle_outline
;
}
function JoinRequests({topic, 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.
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
{items === null ? {loadError ? '申请列表加载失败' : '加载中…'}
:
items.length ? items.map(item =>
{describe(item)} {shortDate(item.created_at)}
{item.status === 'pending' ?
handle(item, false)}>拒绝
handle(item, true)}>确定
:
{statusLabel[item.status] || item.status} }
) :
暂无进群申请
}
;
}
function Announcement({topic, myRole, fromChat, onToast}) {
// Shares the 'group-announcement:'+topic cache key with GroupSettings' preview snippet below --
// whichever screen loads first warms the cache for the other, and both are re-entered often (the
// in-chat banner is a persistent second entry point on top of group-settings).
const cachedAnnouncement = topic && readCachedData('group-announcement:' + topic);
const [data, setData] = React.useState(() => cachedAnnouncement || null);
const [draft, setDraft] = React.useState(() => (cachedAnnouncement && cachedAnnouncement.content) || '');
const [editing, setEditing] = React.useState(false);
const [saving, setSaving] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
const canEdit = myRole === 'owner' || myRole === 'admin';
// Coming from the announcement banner atop the chat itself (messages-view.jsx's
// handleShowAnnouncement) vs. from group-settings' "群公告" row land on the identical
// path[0]=group-announcement-detail hash -- `fromChat` is the only thing telling them apart, so
// "back" can return to the chat (close the sidepanel) instead of always forcing group-settings.
// 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼 hash,所以 path[1] 是空的),返回应该
// 关掉侧栏回到那个会话。原来写的是 navigate('', topic)——navigate 只把 topic 写成 query 参数,
// path[1] 依旧为空,tinode-web 的 handleHashRoute 就把 topicSelected 清成 null,会话跟着一起关了。
// 这里显式把 topic 写回 path[1]。
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
}, [topic]);
React.useEffect(() => {
if (!topic) return;
const cached = readCachedData('group-announcement:' + topic);
if (cached) { setData(cached); setDraft(cached.content || ''); }
getLatestGroupAnnouncement(topic).then(d => {
writeCachedData('group-announcement:' + topic, d);
setData(d); setDraft(d.content || '');
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
const publish = () => {
if (!draft.trim() || saving) return;
setSaving(true);
updateCurrentGroupAnnouncement(topic, draft.trim(), data ? data.version : 0).then(d => {
writeCachedData('group-announcement:' + topic, d);
setData(d); setEditing(false); onToast('群公告已发布');
}).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false));
};
if (!data) return {loadError ? '群公告加载失败' : '加载中…'}
;
return ;
}
function GroupSettings({topic, myUserId, tinode, onToast}) {
// This is the landing point for "back" from GroupProfile/GroupFiles/Governance/MyNicknameEditor/
// Announcement, and (per GroupManagementView's `key={view}` note) the whole subtree remounts on
// every one of those round trips -- essentially every group-settings visit after the first refetches
// from scratch, making this the single highest-traffic page in this file to seed from cache.
const [group, setGroup] = React.useState(() => (topic && readCachedData('group-settings:' + topic)) || null);
const [loadError, setLoadError] = React.useState(false);
// The member strip and AdminList/`myRole` all call the identical unfiltered getGroupMembers(topic) --
// sharing one 'group-members:'+topic cache entry (storing the raw list, sliced to 9 only for local
// display here) lets any one of the three warm it for the others.
const [members, setMembers] = React.useState(() => {
const cached = topic && readCachedData('group-members:' + topic);
return cached ? cached.slice(0, 9) : [];
});
const [announcement, setAnnouncement] = React.useState(() => {
const cached = topic && readCachedData('group-announcement:' + topic);
return cached && cached.content ? cached : null;
});
React.useEffect(() => {
if (!topic) return;
setGroup(readCachedData('group-settings:' + topic) || null);
setMembers((readCachedData('group-members:' + topic) || []).slice(0, 9));
const cachedAnn = readCachedData('group-announcement:' + topic);
setAnnouncement(cachedAnn && cachedAnn.content ? cachedAnn : null);
getGroupSettings(topic).then(g => { writeCachedData('group-settings:' + topic, g); setGroup(g); }).catch(() => { onToast('加载失败'); setLoadError(true); });
getGroupMembers(topic).then(data => {
const items = data.items || [];
writeCachedData('group-members:' + topic, items);
setMembers(items.slice(0, 9));
}).catch(() => {});
getLatestGroupAnnouncement(topic).then(d => {
writeCachedData('group-announcement:' + topic, d);
setAnnouncement(d && d.content ? d : null);
}).catch(() => {});
}, [topic]);
const setPref = patch => {
updateGroupMemberSettings(topic, {pinned: group.pinned, mute_notifications: group.mute_notifications, ...patch})
.then(() => setGroup(g => {
const next = {...g, ...patch};
writeCachedData('group-settings:' + topic, next);
return next;
})).catch(err => onToast(err.message || '设置失败'));
};
const leave = () => {
if (group && group.my_role === 'owner') { onToast('群主需先转让群主或解散群聊'); return; }
leaveGroup(topic).then(() => { onToast('已退出群聊'); navigate('group-list'); }).catch(err => onToast(err.message || '操作失败'));
};
if (!group) return {loadError ? '群组信息加载失败' : '加载中…'}
;
const canManage = group.permissions && group.permissions.can_manage_settings;
const links = [
['group-profile', '群资料', group.name],
['group-announcement-detail', '群公告', announcement ? announcement.content : '暂无群公告'],
['group-files', '群文件', ''],
['group-my-nickname', '我在本群的昵称', '']
];
if (canManage) links.splice(3, 0, ['group-governance', '群组管理', '']);
return
navigate('group-governance', topic) : undefined} />
navigate('group-profile', topic)}>
{group.name} {group.description || '群主很懒,还没有群简介'}
chevron_right
{/* 参考图 docs/images/群聊设置.png:一行"群成员"标题(右侧带人数),下面是等宽网格,最后一格
是"添加"。这里刻意不把标题行右侧做成可点链接、也没有参考图里那个"删除"格——本分支没有
通用的群成员列表页,也没有移除成员的页面,与其跳到语义不对的页面或放一个点了没用的格子,
不如先只展示信息,缺口另行记录。 */}
群成员 共 {group.member_count} 位成员
{members.slice(0, 9).map(item =>
navigateToProfile(topic, item.user_id, item)}>
{item.group_nickname || item.display_name}
)}
onToast('请通过转发/分享群二维码邀请成员')}>
add 添加
{links.map(item => navigate(item[0], topic)}>
{item[1]} {item[2]} chevron_right
)}
置顶聊天 setPref({pinned: v})} />
消息免打扰 setPref({mute_notifications: v})} />
navigate('history-search', topic)}>查看聊天记录 chevron_right
{
const t = tinode ? tinode.getTopic(topic) : null;
(t ? t.delMessagesAll(false) : Promise.resolve()).then(() => onToast('聊天记录已清除')).catch(err => onToast(err.message || '操作失败'));
}}>清空聊天记录 chevron_right
{/* target_type='group' 这条链路早就是通的(tinode-web.jsx 的 handleReportTopic 就在用),
这里原来一直是个"暂未开放"的死桩。跟 contact-management-view.jsx 的好友举报入口一样,
举报页要的是 target_type/target_id/target_name 三个参数,本文件的 navigate 辅助函数
只会写 topic,所以直接拼 hash。 */}
{
let hash = HashNavigation.setUrlSidePanel('', 'abuse-report-reasons');
hash = HashNavigation.addUrlParam(hash, 'target_type', 'group');
hash = HashNavigation.addUrlParam(hash, 'target_id', topic);
hash = HashNavigation.addUrlParam(hash, 'target_name', (group && group.name) || topic);
HashNavigation.navigateTo(hash);
}}>投诉/举报 chevron_right
退出群组
;
}
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 {loadError ? '群组信息加载失败' : '加载中…'}
;
const canEdit = group.permissions && group.permissions.can_edit_profile;
const nicknameLabel = myNickname || '未设置';
return
canEdit && navigate('group-avatar', topic)}>
群头像 chevron_right
navigate('group-my-nickname', topic)}>
群昵称 {nicknameLabel} chevron_right
{canEdit ? navigate('group-nickname', topic)}>
群名称 {group.name} chevron_right
:
群名称 {group.name}
}
{canEdit ? navigate('group-description', topic)}>
群简介 {group.description || '群主很懒,还没有群简介'} chevron_right
:
群简介 {group.description || '群主很懒,还没有群简介'}
}
navigate('group-qr', topic)}>群二维码 qr_code chevron_right
{group.member_count} 位成员
;
}
function AvatarSheet({topic, myUserId, tinode, onToast}) {
const inputRef = React.useRef(null);
const [uploading, setUploading] = React.useState(false);
const pick = () => inputRef.current && inputRef.current.click();
const onFile = e => {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file || !tinode) return;
setUploading(true);
// Fetch the current version right before the write, same optimistic-concurrency protection the
// name/description editors already have (GroupProfile below re-fetches independently and its
// possibly-stale copy isn't reused here) -- avatar updates were the one group-profile field with
// none at all.
uploadLargeFile(tinode, file)
.then(({fileId}) => getGroup(topic).then(group => updateGroup(topic, {avatar_file_id: fileId, version: group.version})))
.then(() => { onToast('群头像已更新'); navigate('group-profile', topic); })
.catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));
};
return ;
}
function GroupQr({topic, onToast}) {
const ref = React.useRef(null);
const [data, setData] = React.useState(null);
React.useEffect(() => {
if (!topic) return;
createGroupQRCode(topic).then(setData).catch(() => onToast('二维码生成失败'));
}, [topic]);
React.useEffect(() => {
if (!ref.current || !data) return;
ref.current.innerHTML = '';
new QRCode(ref.current, {text: data.qr_payload, width: 190, height: 190, correctLevel: QRCode.CorrectLevel.M});
}, [data]);
const group = data && data.group_snapshot;
const share = () => {
if (navigator.share && data) {
navigator.share({title: group && group.name, text: '扫码加入群聊', url: data.qr_payload}).catch(() => {});
} else if (data) {
navigator.clipboard.writeText(data.qr_payload).then(() => onToast('链接已复制')).catch(() => onToast('分享失败'));
}
};
const save = () => {
const canvas = ref.current && ref.current.querySelector('canvas');
if (!canvas) { onToast('保存失败'); return; }
const a = document.createElement('a');
a.href = canvas.toDataURL('image/png');
a.download = (group ? group.name : 'group') + '-qrcode.png';
a.click();
onToast('群二维码已保存');
};
return
{group ? group.name : ''} {group ? group.member_count : 0} 位成员
使用APP扫码二维码,加入群聊
share 分享好友
download 保存至相册
;
}
// 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
navigate('group-avatar', topic)}>设置群头像 chevron_right
navigate('group-join-requests', topic)}>邀请进群申请 chevron_right
navigate('group-muted', topic)}>禁言的群成员 chevron_right
navigate('group-admins', topic)}>设置管理员 chevron_right
navigate('group-owner-select', topic)}>群主管理权转让 chevron_right
{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 ;
}
// Shared alphabetically-grouped multi-select picker for admin-add / mute-add (same visual component,
// three purposes, per docs/images/添加管理员.png vs .../添加管理员-设置成功(1).png which turned out to
// actually be the mute-add variant of the identical layout).
function MemberPicker({type, topic, onToast}) {
const mute = type === 'mute-add';
const back = mute ? 'group-muted' : 'group-admins';
const [members, setMembers] = React.useState(null);
const [selected, setSelected] = React.useState([]);
const [query, setQuery] = React.useState('');
const [toast, setToast] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic, mute ? {muteEligible: true} : {eligibleRole: 'admin'})
.then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))
.catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic, mute]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const groups = groupBy(visible, m => alphaInitial(m.group_nickname || m.display_name));
const toggle = id => setSelected(v => v.includes(id) ? v.filter(x => x != id) : v.concat(id));
const done = () => {
if (!selected.length || busy) return;
setBusy(true);
const call = mute ? muteGroupMembers(topic, selected.map(user_id => ({user_id}))) : addGroupAdmins(topic, selected);
call.then(result => {
// 两个批量接口的响应结构不一样,不能用同一套判定:
// - 添加管理员 POST .../admins/batch 返回 succeeded[],每项带 ok/reason(im_http.go:5377-5392)
// - 添加禁言 POST .../mutes 返回 {topic, items},只有成功的才进 items,
// 非法 uid 会被静默 continue(im_http.go:5486-5488),根本没有 succeeded 字段
// 原来两条路都读 succeeded,禁言那条恒为 undefined → failed 恒为空 → 不管实际成没成都弹"设置成功"。
let failedCount;
if (mute) {
const done = (result && Array.isArray(result.items)) ? result.items.length : selected.length;
failedCount = Math.max(selected.length - done, 0);
} else {
failedCount = (result && Array.isArray(result.succeeded)) ? result.succeeded.filter(s => !s.ok).length : 0;
}
setToast(failedCount ? `成功设置 ${selected.length - failedCount} 人,${failedCount} 人设置失败` : '设置成功');
// 导航会让整棵子树重挂载、把 toast 一起销毁。原来 900ms 就跳走,而 CenterToast 的生命
// 周期是 1400ms —— 参考图里那个「设置成功」实际上只闪一下就被截断,而且从不在目标列表页出现。
window.setTimeout(() => navigate(back, topic), 1500);
}).catch(err => onToast(err.message || '设置失败')).finally(() => setBusy(false));
};
return
{members === null ? {loadError ? '成员列表加载失败' : '加载中…'}
: groups.map(([letter, list]) =>
{letter}
{list.map(item => toggle(item.user_id)}>
{item.group_nickname || item.display_name}
{selected.includes(item.user_id) ? check : null}
)}
)}
setToast('')} />
;
}
function Muted({topic, onToast}) {
const [items, setItems] = React.useState(() => (topic && readCachedData('group-muted:' + topic)) || null);
// groupGovernancePayload's mute list only ever returns a flat user_id (no display_name field --
// confirmed against the real backend struct), so without this it rendered raw ids like "usr8x2kf9"
// instead of names. The full member roster already carries group_nickname/display_name for
// everyone still in the group, and fetching it here is one extra request for the whole list
// (not per-row), unlike JoinRequests' per-item getUserProfile lookup.
const [names, setNames] = React.useState({});
const [avatars, setAvatars] = React.useState({});
const [loadError, setLoadError] = React.useState(false);
const load = React.useCallback(() => {
if (!topic) return;
getGroupMutes(topic).then(data => {
const list = data.items || [];
writeCachedData('group-muted:' + topic, list);
setItems(list);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
getGroupMembers(topic).then(data => {
const map = {};
const avatarMap = {};
(data.items || []).forEach(m => {
map[m.user_id] = m.group_nickname || m.display_name || m.user_id;
if (m.avatar_file_id) avatarMap[m.user_id] = {avatar_file_id: m.avatar_file_id};
});
setNames(map);
setAvatars(avatarMap);
}).catch(() => {});
}, [topic]);
React.useEffect(() => { setItems((topic && readCachedData('group-muted:' + topic)) || null); load(); }, [topic, load]);
const unmute = userId => {
unmuteGroupMember(topic, userId).then(() => { onToast('已解除禁言'); load(); })
.catch(err => onToast(err.message || '操作失败'));
};
return ;
}
function OwnerSelect({topic, onToast}) {
const [members, setMembers] = React.useState(null);
const [query, setQuery] = React.useState('');
const [target, setTarget] = React.useState(null);
// 后端 serveIMGroupOwnerTransfer 只认 {target_user_id, version},没有任何二次验证/action_token
// 机制(跟青少年模式关闭那种真的会被服务端拒绝的场景不一样)。群主转让同样不可逆、后果同样
// 重大,照抄本项目已有的处理这类"后端没有二次验证但操作本身很危险"场景的方式——见
// personal-settings-view.jsx 的 DeleteAccountConfirm:加一步前端自己的密码输入门槛,不追加到
// 请求体、不指望服务端校验它,只是让"手滑点确定"变成需要多一步真实操作才会发生的事。
const [passwordStep, setPasswordStep] = React.useState(false);
const [password, setPassword] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [loadError, setLoadError] = React.useState(false);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic, {eligibleRole: 'owner'}).then(data => setMembers((data.items || []).filter(m => m.eligible !== false)))
.catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic]);
const visible = (members || []).filter(m => (m.group_nickname || m.display_name || '').includes(query));
const admins = visible.filter(m => m.role === 'admin');
const others = visible.filter(m => m.role !== 'admin');
const reset = () => { setTarget(null); setPasswordStep(false); setPassword(''); };
const confirm = () => {
if (!password) { onToast('请输入密码以确认'); return; }
if (busy || !target) return;
setBusy(true);
transferGroupOwner(topic, target.user_id).then(() => { onToast('群主已转让'); navigate('group-governance', topic); })
.catch(err => onToast(err.message || '转让失败')).finally(() => setBusy(false));
};
const row = item => setTarget(item)}>
{item.group_nickname || item.display_name}
;
return
{members === null ? {loadError ? '成员列表加载失败' : '加载中…'}
: <>
{admins.length ? <>管理员
{admins.map(row)}> : null}
{others.length ? <>群成员
{others.map(row)}> : null}
>}
{target && !passwordStep ?
选择新群主
{target.group_nickname || target.display_name}成为新群主,确定后你将立即失去群主身份
setPasswordStep(true)}>确定
取消
: null}
{target && passwordStep ?
: null}
;
}
const FILE_TYPE_META = {
image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'],
document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']
};
function GroupFiles({topic, tinode, onToast}) {
// 群文件搜索走服务端 getGroupFiles({query}),同样防抖。
const [queryInput, setQueryInput] = React.useState('');
const query = useDebouncedValue(queryInput, 300);
// Only the unfiltered (query === '') result is cached -- same rule GroupList already follows, so a
// plain revisit never shows a stale search result under a fresh empty query.
const [files, setFiles] = React.useState(() => (topic && readCachedData('group-files:' + topic)) || null);
const [menuOpen, setMenuOpen] = React.useState(false);
const [uploading, setUploading] = React.useState(null);
const [loadError, setLoadError] = React.useState(false);
// groupFilePayload 只给 uploader_id、不给昵称(im_http.go:6532-6559),原来直接把裸 user_id
// 显示在"来自"位置。跟 Muted 页一样,拉一次成员表交叉引用出可读的名字。
const [uploaderNames, setUploaderNames] = React.useState({});
const inputRefs = {file: React.useRef(null), image: React.useRef(null), video: React.useRef(null)};
const load = React.useCallback(() => {
if (!topic) return;
getGroupFiles(topic, {query}).then(data => {
const items = data.items || [];
if (!query) writeCachedData('group-files:' + topic, items);
setFiles(items);
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic, query]);
React.useEffect(() => { load(); }, [load]);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic).then(data => {
const map = {};
(data.items || []).forEach(m => { map[m.user_id] = m.group_nickname || m.display_name || m.user_id; });
setUploaderNames(map);
}).catch(() => {});
}, [topic]);
const usedBytes = (files || []).reduce((sum, f) => sum + (f.file_size || 0), 0);
const pick = kind => { setMenuOpen(false); inputRefs[kind].current && inputRefs[kind].current.click(); };
const onFile = e => {
const file = e.target.files && e.target.files[0];
e.target.value = '';
if (!file || !tinode) return;
setUploading({name: file.name, progress: 0});
uploadLargeFile(tinode, file, frac => setUploading(u => u ? {...u, progress: Math.round(frac * 100)} : u))
.then(({fileId}) => addGroupFile(topic, {fileId, fileName: file.name, mimeType: file.type, fileSize: file.size}))
.then(() => { setUploading(null); load(); })
.catch(err => { setUploading(null); onToast(err.message || '上传失败'); });
};
const remove = fileId => {
deleteGroupFile(topic, fileId).then(() => load()).catch(err => onToast(err.message || '删除失败'));
};
const gb = n => (n / (1024 ** 3)).toFixed(2);
return ;
}
// 名字叫 bytesToGB 却只输出到 MB —— 1.4GB 的文件会显示成 "1433.6 MB"。补上 GB 档。
function bytesToGB(bytes) {
if (!bytes) return '0 KB';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
// 标题"群昵称"是参考图 设置昵称(1).png 本身的用词(不是笔误)——虽然编辑的其实是 group.name
// 这个所有人都看得到的真实群名,跟 MyNicknameEditor("我在本群的昵称",个人在群内的显示名,
// 完全不同的字段)概念上容易混,但这是设计稿明确画出来的文案,不是代码猜的,不要改。
function GroupNameEditor({topic, onToast}) {
const [group, setGroup] = React.useState(null);
const [value, setValue] = React.useState('');
React.useEffect(() => { if (topic) getGroup(topic).then(g => { setGroup(g); setValue(g.name); }).catch(() => onToast('加载失败')); }, [topic]);
const save = () => {
if (!value.trim()) return;
updateGroup(topic, {name: value.trim(), version: group ? group.version : 0}).then(() => {
onToast('保存成功'); window.setTimeout(() => navigate('group-profile', topic), 600);
}).catch(err => onToast(err.message || '保存失败'));
};
return
setValue(e.target.value)} />{value.length}/11
;
}
function GroupDescriptionEditor({topic, onToast}) {
const [group, setGroup] = React.useState(null);
const [value, setValue] = React.useState('');
React.useEffect(() => { if (topic) getGroup(topic).then(g => { setGroup(g); setValue(g.description || ''); }).catch(() => onToast('加载失败')); }, [topic]);
const publish = () => {
updateGroup(topic, {description: value.trim(), version: group ? group.version : 0}).then(() => {
onToast('保存成功'); window.setTimeout(() => navigate('group-profile', topic), 600);
}).catch(err => onToast(err.message || '保存失败'));
};
return
{/* 上限跟后端硬限对齐(im_http.go:4667 超过 200 rune 直接 400 field=description)。
参考图标的是 0/225,但那个数字后端不接受——按 225 放行会让用户写满再被服务端打回。 */}
;
}
// "我在本群的昵称" 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('')} />>;
}