Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e79fbf1112 | ||
|
|
1db3795d6a | ||
|
|
06fe077ef1 |
No files matched your search
+3
-3
@@ -78,7 +78,7 @@ WebDrop 是**暂存区**,频道工作区:聊天按 thread 展示,文件按 thr
|
|||||||
|
|
||||||
### 6.1 手机:频道导航与聊天/文件切换
|
### 6.1 手机:频道导航与聊天/文件切换
|
||||||
|
|
||||||
顶栏下显示账号频道导航与当前频道的 threads。新建频道自动创建“常规”thread,成员可直接聊天和上传。下方用“聊天 / 文件”切换同一 thread 的内容,聊天输入位于底部;文件区与桌面共用目录路径、上传、文件夹、排序、移动和预览组件。
|
进入频道就是纯聊天界面:单行顶栏(≡、# 频道名、加密图标、搜索、⋯)加底部输入框,没有页签。空间切换、频道列表、本空间的「文件 / 成员 / 邀请 / 分享」、账号与外观都在左侧抽屉(≡ 打开,返回键关闭);点「文件」「成员」整页打开,左上角 ‹ 回到聊天。⋯ 里是空间设置、分享、邀请。触屏没有悬停,消息上不常驻删除按钮,点一下消息弹出菜单(复制、删除)。新建频道自动创建“常规”thread。文件页与桌面共用目录路径、上传、文件夹、排序、移动和预览组件。
|
||||||
|
|
||||||
### 6.2 电脑:频道与 threads | 聊天 | 线程文件
|
### 6.2 电脑:频道与 threads | 聊天 | 线程文件
|
||||||
|
|
||||||
@@ -316,13 +316,13 @@ Share 是独立的只读发布资源,不创建频道成员或 threads。创建
|
|||||||
|
|
||||||
界面层级统一为「空间 → 频道分组(可选)→ 频道 → 聊天与文件夹」。原顶层频道对用户称为空间,原 thread 对用户称为频道。新建空间自动创建“常规”频道。空间负责成员、邀请、成员人数上限和加密边界;账号内设备不重复计入人数,设备只是账号的属性,只在账号设置里管理。频道分组只组织导航,不改变权限或加密密钥。
|
界面层级统一为「空间 → 频道分组(可选)→ 频道 → 聊天与文件夹」。原顶层频道对用户称为空间,原 thread 对用户称为频道。新建空间自动创建“常规”频道。空间负责成员、邀请、成员人数上限和加密边界;账号内设备不重复计入人数,设备只是账号的属性,只在账号设置里管理。频道分组只组织导航,不改变权限或加密密钥。
|
||||||
|
|
||||||
桌面最左是 64px 的空间窄栏(每个空间一个首字方块,当前空间反色;下方是新建空间与加入空间),其右 240px 的频道列:顶部是空间名与加密状态(点击打开空间设置),下面按分组展示频道,分组标题小号灰字,悬停才显示 + 与 ⋯,频道行 32px、选中为整行淡底;便签并入频道列底部。账号卡片贴在左下角横跨两栏。手机上空间栏变成横向一排方块,空间名栏隐藏。支持新建/重命名分组、折叠分组、在分组内新建频道、将已有频道移入其他分组或移回未分组。旧 thread 自动作为未分组频道展示。中间显示当前频道聊天,右侧显示当前频道文件夹和文件。
|
桌面最左是 64px 的空间窄栏(每个空间一个首字方块,当前空间反色;下方是新建空间与加入空间),其右 240px 的频道列:顶部是空间名与加密状态(点击打开空间设置),下面按分组展示频道,分组标题小号灰字,悬停才显示 + 与 ⋯,频道行 32px、选中为整行淡底;便签并入频道列底部。账号卡片贴在左下角横跨两栏。手机上这些都收进左侧抽屉:顶部是横向一排空间方块,其下是空间名栏与频道列表。支持新建/重命名分组、折叠分组、在分组内新建频道、将已有频道移入其他分组或移回未分组。旧 thread 自动作为未分组频道展示。中间显示当前频道聊天,右侧显示当前频道文件夹和文件。
|
||||||
|
|
||||||
空间设置集中管理成员、邀请和空间权限;频道管理提供名称与所属分组。分组名及归属由后端持久化并通过通知刷新,刷新与其他设备保持一致。协议/API 内部保留 channels/thread_id 等标识,避免因界面术语变更重建已有数据。
|
空间设置集中管理成员、邀请和空间权限;频道管理提供名称与所属分组。分组名及归属由后端持久化并通过通知刷新,刷新与其他设备保持一致。协议/API 内部保留 channels/thread_id 等标识,避免因界面术语变更重建已有数据。
|
||||||
|
|
||||||
### 13.1 成员栏、邀请与通知
|
### 13.1 成员栏、邀请与通知
|
||||||
|
|
||||||
右侧栏顶部是「文件 / 成员 n」页签,默认文件;顶栏的人数按钮直接打开成员页,空间主还有一个 + 直接打开邀请弹窗。手机上「聊天 / 文件」切换里多一项「成员」。
|
右侧栏顶部是「文件 / 成员 n」页签,默认文件;顶栏的人数按钮直接打开成员页,空间主还有一个 + 直接打开邀请弹窗。手机上没有页签,「文件」「成员」放在左侧抽屉里。
|
||||||
|
|
||||||
成员页以用户为单位,按 Discord 的分组排列:在线 — n、离线 — n、待接受 — n(仅空间主可见,有「撤销」)。每行头像、昵称(有昵称时下方小字显示用户名),空间主带「空间主」标签;一个用户有多台设备时小字显示「在线设备/设备数 台设备在线」。**设备不再单独列出,只是用户的属性。**在线状态与设备数由后端按账号统计(`GET /channels/{id}/members` 的 `online`、`devices`、`online_devices`,在线 = 该账号在本频道有活动订阅),前端在成员页可见期间每 8 秒读取一次,不改动订阅流。空间主在页头有醒目的「+ 邀请成员」,成员行悬停出现「移除」(需确认)。邀请与成员管理不再放在空间设置里,设置里只留一句指引。
|
成员页以用户为单位,按 Discord 的分组排列:在线 — n、离线 — n、待接受 — n(仅空间主可见,有「撤销」)。每行头像、昵称(有昵称时下方小字显示用户名),空间主带「空间主」标签;一个用户有多台设备时小字显示「在线设备/设备数 台设备在线」。**设备不再单独列出,只是用户的属性。**在线状态与设备数由后端按账号统计(`GET /channels/{id}/members` 的 `online`、`devices`、`online_devices`,在线 = 该账号在本频道有活动订阅),前端在成员页可见期间每 8 秒读取一次,不改动订阅流。空间主在页头有醒目的「+ 邀请成员」,成员行悬停出现「移除」(需确认)。邀请与成员管理不再放在空间设置里,设置里只留一句指引。
|
||||||
|
|
||||||
|
|||||||
@@ -260,7 +260,8 @@ export async function acceptInvitation(channelId: string): Promise<void> {
|
|||||||
const invitation = (await listAccountInvitations()).find((row) => row.channel_id === channelId);
|
const invitation = (await listAccountInvitations()).find((row) => row.channel_id === channelId);
|
||||||
if (!invitation) throw new Error('邀请已失效');
|
if (!invitation) throw new Error('邀请已失效');
|
||||||
const link = await openChannelGrant(invitation.payload, session.username, channelId, session.profile.keys.privateKey, 'member', self.location.origin);
|
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> {
|
export async function inviteChannelUser(channelId: string, input: string): Promise<void> {
|
||||||
requireAccount(); const username = usernameOf(input); const { session, resource } = await getSession(channelId);
|
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;
|
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>);
|
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);
|
states.set(resource.id, state);
|
||||||
const latest = await getResource(resource.id); const keys = { ...latest?.domainKeys };
|
const latest = await getResource(resource.id); const keys = { ...latest?.domainKeys };
|
||||||
|
|||||||
@@ -129,15 +129,19 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
|
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' });
|
publishWorkspace(resource.id, { t: 'reset' });
|
||||||
clearFileCatalog(resource.id);
|
clearFileCatalog(resource.id);
|
||||||
readers.set(resource.id, session.createItemReader());
|
readers.set(resource.id, session.createItemReader());
|
||||||
|
// 密钥与第一页日志互不依赖,同时发出,首次打开少等一个往返
|
||||||
|
const keys = resource.mode === 'enc' ? readDataKeys(resource, session) : undefined;
|
||||||
|
keys?.catch(() => undefined);
|
||||||
let from = 0;
|
let from = 0;
|
||||||
for (;;) {
|
for (let first = true; ; first = false) {
|
||||||
const response = await apiFetch(`/channels/${resource.id}/log?from=${from}&limit=200`, {
|
const response = await apiFetch(`/channels/${resource.id}/log?from=${from}&limit=200`, {
|
||||||
headers: session.authHeaders('read'), signal,
|
headers: session.authHeaders('read'), signal,
|
||||||
});
|
});
|
||||||
|
if (first) await keys;
|
||||||
const records = parseLogRecords(new Uint8Array(await response.arrayBuffer()));
|
const records = parseLogRecords(new Uint8Array(await response.arrayBuffer()));
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
await applyChannelRecord(resource, session, record);
|
await applyChannelRecord(resource, session, record);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { channelCache, isOfflineFailure } from './resourceMetadata';
|
|||||||
import { accountToken } from './accountSession';
|
import { accountToken } from './accountSession';
|
||||||
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
|
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
|
||||||
import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody } from '../generated/api';
|
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 { makeLink } from './links';
|
||||||
import { toChannelInfo, type ChannelWire } from './wire';
|
import { toChannelInfo, type ChannelWire } from './wire';
|
||||||
import { stopChannel } from './channelTransport';
|
import { stopChannel } from './channelTransport';
|
||||||
@@ -90,6 +90,7 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
|
|||||||
};
|
};
|
||||||
await saveResource(resource);
|
await saveResource(resource);
|
||||||
sessions.set(id, session);
|
sessions.set(id, session);
|
||||||
|
freshlyJoined.set(id, Date.now());
|
||||||
return toChannelInfo(info, resource);
|
return toChannelInfo(info, resource);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
session.dispose();
|
session.dispose();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { IntegrityError } from '@webdrop/core';
|
import { IntegrityError } from '@webdrop/core';
|
||||||
import { active, getSession } from './sessions';
|
import { active, freshlyJoined, getSession } from './sessions';
|
||||||
import { apiFetch, type ApiError } from './http';
|
import { apiFetch, type ApiError } from './http';
|
||||||
import { publishWorkspace } from './presentation';
|
import { publishWorkspace } from './presentation';
|
||||||
import { channelMetadata, processChannelBytes, syncChannel } from './channelEvents';
|
import { channelMetadata, processChannelBytes, syncChannel } from './channelEvents';
|
||||||
@@ -20,6 +20,8 @@ export async function connectChannel(id: string): Promise<void> {
|
|||||||
if (signal.aborted) return;
|
if (signal.aborted) return;
|
||||||
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
|
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
|
||||||
// Device sessions can be revoked independently of account membership; rejoin idempotently.
|
// Device sessions can be revoked independently of account membership; rejoin idempotently.
|
||||||
|
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 });
|
await apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
|
||||||
if (resource.needsJoin) {
|
if (resource.needsJoin) {
|
||||||
if (session.can('owner')) await channelMetadata(resource, session);
|
if (session.can('owner')) await channelMetadata(resource, session);
|
||||||
@@ -47,9 +49,8 @@ export async function connectChannel(id: string): Promise<void> {
|
|||||||
while (!signal.aborted) {
|
while (!signal.aborted) {
|
||||||
try {
|
try {
|
||||||
if (!initialized) {
|
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;
|
if (signal.aborted) break;
|
||||||
await channelMetadata(resource, session);
|
|
||||||
await flushOutbox(resource, session);
|
await flushOutbox(resource, session);
|
||||||
initialized = true;
|
initialized = true;
|
||||||
publishWorkspace(id, { t: 'ready' });
|
publishWorkspace(id, { t: 'ready' });
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { clearWorkspaceStructures } from './workspaceStructure';
|
|||||||
import { attachDataKeys, clearDataKeys } from './channelDataKeys';
|
import { attachDataKeys, clearDataKeys } from './channelDataKeys';
|
||||||
export const sessions = new Map<string, ChannelSession>();
|
export const sessions = new Map<string, ChannelSession>();
|
||||||
export const readers = new Map<string, ReturnType<ChannelSession['createItemReader']>>();
|
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 active = new Map<string, AbortController>();
|
||||||
export const blobs = new Map<string, Blob>();
|
export const blobs = new Map<string, Blob>();
|
||||||
const opening = new Map<string, Promise<ChannelSession>>();
|
const opening = new Map<string, Promise<ChannelSession>>();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, type MouseEvent } from 'react';
|
||||||
import { isLocalId, useWorkspace } from '@/store/workspace';
|
import { isLocalId, useWorkspace } from '@/store/workspace';
|
||||||
import { useAccount } from '@/store/account';
|
import { useAccount } from '@/store/account';
|
||||||
import { useDownload } from '@/features/item/download';
|
import { useDownload } from '@/features/item/download';
|
||||||
@@ -7,6 +7,7 @@ import { senderLabel } from '@/lib/sender';
|
|||||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||||
import { Icon } from '@/ui/Icon';
|
import { Icon } from '@/ui/Icon';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
|
import { ContextMenu, type MenuAction } from '@/ui/ContextMenu';
|
||||||
import styles from './ChatPane.module.css';
|
import styles from './ChatPane.module.css';
|
||||||
|
|
||||||
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
|
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 username = useAccount((s) => s.username);
|
||||||
const download = useDownload();
|
const download = useDownload();
|
||||||
const [downloading, setDownloading] = useState(false);
|
const [downloading, setDownloading] = useState(false);
|
||||||
|
const [menu, setMenu] = useState<{ x: number; y: number }>();
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
const sender = item.author;
|
const sender = item.author;
|
||||||
const name = senderLabel(item);
|
const name = senderLabel(item);
|
||||||
@@ -31,7 +33,17 @@ export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props
|
|||||||
if (!item || downloading) return;
|
if (!item || downloading) return;
|
||||||
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
|
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}>
|
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>
|
{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} 的头像`} />
|
: 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}
|
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||||
</div>
|
</div>
|
||||||
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
|
{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>;
|
</article>;
|
||||||
}
|
}
|
||||||
@@ -278,6 +278,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 899px) {
|
@media (width <= 899px) {
|
||||||
|
/* 手机顶栏已显示频道名 */
|
||||||
|
.head {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
grid-template-columns: 32px minmax(0, 1fr);
|
grid-template-columns: 32px minmax(0, 1fr);
|
||||||
column-gap: var(--space-2);
|
column-gap: var(--space-2);
|
||||||
@@ -298,8 +303,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 触屏没有悬停:隐藏常驻按钮,点一下消息弹出菜单 */
|
||||||
@media (hover: none) {
|
@media (hover: none) {
|
||||||
.deleteAction {
|
.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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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) {
|
@media (width <= 899px) {
|
||||||
/* 手机顶栏已有空间名与设置入口 */
|
|
||||||
.header {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rail {
|
.rail {
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|||||||
@@ -318,16 +318,6 @@
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.threads {
|
|
||||||
flex-direction: row;
|
|
||||||
overflow-x: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.thread {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
max-width: 220px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folders {
|
.folders {
|
||||||
max-height: 120px;
|
max-height: 120px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -338,3 +328,22 @@
|
|||||||
opacity: 1;
|
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);
|
||||||
|
}
|
||||||
@@ -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>)}
|
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||||
</select></label> : null}
|
</select></label> : null}
|
||||||
{edit.kind === 'channel' && edit.id && props.owner ? <>
|
{edit.kind === 'channel' && edit.id && props.owner ? <>
|
||||||
<label><input type="checkbox" checked={chatHistory} onChange={event => setChatHistory(event.target.checked)} />新成员可读历史聊天</label>
|
<label className={styles.check}><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={filesHistory} onChange={event => setFilesHistory(event.target.checked)} />新成员可读历史文件</label>
|
||||||
</> : null}
|
</> : null}
|
||||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||||
<div className={`${styles.groupActions} ${styles.modalActions}`}>
|
<div className={`${styles.groupActions} ${styles.modalActions}`}>
|
||||||
|
|||||||
@@ -65,38 +65,11 @@
|
|||||||
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile .navigation {
|
|
||||||
max-height: 30dvh;
|
|
||||||
flex-shrink: 0;
|
|
||||||
border-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobileContent {
|
.mobileContent {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
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 {
|
.pendingCatalog {
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
border-top: 1px solid var(--line);
|
border-top: 1px solid var(--line);
|
||||||
|
|||||||
@@ -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 { useShallow } from 'zustand/react/shallow';
|
||||||
import { channelPath, navigate, sharePath } from '@/app/navigation';
|
import { channelPath, navigate, sharePath } from '@/app/navigation';
|
||||||
import { useDownload } from '@/features/item/download';
|
import { useDownload } from '@/features/item/download';
|
||||||
@@ -10,7 +10,11 @@ import type { FileUploadOptions } from '@/core-bridge/types';
|
|||||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||||
import { Sheet } from '@/ui/Sheet';
|
import { Sheet } from '@/ui/Sheet';
|
||||||
import { Button } from '@/ui/Button';
|
import { Button } from '@/ui/Button';
|
||||||
|
import { Drawer } from '@/ui/Drawer';
|
||||||
|
import { Icon, type IconName } from '@/ui/Icon';
|
||||||
import { SpaceHeader, SpaceRail } from './SpaceRail';
|
import { SpaceHeader, SpaceRail } from './SpaceRail';
|
||||||
|
import { MobileDrawer } from './MobileDrawer';
|
||||||
|
import { MobileTopBar } from './MobileTopBar';
|
||||||
import { ThreadSidebar } from './ThreadSidebar';
|
import { ThreadSidebar } from './ThreadSidebar';
|
||||||
import { ChatPane } from './ChatPane';
|
import { ChatPane } from './ChatPane';
|
||||||
import { FileExplorer } from './FileExplorer';
|
import { FileExplorer } from './FileExplorer';
|
||||||
@@ -66,6 +70,8 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
|
|||||||
const [mobileView, setMobileView] = useState<'chat' | 'files' | 'members'>('chat');
|
const [mobileView, setMobileView] = useState<'chat' | 'files' | 'members'>('chat');
|
||||||
const [sideTab, setSideTab] = useState<SideTab>('files');
|
const [sideTab, setSideTab] = useState<SideTab>('files');
|
||||||
const [inviteOpen, setInviteOpen] = useState(false);
|
const [inviteOpen, setInviteOpen] = useState(false);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
const [memberRevision, setMemberRevision] = useState(0);
|
const [memberRevision, setMemberRevision] = useState(0);
|
||||||
const [fileWidth, setFileWidth] = useState<number>();
|
const [fileWidth, setFileWidth] = useState<number>();
|
||||||
const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>();
|
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 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 : '删除失败' })); }
|
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 rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
|
||||||
const navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />
|
const threadSidebar = <ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory}
|
||||||
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
|
onSelectThread={(id) => { selectThread(id); setDrawerOpen(false); setMobileView('chat'); }} onCreateThread={createThread} onRenameThread={renameThread}
|
||||||
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
|
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />;
|
||||||
{desktop ? <Notepad /> : null}</>;
|
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 chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
|
||||||
const explorer = <>
|
const explorer = <>
|
||||||
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
<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}
|
{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)} />;
|
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'); }
|
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}
|
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
|
||||||
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
|
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
|
||||||
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}>
|
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></>
|
{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}
|
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
||||||
{inviteOpen ? <InviteSheet onClose={() => setInviteOpen(false)} onInvited={() => setMemberRevision((value) => value + 1)} /> : null}
|
{inviteOpen ? <InviteSheet onClose={() => setInviteOpen(false)} onInvited={() => setMemberRevision((value) => value + 1)} /> : null}
|
||||||
{panelOpen ? <Sheet title="分享" onClose={() => setPanelOpen(false)}><SharePanel /></Sheet> : null}
|
{panelOpen ? <Sheet title="分享" onClose={() => setPanelOpen(false)}><SharePanel /></Sheet> : null}
|
||||||
|
|||||||
@@ -127,3 +127,32 @@
|
|||||||
min-height: 40px;
|
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;
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@ import { Sheet } from '@/ui/Sheet';
|
|||||||
import { AccountSettings } from './AccountSettings';
|
import { AccountSettings } from './AccountSettings';
|
||||||
import styles from './AccountDock.module.css';
|
import styles from './AccountDock.module.css';
|
||||||
|
|
||||||
export function AccountDock() {
|
/** inline:嵌进手机侧边栏底部,不再浮在页面上 */
|
||||||
|
export function AccountDock({ inline = false }: { inline?: boolean }) {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const username = useAccount((s) => s.username);
|
const username = useAccount((s) => s.username);
|
||||||
const personal = useAccount((s) => s.personal);
|
const personal = useAccount((s) => s.personal);
|
||||||
@@ -17,7 +18,7 @@ export function AccountDock() {
|
|||||||
const openSettings = () => setSettingsOpen(true);
|
const openSettings = () => setSettingsOpen(true);
|
||||||
|
|
||||||
return <>
|
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} 的账号设置`}>
|
<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>}
|
{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>
|
<span className={styles.profileText}><strong className={styles.profileName}>{displayName}</strong><span className={styles.username}>{subtitle}</span></span>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ const PATHS = {
|
|||||||
phone: 'M8 3h8v18H8z M11 18h2',
|
phone: 'M8 3h8v18H8z M11 18h2',
|
||||||
tablet: 'M5 4h14v16H5z M11 17h2',
|
tablet: 'M5 4h14v16H5z M11 17h2',
|
||||||
more: 'M5 12h.01 M12 12h.01 M19 12h.01',
|
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',
|
undo: 'M9 14L4 9l5-5 M4 9h10a6 6 0 010 12h-3',
|
||||||
warn: 'M12 4l9 16H3z M12 10v4 M12 17h.01',
|
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',
|
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',
|
||||||
|
|||||||
Reference in new issue
Block a user