184 lines
15 KiB
TypeScript
184 lines
15 KiB
TypeScript
import { lazy, Suspense, useCallback, useEffect, useState, type CSSProperties, type DragEvent } from 'react';
|
|
import { useShallow } from 'zustand/react/shallow';
|
|
import { channelPath, navigate, sharePath } from '@/app/navigation';
|
|
import { useDownload } from '@/features/item/download';
|
|
import { matchesFilter, isFiltering, NO_FILTER } from '@/lib/filter';
|
|
import { useGlobalPaste } from '@/lib/useGlobalPaste';
|
|
import { useMediaQuery } from '@/lib/useMediaQuery';
|
|
import { useWorkspace } from '@/store/workspace';
|
|
import type { FileUploadOptions } from '@/core-bridge/types';
|
|
import type { ClipboardPayload } from '@/lib/clipboard';
|
|
import { Sheet } from '@/ui/Sheet';
|
|
import { Button } from '@/ui/Button';
|
|
import { Drawer } from '@/ui/Drawer';
|
|
import { Icon, type IconName } from '@/ui/Icon';
|
|
import { SpaceHeader, SpaceRail } from './SpaceRail';
|
|
import { MobileDrawer } from './MobileDrawer';
|
|
import { MobileTopBar } from './MobileTopBar';
|
|
import { useAccount } from '@/store/account';
|
|
import { permissionsOf } from '@/lib/roles';
|
|
import { ThreadSidebar } from './ThreadSidebar';
|
|
import { ChatPane } from './ChatPane';
|
|
import { FileExplorer } from './FileExplorer';
|
|
import { FilePaneResize } from './FilePaneResize';
|
|
import { InviteSheet } from './InviteSheet';
|
|
import { MembersPane } from './MembersPane';
|
|
import { PaneTabs, type SideTab } from './PaneTabs';
|
|
import { TopBar } from './TopBar';
|
|
import { FilterBar } from './FilterBar';
|
|
import { Notepad } from './Notepad';
|
|
import { SharePanel } from './SharePanel';
|
|
import { ChannelSettingsSheet } from './ChannelSettingsSheet';
|
|
import { UploadOptionsSheet } from './UploadOptionsSheet';
|
|
import styles from './Workspace.module.css';
|
|
import { useServerSettings } from '@/store/server';
|
|
|
|
const PreviewSheet = lazy(() => import('@/features/item/PreviewSheet'));
|
|
const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet })));
|
|
|
|
export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) {
|
|
const desktop = useMediaQuery('(width >= 900px) and (height >= 500px)');
|
|
const { info: serverInfo } = useServerSettings();
|
|
const channel = useWorkspace((s) => s.channel);
|
|
const threads = useWorkspace((s) => s.threads);
|
|
const groups = useWorkspace((s) => s.groups);
|
|
const setChannelHistory = useWorkspace(s => s.setChannelHistory);
|
|
const createGroup = useWorkspace((s) => s.createGroup);
|
|
const renameGroup = useWorkspace((s) => s.renameGroup);
|
|
const deleteGroup = useWorkspace((s) => s.deleteGroup);
|
|
const groupThread = useWorkspace((s) => s.groupThread);
|
|
const folders = useWorkspace((s) => s.folders);
|
|
const sendText = useWorkspace((s) => s.sendText);
|
|
const sendFiles = useWorkspace((s) => s.sendFiles);
|
|
const createThread = useWorkspace((s) => s.createThread);
|
|
const renameThread = useWorkspace((s) => s.renameThread);
|
|
const createFolder = useWorkspace((s) => s.createFolder);
|
|
const renameFolder = useWorkspace((s) => s.renameFolder);
|
|
const moveItems = useWorkspace((s) => s.moveItems);
|
|
const remove = useWorkspace((s) => s.remove);
|
|
const updateSettings = useWorkspace((s) => s.updateChannelSettings);
|
|
const error = useWorkspace((s) => s.error);
|
|
const download = useDownload();
|
|
const [threadId, setThreadId] = useState<string>();
|
|
const [folderId, setFolderId] = useState<string>();
|
|
const [selectedId, setSelectedId] = useState<string>();
|
|
const [previewId, setPreviewId] = useState<string>();
|
|
const [checked, setChecked] = useState<ReadonlySet<string>>(new Set());
|
|
const [filter, setFilter] = useState(NO_FILTER);
|
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
const [panelOpen, setPanelOpen] = useState(false);
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [scanOpen, setScanOpen] = useState(false);
|
|
const [dragging, setDragging] = useState(false);
|
|
const [mobileView, setMobileView] = useState<'chat' | 'files' | 'members'>('chat');
|
|
const [sideTab, setSideTab] = useState<SideTab>('files');
|
|
const [inviteOpen, setInviteOpen] = useState(false);
|
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
const [memberRevision, setMemberRevision] = useState(0);
|
|
const [fileWidth, setFileWidth] = useState<number>();
|
|
const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>();
|
|
const activeThread = threads.find((thread) => thread.id === threadId) ?? threads.find((thread) => thread.name === '常规') ?? threads[threads.length - 1];
|
|
const activeThreadId = activeThread?.id;
|
|
const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId);
|
|
const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined;
|
|
const enabled = Boolean(activeThreadId && channel?.canUpload);
|
|
const owner = channel?.role === 'owner';
|
|
const roles = useWorkspace((s) => s.roles);
|
|
const self = useAccount((s) => s.username);
|
|
const perms = permissionsOf(roles, self ?? undefined, owner);
|
|
const files = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
|
const item = s.byId[id]; return item?.kind === 'file' && !item.metadataHidden && item.threadId === activeThreadId
|
|
&& (item.folderId ?? undefined) === activeFolderId && matchesFilter(item, filter);
|
|
})));
|
|
const messages = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
|
const item = s.byId[id]; return Boolean(item && item.threadId === activeThreadId && !(item.kind === 'file' && item.metadataHidden));
|
|
})));
|
|
const restricted = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
|
const item = s.byId[id]; return item?.kind === 'file' && item.metadataHidden;
|
|
})));
|
|
|
|
const requestFiles = useCallback((incoming: File[], destination?: { folderId?: string }) => {
|
|
const target = destination ? destination.folderId : activeFolderId;
|
|
if (incoming.length && activeThreadId && enabled) setUpload({ files: incoming, threadId: activeThreadId, ...(target ? { folderId: target } : {}) });
|
|
}, [activeThreadId, activeFolderId, enabled]);
|
|
const paste = useCallback((payload: ClipboardPayload) => {
|
|
if (payload.files.length) requestFiles(payload.files);
|
|
else if (payload.text && activeThreadId && enabled) void sendText(payload.text, activeThreadId);
|
|
}, [requestFiles, activeThreadId, sendText, enabled]);
|
|
useGlobalPaste(paste);
|
|
function selectThread(id: string) { setThreadId(id); setFolderId(undefined); setSelectedId(undefined); setChecked(new Set()); }
|
|
function selectFolder(id?: string) { setFolderId(id); setSelectedId(undefined); setChecked(new Set()); }
|
|
function toggle(id: string) { setChecked((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
|
function confirmUpload(options: FileUploadOptions) {
|
|
const target = upload; setUpload(undefined);
|
|
if (target) void sendFiles(target.files, options, target.threadId, target.folderId);
|
|
}
|
|
async function downloadItems(ids: string[]) { for (const id of ids) { const item = useWorkspace.getState().byId[id]; if (item) await download(item); } }
|
|
async function deleteFiles(ids: string[]) {
|
|
if (!window.confirm(ids.length === 1 ? '删除这项内容?' : `删除所选的 ${ids.length} 个文件?`)) return;
|
|
for (const id of ids) await remove(id);
|
|
setChecked(new Set()); setSelectedId(undefined); setPreviewId(undefined);
|
|
}
|
|
function drop(event: DragEvent) { event.preventDefault(); setDragging(false); requestFiles([...event.dataTransfer.files]); }
|
|
function deleteItem(id: string) { void deleteFiles([id]).catch((cause: unknown) => useWorkspace.setState({ error: cause instanceof Error ? cause.message : '删除失败' })); }
|
|
// 手机聊天页的账号入口在侧边栏,全局浮窗据此让位
|
|
useEffect(() => {
|
|
if (desktop) return;
|
|
document.documentElement.dataset.chatShell = '';
|
|
return () => { delete document.documentElement.dataset.chatShell; };
|
|
}, [desktop]);
|
|
const channelId = channel?.id;
|
|
useEffect(() => { setDrawerOpen(false); }, [channelId]);
|
|
const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
|
|
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure) || perms.has('structure.manage')} canCreate={owner} owner={owner} onHistory={setChannelHistory}
|
|
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread}
|
|
onCreateGroup={createGroup} onRenameGroup={renameGroup} onDeleteGroup={deleteGroup} onGroupThread={groupThread} />;
|
|
const navigation = <><SpaceHeader />{threadSidebar}{desktop ? <Notepad /> : null}</>;
|
|
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
|
|
const explorer = <>
|
|
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
|
<FileExplorer key={activeThreadId} scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'}
|
|
fileBytes={activeThread?.fileBytes ?? 0} maxFileBytes={serverInfo?.limits.max_workspace_channel_bytes}
|
|
folders={activeFolders} folderId={activeFolderId} ids={files}
|
|
selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} structureEnabled={Boolean(activeThreadId && channel?.canManageStructure)} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked}
|
|
onFiles={(incoming, target) => requestFiles(incoming, { folderId: target })}
|
|
onCreateFolder={(name) => activeThreadId ? createFolder(activeThreadId, name, activeFolderId) : Promise.reject(new Error('请先选择频道'))}
|
|
onRenameFolder={(id, name) => activeThreadId ? renameFolder(id, activeThreadId, name) : Promise.reject(new Error('请先选择频道'))}
|
|
onDownloadItems={downloadItems} onDeleteItems={deleteFiles} onMoveItems={async (ids, target) => { if (activeThreadId) { await moveItems(ids, activeThreadId, target); setChecked(new Set()); setSelectedId(undefined); } }} />
|
|
{restricted.length ? <div className={styles.pendingCatalog}><span>等待同步文件目录</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
|
|
</>;
|
|
const members = <MembersPane owner={owner} canRemove={perms.has('members.remove')} refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />;
|
|
function goMobile(view: 'chat' | 'files' | 'members') { setMobileView(view); setDrawerOpen(false); }
|
|
const moreRows: { icon: IconName; label: string; run: () => void }[] = [
|
|
{ icon: 'settings', label: '空间设置', run: () => setSettingsOpen(true) },
|
|
{ icon: 'share', label: '分享', run: () => setPanelOpen(true) },
|
|
...(owner ? [{ icon: 'plus' as const, label: '邀请成员', run: () => setInviteOpen(true) }] : []),
|
|
];
|
|
const mobileTitle = mobileView === 'files' ? '文件' : mobileView === 'members' ? '成员' : `# ${activeThread?.name ?? '选择频道'}`;
|
|
function openMembers() { if (desktop) setSideTab('members'); else setMobileView('members'); }
|
|
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
|
|
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
|
|
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}>
|
|
{desktop ? <TopBar onBack={() => navigate('/')} onOpenMembers={openMembers} onInvite={owner ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} /> : <MobileTopBar view={mobileView} title={mobileTitle} searchOpen={searchOpen} filtering={isFiltering(filter)} onMenu={() => setDrawerOpen(true)} onBack={() => setMobileView('chat')}
|
|
onToggleSearch={() => { setSearchOpen((value) => !value); setMobileView('files'); }} onMore={() => setMoreOpen(true)} />}
|
|
{desktop ? <><aside className={styles.rail}>{rail}</aside><aside className={styles.navigation} data-space-nav>{navigation}</aside><main className={styles.center}>{chat}</main><FilePaneResize width={fileWidth} onWidth={setFileWidth} /><aside className={styles.files} data-file-pane><PaneTabs tab={sideTab} memberCount={channel?.membersUsed} onTab={setSideTab} />{sideTab === 'files' ? explorer : members}</aside></>
|
|
: <main className={styles.mobileContent}>{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}</main>}
|
|
{/* 弹层打开时抽屉留在下面:同时关抽屉(history.back)会把刚压入的弹层历史一并弹掉 */}
|
|
{!desktop && drawerOpen ? <Drawer label="侧边栏" onClose={() => setDrawerOpen(false)}>
|
|
<MobileDrawer rail={{ id: channel?.id, onCreate: onCreateChannel, onJoin: () => setScanOpen(true) }}
|
|
threads={threadSidebar} onFiles={() => goMobile('files')} onMembers={() => goMobile('members')}
|
|
onInvite={owner ? () => setInviteOpen(true) : undefined} onShare={() => setPanelOpen(true)} onHome={() => navigate('/')} />
|
|
</Drawer> : null}
|
|
{!desktop && moreOpen ? <Sheet title="更多" onClose={() => setMoreOpen(false)}>{moreRows.map((row) => <Button key={row.label} variant="ghost" onClick={() => { setMoreOpen(false); row.run(); }}><Icon name={row.icon} size={18} />{row.label}</Button>)}</Sheet> : null}
|
|
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
|
{inviteOpen ? <InviteSheet onClose={() => setInviteOpen(false)} onInvited={() => setMemberRevision((value) => value + 1)} /> : null}
|
|
{panelOpen ? <Sheet title="分享" onClose={() => setPanelOpen(false)}><SharePanel /></Sheet> : null}
|
|
{settingsOpen && channel ? <ChannelSettingsSheet channel={channel} onClose={() => setSettingsOpen(false)} onSave={updateSettings} /> : null}
|
|
{upload && channel?.maxDownloads !== undefined ? <UploadOptionsSheet files={upload.files} encrypted={channel?.mode === 'enc'} defaultMaxDownloads={channel.maxDownloads} onClose={() => setUpload(undefined)} onUpload={confirmUpload} /> : null}
|
|
<Suspense fallback={null}>{previewId ? <PreviewSheet id={previewId} onClose={() => setPreviewId(undefined)} onDelete={deleteItem} /> : null}
|
|
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onChannel={(resource) => navigate(channelPath(resource.id))} onShare={(resource) => navigate(sharePath(resource.id))} /> : null}</Suspense>
|
|
{dragging ? <div className={styles.drop}>松开以上传到当前频道</div> : null}
|
|
</div>;
|
|
}
|