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(); const [folderId, setFolderId] = useState(); const [selectedId, setSelectedId] = useState(); const [previewId, setPreviewId] = useState(); const [checked, setChecked] = useState>(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('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(); 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 = setScanOpen(true)} />; const threadSidebar = { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread} onCreateGroup={createGroup} onRenameGroup={renameGroup} onDeleteGroup={deleteGroup} onGroupThread={groupThread} />; const navigation = <>{threadSidebar}{desktop ? : null}; const chat = sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />; const explorer = <> 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 ?
等待同步文件目录{restricted.map((id) => )}
: null} ; const members = 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
setDragging(false)} onDragEndCapture={() => setDragging(false)} onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}> {desktop ? 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'); }} /> : setDrawerOpen(true)} onBack={() => setMobileView('chat')} onToggleSearch={() => { setSearchOpen((value) => !value); setMobileView('files'); }} onMore={() => setMoreOpen(true)} />} {desktop ? <>
{chat}
:
{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}
} {/* 弹层打开时抽屉留在下面:同时关抽屉(history.back)会把刚压入的弹层历史一并弹掉 */} {!desktop && drawerOpen ? setDrawerOpen(false)}> setScanOpen(true) }} threads={threadSidebar} onFiles={() => goMobile('files')} onMembers={() => goMobile('members')} onInvite={owner ? () => setInviteOpen(true) : undefined} onShare={() => setPanelOpen(true)} onHome={() => navigate('/')} /> : null} {!desktop && moreOpen ? setMoreOpen(false)}>{moreRows.map((row) => )} : null} {error ?
{error}
: null} {inviteOpen ? setInviteOpen(false)} onInvited={() => setMemberRevision((value) => value + 1)} /> : null} {panelOpen ? setPanelOpen(false)}> : null} {settingsOpen && channel ? setSettingsOpen(false)} onSave={updateSettings} /> : null} {upload && channel?.maxDownloads !== undefined ? setUpload(undefined)} onUpload={confirmUpload} /> : null} {previewId ? setPreviewId(undefined)} onDelete={deleteItem} /> : null} {scanOpen ? setScanOpen(false)} onChannel={(resource) => navigate(channelPath(resource.id))} onShare={(resource) => navigate(sharePath(resource.id))} /> : null} {dragging ?
松开以上传到当前频道
: null}
; }