feat(web): open spaces as a plain chat on phones with a side drawer

Spaces, channels, files, members and the account move into a left drawer,
the top bar keeps only the essentials, and touch devices get a tap menu
instead of always-visible delete buttons. Also fixes the history checkboxes
in the channel settings sheet.
This commit is contained in:
justme committed 2026-10-16 05:34:38 +08:00
1 parent 06fe077ef1
commit 1db3795d6a
16 files changed
+383 -56

No files matched your search

+36 -7
View File
@@ -1,4 +1,4 @@
import { lazy, Suspense, useCallback, useState, type CSSProperties, type DragEvent } from 'react';
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';
@@ -10,7 +10,11 @@ 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 { ThreadSidebar } from './ThreadSidebar';
import { ChatPane } from './ChatPane';
import { FileExplorer } from './FileExplorer';
@@ -66,6 +70,8 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
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 }>();
@@ -110,11 +116,19 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
}
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 navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
{desktop ? <Notepad /> : null}</>;
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory}
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread}
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />;
const navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />{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} />
@@ -129,13 +143,28 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
{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} 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}>
<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'); }} />
{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></>
: <>{rail}<aside className={styles.navigation}>{navigation}</aside><nav className={styles.tabs} aria-label="频道视图"><button type="button" aria-pressed={mobileView === 'chat'} onClick={() => setMobileView('chat')}>聊天</button><button type="button" aria-pressed={mobileView === 'files'} onClick={() => setMobileView('files')}>文件</button><button type="button" aria-pressed={mobileView === 'members'} onClick={() => setMobileView('members')}>成员</button></nav><main className={styles.mobileContent}>{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}</main></>}
: <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) }}
onSettings={() => setSettingsOpen(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}