3 Commits
Author SHA1 Message Date
justme e79fbf1112 docs(ui): describe the phone chat shell [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
2026-10-16 05:34:38 +08:00
justme 1db3795d6a 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.
2026-10-16 05:34:38 +08:00
justme 06fe077ef1 perf(web): shorten the first open of a space for new members
Accepting an invitation no longer waits for the vault sync, the redundant
join right after joining is skipped, and the crypto state, first log page and
channel metadata are requested in parallel.
2026-10-16 05:34:38 +08:00
23 changed files with 412 additions and 68 deletions

No files matched your search

+3 -3
View File
@@ -78,7 +78,7 @@ WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thr
### 6.1 手机:频道导航与聊天/文件切换
顶栏下显示账号频道导航与当前频道的 threads。新建频道自动创建“常规”thread,成员可直接聊天和上传。下方用“聊天 / 文件”切换同一 thread 的内容,聊天输入位于底部;文件区与桌面共用目录路径、上传、文件夹、排序、移动和预览组件。
进入频道就是纯聊天界面:单行顶栏(≡、# 频道名、加密图标、搜索、⋯)加底部输入框,没有页签。空间切换、频道列表、本空间的「文件 / 成员 / 邀请 / 分享」、账号与外观都在左侧抽屉(≡ 打开,返回键关闭);点「文件」「成员」整页打开,左上角 ‹ 回到聊天。⋯ 里是空间设置、分享、邀请。触屏没有悬停,消息上不常驻删除按钮,点一下消息弹出菜单(复制、删除)。新建频道自动创建“常规”thread。文件页与桌面共用目录路径、上传、文件夹、排序、移动和预览组件。
### 6.2 电脑:频道与 threads | 聊天 | 线程文件
@@ -316,13 +316,13 @@ Share 是独立的只读发布资源,不创建频道成员或 threads。创建
界面层级统一为「空间 → 频道分组(可选)→ 频道 → 聊天与文件夹」。原顶层频道对用户称为空间,原 thread 对用户称为频道。新建空间自动创建“常规”频道。空间负责成员、邀请、成员人数上限和加密边界;账号内设备不重复计入人数,设备只是账号的属性,只在账号设置里管理。频道分组只组织导航,不改变权限或加密密钥。
桌面最左是 64px 的空间窄栏(每个空间一个首字方块,当前空间反色;下方是新建空间与加入空间),其右 240px 的频道列:顶部是空间名与加密状态(点击打开空间设置),下面按分组展示频道,分组标题小号灰字,悬停才显示 + 与 ⋯,频道行 32px、选中为整行淡底;便签并入频道列底部。账号卡片贴在左下角横跨两栏。手机上空间栏变成横向一排方块,空间名栏隐藏。支持新建/重命名分组、折叠分组、在分组内新建频道、将已有频道移入其他分组或移回未分组。旧 thread 自动作为未分组频道展示。中间显示当前频道聊天,右侧显示当前频道文件夹和文件。
桌面最左是 64px 的空间窄栏(每个空间一个首字方块,当前空间反色;下方是新建空间与加入空间),其右 240px 的频道列:顶部是空间名与加密状态(点击打开空间设置),下面按分组展示频道,分组标题小号灰字,悬停才显示 + 与 ⋯,频道行 32px、选中为整行淡底;便签并入频道列底部。账号卡片贴在左下角横跨两栏。手机上这些都收进左侧抽屉:顶部是横向一排空间方块,其下是空间名栏与频道列表。支持新建/重命名分组、折叠分组、在分组内新建频道、将已有频道移入其他分组或移回未分组。旧 thread 自动作为未分组频道展示。中间显示当前频道聊天,右侧显示当前频道文件夹和文件。
空间设置集中管理成员、邀请和空间权限;频道管理提供名称与所属分组。分组名及归属由后端持久化并通过通知刷新,刷新与其他设备保持一致。协议/API 内部保留 channels/thread_id 等标识,避免因界面术语变更重建已有数据。
### 13.1 成员栏、邀请与通知
右侧栏顶部是「文件 / 成员 n」页签,默认文件;顶栏的人数按钮直接打开成员页,空间主还有一个 + 直接打开邀请弹窗。手机上「聊天 / 文件」切换里多一项「成员」。
右侧栏顶部是「文件 / 成员 n」页签,默认文件;顶栏的人数按钮直接打开成员页,空间主还有一个 + 直接打开邀请弹窗。手机上没有页签,「文件」「成员」放在左侧抽屉里。
成员页以用户为单位,按 Discord 的分组排列:在线 — n、离线 — n、待接受 — n(仅空间主可见,有「撤销」)。每行头像、昵称(有昵称时下方小字显示用户名),空间主带「空间主」标签;一个用户有多台设备时小字显示「在线设备/设备数 台设备在线」。**设备不再单独列出,只是用户的属性。**在线状态与设备数由后端按账号统计(`GET /channels/{id}/members` 的 `online`、`devices`、`online_devices`,在线 = 该账号在本频道有活动订阅),前端在成员页可见期间每 8 秒读取一次,不改动订阅流。空间主在页头有醒目的「+ 邀请成员」,成员行悬停出现「移除」(需确认)。邀请与成员管理不再放在空间设置里,设置里只留一句指引。
+2 -1
View File
@@ -260,7 +260,8 @@ export async function acceptInvitation(channelId: string): Promise<void> {
const invitation = (await listAccountInvitations()).find((row) => row.channel_id === channelId);
if (!invitation) throw new Error('邀请已失效');
const link = await openChannelGrant(invitation.payload, session.username, channelId, session.profile.keys.privateKey, 'member', self.location.origin);
stillActive(session); await joinChannel({ link }); await syncVault();
// 保险库同步(约 5 次往返)放到后台,不让用户等着:频道资源已在本机保存,可以直接打开
stillActive(session); await joinChannel({ link }); scheduleSync();
}
export async function inviteChannelUser(channelId: string, input: string): Promise<void> {
requireAccount(); const username = usernameOf(input); const { session, resource } = await getSession(channelId);
@@ -27,7 +27,15 @@ export function attachDataKeys(session: ChannelSession, resource: SavedResource)
objectKeys.set(`${resource.id}:${itemId}`, unwrapped); return unwrapped;
};
}
export async function readDataKeys(resource: SavedResource, session: ChannelSession): Promise<{ state: CryptoState; keys: Record<string, string> }> {
type DataKeys = { state: CryptoState; keys: Record<string, string> };
const pendingKeys = new Map<string, Promise<DataKeys>>();
/** 同一空间同时发起的读取合并成一次请求 */
export function readDataKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> {
let task = pendingKeys.get(resource.id);
if (!task) { task = loadDataKeys(resource, session).finally(() => pendingKeys.delete(resource.id)); pendingKeys.set(resource.id, task); }
return task;
}
async function loadDataKeys(resource: SavedResource, session: ChannelSession): Promise<DataKeys> {
const state = await apiFetch(`/spaces/${resource.id}/crypto`, { headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') }).then(r => r.json() as Promise<CryptoState>);
states.set(resource.id, state);
const latest = await getResource(resource.id); const keys = { ...latest?.domainKeys };
+6 -2
View File
@@ -129,15 +129,19 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
}
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
if (resource.mode === 'enc') await readDataKeys(resource, session);
// reset 必须同步发出:connectChannel 随后并行拉取频道信息,它们的结果要落在 reset 之后
publishWorkspace(resource.id, { t: 'reset' });
clearFileCatalog(resource.id);
readers.set(resource.id, session.createItemReader());
// 密钥与第一页日志互不依赖,同时发出,首次打开少等一个往返
const keys = resource.mode === 'enc' ? readDataKeys(resource, session) : undefined;
keys?.catch(() => undefined);
let from = 0;
for (;;) {
for (let first = true; ; first = false) {
const response = await apiFetch(`/channels/${resource.id}/log?from=${from}&limit=200`, {
headers: session.authHeaders('read'), signal,
});
if (first) await keys;
const records = parseLogRecords(new Uint8Array(await response.arrayBuffer()));
for (const record of records) {
await applyChannelRecord(resource, session, record);
@@ -8,7 +8,7 @@ import { channelCache, isOfflineFailure } from './resourceMetadata';
import { accountToken } from './accountSession';
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody } from '../generated/api';
import { getSession, sessions } from './sessions';
import { freshlyJoined, getSession, sessions } from './sessions';
import { makeLink } from './links';
import { toChannelInfo, type ChannelWire } from './wire';
import { stopChannel } from './channelTransport';
@@ -90,6 +90,7 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
};
await saveResource(resource);
sessions.set(id, session);
freshlyJoined.set(id, Date.now());
return toChannelInfo(info, resource);
} catch (error) {
session.dispose();
@@ -1,5 +1,5 @@
import { IntegrityError } from '@webdrop/core';
import { active, getSession } from './sessions';
import { active, freshlyJoined, getSession } from './sessions';
import { apiFetch, type ApiError } from './http';
import { publishWorkspace } from './presentation';
import { channelMetadata, processChannelBytes, syncChannel } from './channelEvents';
@@ -20,7 +20,9 @@ export async function connectChannel(id: string): Promise<void> {
if (signal.aborted) return;
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
// Device sessions can be revoked independently of account membership; rejoin idempotently.
await apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
const joinedAt = freshlyJoined.get(id); freshlyJoined.delete(id);
if (joinedAt === undefined || Date.now() - joinedAt > 60_000)
await apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
if (resource.needsJoin) {
if (session.can('owner')) await channelMetadata(resource, session);
resource.needsJoin = false;
@@ -47,9 +49,8 @@ export async function connectChannel(id: string): Promise<void> {
while (!signal.aborted) {
try {
if (!initialized) {
stream.cursor = await syncChannel(resource, session, signal);
[stream.cursor] = await Promise.all([syncChannel(resource, session, signal), channelMetadata(resource, session)]);
if (signal.aborted) break;
await channelMetadata(resource, session);
await flushOutbox(resource, session);
initialized = true;
publishWorkspace(id, { t: 'ready' });
+2
View File
@@ -8,6 +8,8 @@ import { clearWorkspaceStructures } from './workspaceStructure';
import { attachDataKeys, clearDataKeys } from './channelDataKeys';
export const sessions = new Map<string, ChannelSession>();
export const readers = new Map<string, ReturnType<ChannelSession['createItemReader']>>();
/** 刚加入的频道 → 加入时间;connectChannel 据此跳过紧接着的重复 join(省一次往返) */
export const freshlyJoined = new Map<string, number>();
export const active = new Map<string, AbortController>();
export const blobs = new Map<string, Blob>();
const opening = new Map<string, Promise<ChannelSession>>();
+15 -2
View File
@@ -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 <article className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
// 触屏没有悬停:点一下消息弹出操作菜单(桌面仍是悬停按钮)
const actions: MenuAction[] = [
...(item.kind === 'text' ? [{ label: '复制', icon: 'copy' as const, action: () => { void navigator.clipboard?.writeText(item.text).catch(() => undefined); } }] : []),
...(owner || self ? [{ label: '删除', icon: 'trash' as const, action: () => onDelete(id) }] : []),
];
function tap(event: MouseEvent<HTMLElement>) {
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 <article onClick={tap} className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`} aria-busy={sending}>
{compact ? <time className={styles.gutterTime} dateTime={new Date(item.ts).toISOString()}>{formatTime(item.ts)}</time>
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
@@ -52,5 +64,6 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
</div>
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
{menu ? <ContextMenu point={menu} actions={actions} onClose={() => setMenu(undefined)} /> : null}
</article>;
}
+7 -1
View File
@@ -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;
}
}
@@ -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);
}
+48
View File
@@ -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 <button type="button" className={styles.row} onClick={onClick}>
<Icon name={icon} size={18} /><span>{label}</span>{count ? <small>{count}</small> : null}
</button>;
}
/** 手机侧边栏:空间、频道、本频道的文件与成员;账号与外观在底部 */
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 <>
<SpaceRail {...rail} />
<SpaceHeader onSettings={onSettings} />
<div className={styles.scroll}>
{threads}
<nav className={styles.section} aria-label="本频道">
<h2>本空间</h2>
<Row icon="folder" label="文件" onClick={onFiles} />
<Row icon="users" label="成员" count={used !== undefined ? `${used}/${max ?? '—'}` : undefined} onClick={onMembers} />
{onInvite ? <Row icon="plus" label="邀请成员" onClick={onInvite} /> : null}
<Row icon="share" label="分享" onClick={onShare} />
<Row icon="chevronLeft" label={spaces > 1 ? '回到首页' : '返回首页'} onClick={onHome} />
</nav>
</div>
<footer className={styles.footer}><AccountDock inline /><ThemeMenu /></footer>
</>;
}
@@ -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);
}
+30
View File
@@ -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 <header className={`${styles.bar} ${raw ? styles.raw : ''}`}>
{view === 'chat' ? <Button variant="ghost" icon="menu" label="打开侧边栏" onClick={onMenu} /> : <Button variant="ghost" icon="chevronLeft" label="返回聊天" onClick={onBack} />}
<h1 className={styles.title}>{title}</h1>
{raw ? <span className={styles.warn}>明文</span> : <span className={styles.lock} title="端到端加密"><Icon name="lock" size={14} /></span>}
{connection === 'online' ? null : <span className={`${styles.conn} ${styles[connection]}`} role="status">{connection === 'offline' ? '离线' : '重连中'}</span>}
<Button variant="ghost" icon="search" label="搜索文件" aria-pressed={searchOpen} className={filtering ? styles.active : undefined} onClick={onToggleSearch} />
<Button variant="ghost" icon="more" label="更多" onClick={onMore} />
</header>;
}
@@ -88,11 +88,6 @@
}
@media (width <= 899px) {
/* 手机顶栏已有空间名与设置入口 */
.header {
display: none;
}
.rail {
flex-direction: row;
justify-content: flex-start;
@@ -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);
}
+2 -2
View File
@@ -91,8 +91,8 @@ export function ThreadSidebar(props: Props) {
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
</select></label> : null}
{edit.kind === 'channel' && edit.id && props.owner ? <>
<label><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
<label><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
<label className={styles.check}><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
<label className={styles.check}><input type="checkbox" checked={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
</> : null}
{error ? <p className={styles.error} role="alert">{error}</p> : null}
<div className={`${styles.groupActions} ${styles.modalActions}`}>
@@ -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);
+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}
@@ -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;
}
+3 -2
View File
@@ -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 <>
<section className={styles.dock} aria-label="当前账号">
<section className={`${styles.dock} ${inline ? styles.inline : ''}`} aria-label="当前账号">
<button className={styles.profile} type="button" onClick={openSettings} aria-label={`打开 ${displayName} 的账号设置`}>
{personal?.avatar ? <img className={styles.avatar} src={personal.avatar} alt="" /> : <span className={styles.avatarFallback}>{initial}</span>}
<span className={styles.profileText}><strong className={styles.profileName}>{displayName}</strong><span className={styles.username}>{subtitle}</span></span>
+40
View File
@@ -0,0 +1,40 @@
.scrim {
position: fixed;
inset: 0;
z-index: 50;
background: var(--scrim);
animation: drawer-scrim-in 0.15s ease-out;
}
.drawer {
display: flex;
flex-direction: column;
width: min(320px, 86vw);
height: 100%;
padding-top: env(safe-area-inset-top);
border-right: 1px solid var(--line);
background: var(--bg);
box-shadow: var(--shadow-sheet);
outline: none;
animation: drawer-in 0.2s ease-out;
}
@keyframes drawer-scrim-in {
from {
opacity: 0;
}
}
@keyframes drawer-in {
from {
translate: -24px 0;
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.scrim,
.drawer {
animation: none;
}
}
+29
View File
@@ -0,0 +1,29 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useBackToClose } from '@/lib/useBackToClose';
import styles from './Drawer.module.css';
interface Props { label: string; onClose: () => void; children: ReactNode }
/** 手机的左侧抽屉:遮罩是纯色,返回键关闭(与 Sheet 一致) */
export function Drawer({ label, onClose, children }: Props) {
const ref = useRef<HTMLDivElement>(null);
const close = useRef(onClose);
useEffect(() => { close.current = onClose; }, [onClose]);
useBackToClose(onClose);
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
ref.current?.focus();
const onKey = (event: KeyboardEvent) => { if (event.key === 'Escape') close.current(); };
window.addEventListener('keydown', onKey);
return () => { window.removeEventListener('keydown', onKey); prev?.focus(); };
}, []);
return createPortal(
<div className={styles.scrim} onClick={onClose}>
<div ref={ref} tabIndex={-1} role="dialog" aria-modal="true" aria-label={label} className={styles.drawer} onClick={(event) => event.stopPropagation()}>
{children}
</div>
</div>,
document.body,
);
}
+2
View File
@@ -21,6 +21,8 @@ const PATHS = {
phone: 'M8 3h8v18H8z M11 18h2',
tablet: 'M5 4h14v16H5z M11 17h2',
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
menu: 'M4 7h16 M4 12h16 M4 17h16',
users: 'M9 11a3 3 0 100-6 3 3 0 000 6z M3 19a6 6 0 0112 0 M16 6a3 3 0 010 6 M18 19a5 5 0 00-3-4.6',
undo: 'M9 14L4 9l5-5 M4 9h10a6 6 0 010 12h-3',
warn: 'M12 4l9 16H3z M12 10v4 M12 17h.01',
sun: 'M12 8a4 4 0 100 8 4 4 0 000-8z M12 2v2 M12 20v2 M2 12h2 M20 12h2 M5 5l1.5 1.5 M17.5 17.5L19 19 M5 19l1.5-1.5 M17.5 6.5L19 5',