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, readGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins, deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner, getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile, getGroupMemberProfile, fetchAllPages } 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-members', '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 ; } // onBack:少数页面的返回目标不是"另一个侧栏视图",而是要关掉侧栏回到某个会话(需要写 path[1], // navigate 辅助函数只会写 query 参数),这类情况传一个自己的回调进来。 function Header({title, back = 'group-list', backTopic, onBack, right, onRight}) { return

{title}

{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 ; } function Toggle({checked, onChange, disabled}) { return ; } 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
setCreating(true)} />
{current === null ?

{loadErrors[category] ? '群列表加载失败' : '加载中…'}

: current.length ? current.map(group => ) :
groups这里还没有群聊

{category === 'owned' ? '创建一个群聊,邀请成员一起沟通' : '加入或被设置为管理员后会显示在这里'}

}
{creating ?

创建群聊

{/* 参考图 设置昵称(1).png 明确把这同一个字段(group.name)的上限画成 11 字(0/11)—— 这里原来是 30,跟编辑页的 11 对不上:建群时能起最多 30 字的名字,之后进编辑页 却连打字都打不过 11 位,改不回原来的长度。后端本身没有强制 11(今天实测 PATCH 17 字 成功),11 纯粹是这一份参考图给的前端约束,创建页应该和它保持一致,而不是反过来放宽 编辑页——市面上没有任何参考图支持 30 这个数字。 */}

创建后可继续邀请成员和设置群资料。

: null}
; } // 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

产品研发群

18人
; } function SystemEvent({children}) { return

{children}

; } function GroupChat({mode, onToast}) { const [joined, setJoined] = React.useState(false); return
{['group-new-request', 'group-summary'].includes(mode) ? : null}{['group-announcement', 'group-summary'].includes(mode) ? : null}

今天 10:24

{mode === 'group-invite' ?
产品研发群安妮邀请你加入群聊

一起参与新版产品的设计与研发讨论

: <>安妮邀请栗子来了加入群聊管理员通过了王珊珊的进群申请
安妮 管理员

下午评审前请大家把问题同步到文档里。

陈晨 群主

收到,我会在两点前整理一版。

}
; } 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
{items === null ?

{loadError ? '申请列表加载失败' : '加载中…'}

: items.length ? items.map(item =>
{describe(item)}{shortDate(item.created_at)}
{item.status === 'pending' ? : {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)); }; // 6.9 要求"记录已读状态",但这个接口(POST .../read)之前完全没有调用点——每次打开公告详情页 // (无论从群设置点进来还是从聊天顶部横幅点进来)都视为"已读",用ref去重避免同一条公告在同一次 // 页面停留内重复上报。静默失败:已读打点不是这个页面的主要功能,不应该在失败时打断用户。 const readReportedFor = React.useRef(null); React.useEffect(() => { const id = data && data.announcement_id; if (!id || !topic || readReportedFor.current === id) return; readReportedFor.current = id; readGroupAnnouncement(topic, id).catch(() => {}); }, [topic, data]); if (!data) return

{loadError ? '群公告加载失败' : '加载中…'}

; return
{canEdit ?