111 lines
6.9 KiB
TypeScript
111 lines
6.9 KiB
TypeScript
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
|
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
|
import type { AccountInvitation } from '@/core-bridge/account';
|
|
import { errorMessage } from '@/core-bridge/errors';
|
|
import type { SenderInfo } from '@/core-bridge/types';
|
|
import { useToast } from '@/store/toast';
|
|
import { useWorkspace } from '@/store/workspace';
|
|
import { formatSeen } from '@/lib/format';
|
|
import { OWNER_ROLE_ID, permissionsOf } from '@/lib/roles';
|
|
import { useAccount } from '@/store/account';
|
|
import { accountApi } from '@/core-bridge/account';
|
|
import { Button } from '@/ui/Button';
|
|
import { AvatarPreview } from '../common/AvatarPreview';
|
|
import { NameWithBadges } from './RoleBadges';
|
|
import styles from './MembersPane.module.css';
|
|
|
|
interface Props { owner: boolean; /** 空间主,或所在身份组被授予「移除成员」 */ canRemove: boolean; refreshKey: number; onInvite: () => void }
|
|
|
|
function Avatar({ person, name, onOpen }: { person?: SenderInfo; name: string; onOpen: () => void }) {
|
|
return person?.avatar ? <button type="button" className={styles.avatarButton} aria-label={`查看 ${name} 的头像原图`} onClick={onOpen}><img className={styles.avatar} src={person.avatar} alt="" /></button>
|
|
: <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(name)[0]?.toLocaleUpperCase() ?? '?'}</span>;
|
|
}
|
|
|
|
function Section({ title, count, children }: { title: string; count: number; children: ReactNode }) {
|
|
return count ? <section className={styles.section}><h3>{title} — {count}</h3><ul>{children}</ul></section> : null;
|
|
}
|
|
|
|
/** 右栏「成员」页:以用户为单位,按在线 / 离线 / 待接受分组。设备只是用户的属性(在线设备数由后端按账号统计) */
|
|
|
|
const PRESENCE_POLL_MS = 8000;
|
|
export function MembersPane({ owner, canRemove, refreshKey, onInvite }: Props) {
|
|
const channelId = useWorkspace((s) => s.channel?.id);
|
|
const maxMembers = useWorkspace((s) => s.channel?.maxMembers);
|
|
const people = useWorkspace((s) => s.people);
|
|
const roles = useWorkspace((s) => s.roles);
|
|
const creator = useWorkspace((s) => s.channel?.creatorAccount);
|
|
const self = useAccount((s) => s.username);
|
|
const listInvitations = useWorkspace((s) => s.listInvitations);
|
|
const revoke = useWorkspace((s) => s.revokeInvitation);
|
|
const [members, setMembers] = useState<ChannelMember[]>([]);
|
|
const [pending, setPending] = useState<AccountInvitation[]>([]);
|
|
const [error, setError] = useState('');
|
|
const [viewing, setViewing] = useState<string>();
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
const refresh = useCallback(async () => {
|
|
if (!channelId) return;
|
|
try {
|
|
setMembers(await channelAccessApi.members(channelId));
|
|
setPending(canRemove ? await listInvitations() : []);
|
|
setError('');
|
|
} catch { setError('成员清单读取失败'); }
|
|
}, [channelId, canRemove, listInvitations]);
|
|
useEffect(() => {
|
|
void refresh();
|
|
// 在线状态不走订阅流,成员页可见期间定时读取
|
|
const timer = window.setInterval(() => { void refresh(); }, PRESENCE_POLL_MS);
|
|
return () => window.clearInterval(timer);
|
|
}, [refresh, refreshKey]);
|
|
|
|
async function remove(username: string, invited: boolean) {
|
|
if (!invited && !window.confirm(`移除成员 ${username}?对方的设备会被断开,已读取的内容无法收回。`)) return;
|
|
setBusy(true);
|
|
try {
|
|
await revoke(username);
|
|
useToast.getState().push({ kind: 'ok', text: invited ? `已撤销对 ${username} 的邀请` : `已移除 ${username}` });
|
|
await refresh();
|
|
} catch (cause) { useToast.getState().push({ kind: 'error', text: `操作失败:${errorMessage(cause)}` }); }
|
|
finally { setBusy(false); }
|
|
}
|
|
|
|
const online = members.filter((member) => member.online);
|
|
const offline = members.filter((member) => !member.online);
|
|
// 所在的身份组名(空间主在固定的 Owner 组里);待接受的邀请还没有身份
|
|
const groupsOf = (username: string) => roles.filter((role) => (role.id === OWNER_ROLE_ID ? username === creator : role.members.includes(username))).map((role) => role.name);
|
|
const row = (username: string, detail: string | undefined, invited: boolean, removable: boolean, isOwner = false, isOnline = false) => {
|
|
const person = people[username];
|
|
const name = person?.displayName || username;
|
|
const groups = invited ? [] : groupsOf(username);
|
|
return <li className={`${styles.row} ${invited || (!isOnline && !isOwner) ? styles.dim : ''}`} key={`${invited ? 'i' : 'm'}:${username}`}>
|
|
<Avatar person={person} name={name} onOpen={() => setViewing(username)} />
|
|
<span className={styles.who}><strong><NameWithBadges username={username}>{name}</NameWithBadges>{groups.map((group) => <em key={group}>{group}</em>)}</strong>
|
|
{detail || name !== username ? <small>{[name !== username ? username : '', detail].filter(Boolean).join(' · ')}</small> : null}</span>
|
|
{isOnline ? <span className={styles.online} role="img" aria-label="在线" /> : null}
|
|
{removable ? <Button className={styles.remove} variant="ghost" disabled={busy} onClick={() => { void remove(username, invited); }}>{invited ? '撤销' : '移除'}</Button> : null}
|
|
</li>;
|
|
};
|
|
// 被授权的成员不能移除自己、空间主,也不能移除同样有移除权限的人
|
|
const removable = (item: ChannelMember) => item.role !== 'owner' && (owner || (canRemove && item.username !== self && !permissionsOf(roles, item.username, false).has('members.remove')));
|
|
// 在线的不用写时间;离线的写最后一次活跃(没登录过设备的老账号没有记录)
|
|
const member = (item: ChannelMember) => row(item.username,
|
|
!item.online && item.last_seen > 0 ? `最后在线 ${formatSeen(item.last_seen)}` : undefined,
|
|
false, removable(item), item.role === 'owner', item.online);
|
|
|
|
const viewed = viewing ? people[viewing] : undefined;
|
|
return <div className={styles.pane}>
|
|
{viewing && viewed?.avatar ? <AvatarPreview title={`${viewed.displayName || viewing} 的头像`} thumb={viewed.avatar} load={() => accountApi.avatar(viewing, 0, true)} onClose={() => setViewing(undefined)} /> : null}
|
|
<div className={styles.head}>
|
|
<span>成员 {members.length}{maxMembers ? `/${maxMembers}` : ''}</span>
|
|
{owner ? <Button variant="primary" icon="plus" onClick={onInvite}>邀请成员</Button> : null}
|
|
</div>
|
|
<div className={styles.scroll}>
|
|
<Section title="在线" count={online.length}>{online.map(member)}</Section>
|
|
<Section title="离线" count={offline.length}>{offline.map(member)}</Section>
|
|
<Section title="待接受" count={pending.length}>{pending.map((invitation) => row(invitation.username, undefined, true, owner))}</Section>
|
|
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
|
{!owner ? <p className={styles.note}>{canRemove ? '你可以移除成员;邀请成员只有空间主可以。' : '只有空间主可以邀请或移除成员。'}</p> : null}
|
|
</div>
|
|
</div>;
|
|
}
|