diff --git a/src/views/group-management-view.jsx b/src/views/group-management-view.jsx index 01616c2c..f6557e76 100644 --- a/src/views/group-management-view.jsx +++ b/src/views/group-management-view.jsx @@ -10,7 +10,7 @@ import { updateCurrentGroupAnnouncement, readGroupAnnouncement, getGroupGovernance, updateGroupGovernance, addGroupAdmins, deleteGroupAdmin, muteGroupMembers, getGroupMutes, unmuteGroupMember, transferGroupOwner, getGroupFiles, addGroupFile, deleteGroupFile, uploadLargeFile, getFileUrl, getUserProfile, - getGroupMemberProfile, fetchAllPages + getGroupMemberProfile, fetchAllPages, getFriends, inviteGroupMembers } from '../lib/im-api.js'; import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-cached-data.js'; @@ -67,7 +67,7 @@ export const GROUP_MANAGEMENT_VIEWS = [ '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' + 'group-file-upload', 'group-invite-friends' ]; // Same first-pinyin-letter grouping as contact-management-view.jsx's alphaInitial -- duplicated rather @@ -467,7 +467,12 @@ function GroupSettings({topic, myUserId, tinode, onToast}) { {members.slice(0, 9).map(item => )} - @@ -848,6 +853,72 @@ function MemberPicker({type, topic, onToast}) { ; } +// docs/images/内容沟通对话3-开启进群验证与拉人状态.png:"你邀请时间旅行者、李惠惠加入群聊" 这条系统 +//事件证明产品设计里确实有一条"从我的好友里挑人直接邀请进群"的路径,且 `POST /groups/{topic}/invitations` +// (im-api.js 的 inviteGroupMembers) 早就封装好、服务端也确认真实可用——但全仓库检索不到任何调用点, +// GroupSettings 成员格的"添加"格子原来只弹一句"请通过转发/分享群二维码邀请成员",把这条本该走的路 +// 堵死成了唯一路径(二维码分享走的是另一条"申请入群待审批"的路,语义并不一样,不能算是同一功能的替代)。 +// 复用 MemberPicker 同款字母分组多选布局,数据源换成好友列表并按当前群成员名单过滤掉已在群里的人。 +function InviteFriends({topic, onToast}) { + const [friends, setFriends] = React.useState(null); + const [memberIds, setMemberIds] = 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; + fetchAllPages(cursor => getFriends('', cursor)).then(setFriends).catch(() => { onToast('加载失败'); setLoadError(true); }); + fetchAllPages(cursor => getGroupMembers(topic, {cursor})).then(items => setMemberIds(new Set(items.map(m => m.user_id)))) + // 成员表拉取失败不应该拦掉整个邀请流程——退化成"不过滤",最坏情况后端自己会在 + // 响应里把已在群里的人标成 already_member(治理开关那份文档已经实测过这个字段)。 + .catch(() => setMemberIds(new Set())); + }, [topic]); + const eligible = (friends || []).filter(f => !memberIds || !memberIds.has(f.user_id)); + const visible = eligible.filter(m => (m.remark || m.display_name || '').toLowerCase().includes(query.toLowerCase())); + const groups = groupBy(visible, m => alphaInitial(m.remark || 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); + inviteGroupMembers(topic, selected).then(result => { + const items = (result && result.items) || []; + // 已实测过的两种真实 status:新邀请是 "pending"(对方会在跟我的会话里收到"立即加入"卡片), + // 治理开关关闭邀请权限那份文档里实测过 "already_member"(对方已经在群里,直接被服务端忽略)。 + // 未识别的 status 一律计入"失败",宁可少报成功也不要谎报。 + const already = items.filter(it => it.status === 'already_member').length; + const sent = items.filter(it => it.status === 'pending').length; + const failed = Math.max(selected.length - sent - already, 0); + const parts = []; + if (sent) parts.push(`已发送 ${sent} 条邀请`); + if (already) parts.push(`${already} 人已在群内`); + if (failed) parts.push(`${failed} 人邀请失败`); + setToast(parts.join(',') || '邀请已发送'); + window.setTimeout(() => navigate('group-settings', topic), 1500); + }).catch(err => onToast(err.message || '邀请失败')).finally(() => setBusy(false)); + }; + return
+
+ +
+ {friends === null || memberIds === null ?

{loadError ? '好友列表加载失败' : '加载中…'}

: + groups.length === 0 ?

{friends.length ? '好友都已在群里了' : '暂无可邀请的好友'}

: + groups.map(([letter, list]) => +
{letter}
+ {list.map(item => )} +
)} +
+ 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 -- @@ -1184,6 +1255,7 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode, 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-invite-friends') content = ; else if (view === 'group-muted') content = ; else if (['group-owner-select', 'group-owner-confirm'].includes(view)) content = ; else content = ; diff --git a/src/views/tinode-web.jsx b/src/views/tinode-web.jsx index 643d5680..72e9a001 100644 --- a/src/views/tinode-web.jsx +++ b/src/views/tinode-web.jsx @@ -127,6 +127,7 @@ const SIDE_PANEL_VIEWS = ['acc_general','archive','blocked','contacts','cred','c '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', + 'group-invite-friends', 'favorites','favorites-mixed','favorites-files','favorites-text','favorites-voice', 'favorites-links','favorites-image','favorites-video','favorites-select','favorite-share','file-download','file-downloading', 'file-downloaded','history-search','history-files','history-links','history-images','history-videos',