diff --git a/web/src/features/channel/ChatMessage.tsx b/web/src/features/channel/ChatMessage.tsx index 6d4d390..b8526ee 100644 --- a/web/src/features/channel/ChatMessage.tsx +++ b/web/src/features/channel/ChatMessage.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, type MouseEvent } from 'react'; import { isLocalId, useWorkspace } from '@/store/workspace'; import { useAccount } from '@/store/account'; import { useDownload } from '@/features/item/download'; @@ -7,6 +7,7 @@ import { senderLabel } from '@/lib/sender'; import { formatSize, formatTime, formatTransferProgress } from '@/lib/format'; import { Icon } from '@/ui/Icon'; import { Button } from '@/ui/Button'; +import { ContextMenu, type MenuAction } from '@/ui/ContextMenu'; import styles from './ChatPane.module.css'; interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void } @@ -18,6 +19,7 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props const username = useAccount((s) => s.username); const download = useDownload(); const [downloading, setDownloading] = useState(false); + const [menu, setMenu] = useState<{ x: number; y: number }>(); if (!item) return null; const sender = item.author; const name = senderLabel(item); @@ -31,7 +33,17 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props if (!item || downloading) return; setDownloading(true); try { await download(item); } finally { setDownloading(false); } } - return
{ void navigator.clipboard?.writeText(item.text).catch(() => undefined); } }] : []), + ...(owner || self ? [{ label: '删除', icon: 'trash' as const, action: () => onDelete(id) }] : []), + ]; + function tap(event: MouseEvent) { + if (!matchMedia('(hover: none)').matches || !actions.length || sending) return; + if ((event.target as HTMLElement).closest('button, a, input, progress') || window.getSelection()?.toString()) return; + setMenu({ x: event.clientX, y: event.clientY }); + } + return
{compact ? : sender?.avatar ? {`${name} @@ -52,5 +64,6 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props : item.state.kind === 'incomplete' ? 发送未完成,等待续传。 : null} {owner || self ?
; } diff --git a/web/src/features/channel/ChatPane.module.css b/web/src/features/channel/ChatPane.module.css index 1731281..c177019 100644 --- a/web/src/features/channel/ChatPane.module.css +++ b/web/src/features/channel/ChatPane.module.css @@ -278,6 +278,11 @@ } @media (width <= 899px) { + /* 手机顶栏已显示频道名 */ + .head { + display: none; + } + .message { grid-template-columns: 32px minmax(0, 1fr); column-gap: var(--space-2); @@ -298,8 +303,9 @@ } } +/* 触屏没有悬停:隐藏常驻按钮,点一下消息弹出菜单 */ @media (hover: none) { .deleteAction { - opacity: 1; + display: none; } } diff --git a/web/src/features/channel/MobileDrawer.module.css b/web/src/features/channel/MobileDrawer.module.css new file mode 100644 index 0000000..0158237 --- /dev/null +++ b/web/src/features/channel/MobileDrawer.module.css @@ -0,0 +1,61 @@ +.scroll { + display: flex; + flex: 1; + flex-direction: column; + min-height: 0; + overflow-y: auto; +} + +.section { + display: flex; + flex-direction: column; + gap: var(--space-1); + padding: var(--space-3); + border-top: 1px solid var(--line); +} + +.section h2 { + margin: 0 0 var(--space-1); + padding-inline: var(--space-2); + color: var(--text-dim); + font-size: 12px; + font-weight: 600; +} + +.row { + display: flex; + align-items: center; + gap: var(--space-3); + min-height: 44px; + padding: 0 var(--space-2); + border: 0; + border-radius: var(--radius-md); + background: transparent; + color: var(--text); + font: inherit; + font-size: 14px; + text-align: left; + cursor: pointer; +} + +.row:hover { + background: var(--accent-soft); +} + +.row span { + flex: 1; +} + +.row small { + color: var(--text-dim); + font-size: 12px; +} + +.footer { + display: flex; + align-items: center; + gap: var(--space-2); + padding: var(--space-2); + padding-bottom: max(var(--space-2), env(safe-area-inset-bottom)); + border-top: 1px solid var(--line); +} diff --git a/web/src/features/channel/MobileDrawer.tsx b/web/src/features/channel/MobileDrawer.tsx new file mode 100644 index 0000000..1dfe79f --- /dev/null +++ b/web/src/features/channel/MobileDrawer.tsx @@ -0,0 +1,48 @@ +import { useChannels } from '@/store/channels'; +import { useWorkspace } from '@/store/workspace'; +import { AccountDock } from '@/features/home/AccountDock'; +import { Icon, type IconName } from '@/ui/Icon'; +import { ThemeMenu } from '@/ui/ThemeMenu'; +import { SpaceHeader, SpaceRail } from './SpaceRail'; +import styles from './MobileDrawer.module.css'; + +interface Props { + rail: { id?: string; onCreate: () => void; onJoin: () => void }; + onSettings: () => void; + /** 频道列表(ThreadSidebar),由 Workspace 组装 */ + threads: React.ReactNode; + onFiles: () => void; + onMembers: () => void; + onInvite?: () => void; + onShare: () => void; + onHome: () => void; +} + +function Row({ icon, label, count, onClick }: { icon: IconName; label: string; count?: string; onClick: () => void }) { + return ; +} + +/** 手机侧边栏:空间、频道、本频道的文件与成员;账号与外观在底部 */ +export function MobileDrawer({ rail, onSettings, threads, onFiles, onMembers, onInvite, onShare, onHome }: Props) { + const used = useWorkspace((s) => s.channel?.membersUsed); + const max = useWorkspace((s) => s.channel?.maxMembers); + const spaces = useChannels((s) => s.list.length); + return <> + + +
+ {threads} + +
+
+ ; +} diff --git a/web/src/features/channel/MobileTopBar.module.css b/web/src/features/channel/MobileTopBar.module.css new file mode 100644 index 0000000..46c090f --- /dev/null +++ b/web/src/features/channel/MobileTopBar.module.css @@ -0,0 +1,62 @@ +.bar { + position: sticky; + top: 0; + z-index: 10; + display: flex; + flex-shrink: 0; + align-items: center; + gap: var(--space-1); + min-height: var(--bar-h); + padding: 0 var(--space-2); + padding-top: env(safe-area-inset-top); + border-bottom: 1px solid var(--line); + background: var(--glass); + /* stylelint-disable-next-line property-no-vendor-prefix */ + -webkit-backdrop-filter: var(--blur-bar); + backdrop-filter: var(--blur-bar); +} + +.raw { + border-bottom-color: var(--warn); +} + +.title { + flex: 1; + min-width: 0; + margin: 0; + overflow: hidden; + font-size: 16px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lock { + display: inline-flex; + color: var(--ok); +} + +.warn { + padding: 1px var(--space-2); + border-radius: var(--radius-sm); + background: var(--warn-soft); + color: var(--warn); + font-size: 12px; +} + +.conn { + font-size: 12px; +} + +.reconnecting { + color: var(--busy); +} + +.offline { + color: var(--danger); +} + +.active { + background: var(--accent-soft); + color: var(--busy); +} diff --git a/web/src/features/channel/MobileTopBar.tsx b/web/src/features/channel/MobileTopBar.tsx new file mode 100644 index 0000000..19583d6 --- /dev/null +++ b/web/src/features/channel/MobileTopBar.tsx @@ -0,0 +1,30 @@ +import { useWorkspace } from '@/store/workspace'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import styles from './MobileTopBar.module.css'; + +interface Props { + /** 聊天页是 ≡(开侧边栏);文件、成员页是 ‹(回聊天) */ + view: 'chat' | 'files' | 'members'; + title: string; + searchOpen: boolean; + filtering: boolean; + onMenu: () => void; + onBack: () => void; + onToggleSearch: () => void; + onMore: () => void; +} + +/** 手机顶栏:只放最必要的几项,其余收进「更多」 */ +export function MobileTopBar({ view, title, searchOpen, filtering, onMenu, onBack, onToggleSearch, onMore }: Props) { + const connection = useWorkspace((s) => s.connection); + const raw = useWorkspace((s) => s.channel?.mode === 'raw'); + return
+ {view === 'chat' ?
; +} diff --git a/web/src/features/channel/SpaceRail.module.css b/web/src/features/channel/SpaceRail.module.css index ba70d06..e51bec7 100644 --- a/web/src/features/channel/SpaceRail.module.css +++ b/web/src/features/channel/SpaceRail.module.css @@ -88,11 +88,6 @@ } @media (width <= 899px) { - /* 手机顶栏已有空间名与设置入口 */ - .header { - display: none; - } - .rail { flex-direction: row; justify-content: flex-start; diff --git a/web/src/features/channel/ThreadSidebar.module.css b/web/src/features/channel/ThreadSidebar.module.css index 0678498..ae8e533 100644 --- a/web/src/features/channel/ThreadSidebar.module.css +++ b/web/src/features/channel/ThreadSidebar.module.css @@ -318,16 +318,6 @@ opacity: 1; } - .threads { - flex-direction: row; - overflow-x: auto; - } - - .thread { - flex: 0 0 auto; - max-width: 220px; - } - .folders { max-height: 120px; overflow-y: auto; @@ -338,3 +328,22 @@ opacity: 1; } } + +/* 勾选项:框在左、文字在右,同一行 */ +.channelEditor .check { + flex-direction: row; + align-items: center; + gap: var(--space-2); + min-height: 36px; + color: var(--text); + font-size: 14px; +} + +.channelEditor .check input { + flex: 0 0 18px; + width: 18px; + min-height: 18px; + height: 18px; + padding: 0; + accent-color: var(--accent); +} diff --git a/web/src/features/channel/ThreadSidebar.tsx b/web/src/features/channel/ThreadSidebar.tsx index 737f041..b8331dc 100644 --- a/web/src/features/channel/ThreadSidebar.tsx +++ b/web/src/features/channel/ThreadSidebar.tsx @@ -91,8 +91,8 @@ export function ThreadSidebar(props: Props) { {props.groups.map((group) => )} : null} {edit.kind === 'channel' && edit.id && props.owner ? <> - - + + : null} {error ?

{error}

: null}
diff --git a/web/src/features/channel/Workspace.module.css b/web/src/features/channel/Workspace.module.css index cff32bd..a00e8f3 100644 --- a/web/src/features/channel/Workspace.module.css +++ b/web/src/features/channel/Workspace.module.css @@ -65,38 +65,11 @@ padding-inline: env(safe-area-inset-left) env(safe-area-inset-right); } -.mobile .navigation { - max-height: 30dvh; - flex-shrink: 0; - border-right: 0; -} - .mobileContent { flex: 1; overflow: hidden; } -.tabs { - display: flex; - flex-shrink: 0; - border-bottom: 1px solid var(--line); -} - -.tabs button { - flex: 1; - min-height: 44px; - border: 0; - background: var(--surface); - color: var(--text-dim); - font: inherit; - cursor: pointer; -} - -.tabs button[aria-pressed="true"] { - border-bottom: 2px solid var(--accent); - color: var(--text); -} - .pendingCatalog { padding: var(--space-2) var(--space-3); border-top: 1px solid var(--line); diff --git a/web/src/features/channel/Workspace.tsx b/web/src/features/channel/Workspace.tsx index 3ba0b6f..338a681 100644 --- a/web/src/features/channel/Workspace.tsx +++ b/web/src/features/channel/Workspace.tsx @@ -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('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 }>(); @@ -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 = setScanOpen(true)} />; - const navigation = <> setSettingsOpen(true)} /> - - {desktop ? : null}; + const threadSidebar = { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread} + onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />; + const navigation = <> setSettingsOpen(true)} />{threadSidebar}{desktop ? : null}; const chat = sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />; const explorer = <> @@ -129,13 +143,28 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) {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}> - 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 ? 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}
- : <>{rail}
{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}
} + :
{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}
} + {/* 弹层打开时抽屉留在下面:同时关抽屉(history.back)会把刚压入的弹层历史一并弹掉 */} + {!desktop && drawerOpen ? setDrawerOpen(false)}> + setScanOpen(true) }} + onSettings={() => setSettingsOpen(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} diff --git a/web/src/features/home/AccountDock.module.css b/web/src/features/home/AccountDock.module.css index c1f8033..b4a02e1 100644 --- a/web/src/features/home/AccountDock.module.css +++ b/web/src/features/home/AccountDock.module.css @@ -127,3 +127,32 @@ min-height: 40px; } } + +/* 手机聊天页由侧边栏承载账号入口,浮窗让位 */ +@media (width <= 899px) { + /* stylelint-disable-next-line selector-pseudo-class-no-unknown */ + :global([data-chat-shell]) .dock:not(.inline) { + display: none; + } +} + +.inline { + position: static; + flex: 1; + width: auto; + min-width: 0; + padding: 0; + border: 0; + background: transparent; +} + +.inline .profile { + flex: 1; + width: auto; + justify-content: flex-start; + padding: var(--space-1) var(--space-2); +} + +.inline .profileText { + display: flex; +} diff --git a/web/src/features/home/AccountDock.tsx b/web/src/features/home/AccountDock.tsx index bd801d8..25d3f37 100644 --- a/web/src/features/home/AccountDock.tsx +++ b/web/src/features/home/AccountDock.tsx @@ -5,7 +5,8 @@ import { Sheet } from '@/ui/Sheet'; import { AccountSettings } from './AccountSettings'; import styles from './AccountDock.module.css'; -export function AccountDock() { +/** inline:嵌进手机侧边栏底部,不再浮在页面上 */ +export function AccountDock({ inline = false }: { inline?: boolean }) { const [settingsOpen, setSettingsOpen] = useState(false); const username = useAccount((s) => s.username); const personal = useAccount((s) => s.personal); @@ -17,7 +18,7 @@ export function AccountDock() { const openSettings = () => setSettingsOpen(true); return <> -
+