feat(web): add spaces, grouped channels and file chat
This commit is contained in:
1 parent
c7d8ef15bc
commit
f6cfb12716
111 files changed
+4371
-506
No files matched your search
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { accountApi } from '@/core-bridge/account';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { AccountScreen } from '@/features/home/AccountScreen';
|
||||
import { MissingAccess } from '@/features/scan/JoinScreen';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from '@/features/scan/JoinScreen.module.css';
|
||||
|
||||
export function AccountChannelAccess({ id, onHome }: { id: string; onHome: () => void }) {
|
||||
const username = useAccount((s) => s.username);
|
||||
const [invited, setInvited] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
setInvited(false); setError('');
|
||||
if (username) void accountApi.invitations().then((rows) => {
|
||||
if (active) setInvited(rows.some((row) => row.channel_id === id));
|
||||
}).catch(() => { if (active) setError('邀请读取失败,请重试同步。'); });
|
||||
return () => { active = false; };
|
||||
}, [id, username]);
|
||||
async function accept() {
|
||||
setBusy(true); setError('');
|
||||
try { await accountApi.acceptInvitation(id); await useChannels.getState().refresh(); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '加入失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
if (!username) return <AccountScreen />;
|
||||
if (!invited) return <MissingAccess onHome={onHome} />;
|
||||
return <div className={styles.page}><main className={styles.card}><h1 className={styles.title}>空间邀请</h1><p className={styles.dim}>接受邀请后,使用你的账号密钥自动解锁此空间,无需复制密钥链接。</p>
|
||||
<Button variant="primary" disabled={busy} onClick={() => { void accept(); }}>{busy ? '加入中…' : '接受并加入空间'}</Button>
|
||||
<Button variant="ghost" onClick={onHome}>回到首页</Button>
|
||||
{error ? <p role="alert">{error}</p> : null}</main></div>;
|
||||
}
|
||||
+18
-7
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Home } from '@/features/home/Home';
|
||||
import { JoinScreen, MissingAccess } from '@/features/scan/JoinScreen';
|
||||
import { JoinScreen } from '@/features/scan/JoinScreen';
|
||||
import { AccountChannelAccess } from './AccountChannelAccess';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useShares } from '@/store/shares';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
@@ -8,6 +9,8 @@ import { ChannelScreen } from './ChannelScreen';
|
||||
import { ShareScreen } from './ShareScreen';
|
||||
import { channelPath, navigate, useRoute } from './navigation';
|
||||
import { Splash } from './Splash';
|
||||
import { AccountScreen } from '@/features/home/AccountScreen';
|
||||
import { useAccount } from '@/store/account';
|
||||
|
||||
const SEEN_KEY = 'wd.splash';
|
||||
|
||||
@@ -20,6 +23,13 @@ function splashSeen(): boolean {
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const username = useAccount((s) => s.username);
|
||||
const accountHydrated = useAccount((s) => s.hydrated);
|
||||
const refreshAccount = useAccount((s) => s.refresh);
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => { void refreshAccount(); }, 5000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refreshAccount]);
|
||||
const route = useRoute();
|
||||
const list = useChannels((s) => s.list);
|
||||
const channelsHydrated = useChannels((s) => s.hydrated);
|
||||
@@ -31,12 +41,12 @@ export function App() {
|
||||
const [splash, setSplash] = useState(() => !splashSeen());
|
||||
|
||||
const saved = route.name === 'channel' && list.some((c) => c.id === route.id);
|
||||
// 只有打开已保存的频道才需要等数据;首页与加入页没有加载过程
|
||||
// 只有打开已保存的空间才需要等数据;首页与加入页没有加载过程
|
||||
const waitingOnSavedChannel = route.name === 'channel' && !pendingLink && !channelsHydrated;
|
||||
const waitingOnSavedShare = route.name === 'share' && pendingShareLink?.id !== route.id && !sharesHydrated;
|
||||
const ready = route.name === 'home' || (route.name === 'channel'
|
||||
const ready = accountHydrated && (route.name === 'home' || (route.name === 'channel'
|
||||
? pendingLink?.id === route.id || (channelsHydrated && (!saved || wsReady))
|
||||
: pendingShareLink?.id === route.id || sharesHydrated);
|
||||
: pendingShareLink?.id === route.id || sharesHydrated));
|
||||
|
||||
function onSplashDone() {
|
||||
try {
|
||||
@@ -48,7 +58,8 @@ export function App() {
|
||||
}
|
||||
|
||||
let screen;
|
||||
if (route.name === 'home') screen = <Home />;
|
||||
if (!accountHydrated) screen = null;
|
||||
else if (route.name === 'home') screen = username ? <Home /> : <AccountScreen />;
|
||||
else if (route.name === 'share' && waitingOnSavedShare) screen = null;
|
||||
else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
|
||||
else if (route.name === 'channel' && waitingOnSavedChannel) screen = null;
|
||||
@@ -64,7 +75,7 @@ export function App() {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else screen = <MissingAccess onHome={() => navigate('/')} />;
|
||||
} else screen = <AccountChannelAccess id={route.name === 'channel' ? route.id : ''} onHome={() => navigate('/')} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Workspace } from '@/features/channel/Workspace';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { CreateSheet } from '@/features/home/CreateSheet';
|
||||
import { channelPath, navigate } from './navigation';
|
||||
|
||||
/** 打开(订阅)频道,数据就绪后显示暂存区 */
|
||||
/** 打开(订阅)空间,数据就绪后显示暂存区 */
|
||||
export function ChannelScreen({ id }: { id: string }) {
|
||||
const open = useWorkspace((s) => s.open);
|
||||
const ready = useWorkspace((s) => s.ready);
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
||||
useEffect(() => open(id), [open, id]);
|
||||
|
||||
return ready ? <Workspace /> : null;
|
||||
return ready ? <><Workspace onCreateChannel={() => setCreating(true)} />
|
||||
{creating ? <CreateSheet onClose={() => setCreating(false)} onCreated={(channel) => { setCreating(false); navigate(channelPath(channel.id)); }} /> : null}</> : null;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { callWorker } from './workerClient';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
|
||||
export interface AccountStatus { username: string | null; revision: number; personal?: { displayName: string; avatar?: string; modifiedAt: number }; error?: string }
|
||||
export interface AccountDevice { id: string; name: string; created_at: number; last_seen: number; current: boolean }
|
||||
export interface AccountInvitation { channel_id: string; username: string; payload: string; created_at: number }
|
||||
|
||||
export const accountApi = {
|
||||
register: (username: string, password: string) => callWorker<AccountStatus>('registerAccount', { username, password, apiBase: API_BASE }),
|
||||
login: (username: string, password: string) => callWorker<AccountStatus>('loginAccount', { username, password, apiBase: API_BASE }),
|
||||
status: () => callWorker<AccountStatus>('accountStatus', { apiBase: API_BASE }),
|
||||
logout: () => callWorker<void>('logoutAccount', { apiBase: API_BASE }),
|
||||
sync: () => callWorker<AccountStatus>('syncAccount', { apiBase: API_BASE }),
|
||||
invitations: () => callWorker<AccountInvitation[]>('accountInvitations', { apiBase: API_BASE }),
|
||||
acceptInvitation: (id: string) => callWorker<void>('acceptInvitation', { id, apiBase: API_BASE }),
|
||||
updatePersonal: (displayName: string, avatar?: string) => callWorker<AccountStatus>('updatePersonal', { displayName, avatar, apiBase: API_BASE }),
|
||||
devices: () => callWorker<AccountDevice[]>('accountDevices', { apiBase: API_BASE }),
|
||||
renameDevice: (id: string, name: string) => callWorker<void>('renameAccountDevice', { id, name, apiBase: API_BASE }),
|
||||
revokeDevice: (id: string) => callWorker<AccountStatus>('revokeAccountDevice', { id, apiBase: API_BASE }),
|
||||
};
|
||||
+27
-11
@@ -1,27 +1,43 @@
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, WorkspaceEvent } from './types';
|
||||
import type { AccountInvitation } from './account';
|
||||
import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, FileUploadOptions, WorkspaceEvent } from './types';
|
||||
|
||||
/**
|
||||
* 常驻频道的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。
|
||||
* 常驻空间的后端边界。真实实现接 @webdrop/core 与 api.md,现在由 mock.ts 提供假实现。
|
||||
* 便捷分享有自己的边界,见 shareApi.ts。
|
||||
*/
|
||||
export interface WorkspaceApi {
|
||||
/** 新建频道:本地生成 id 与密钥、算 PoW、POST channels(api.md) */
|
||||
/** 新建空间:本地生成 id 与密钥、算 PoW、POST channels(api.md) */
|
||||
createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>;
|
||||
/** 通过链接加入:先 GET 频道信息预览,再 POST seats */
|
||||
joinChannel(link: ParsedChannelLink): Promise<ChannelInfo>;
|
||||
/** 使用现有空间 settings 接口更新空间设置。 */
|
||||
updateChannelSettings(patch: ChannelSettingsPatch): Promise<void>;
|
||||
renameDevice(endpointHash: string, name: string): Promise<void>;
|
||||
releaseDevice(endpointHash: string): Promise<void>;
|
||||
/** 通过链接加入:先 GET 空间信息预览,再 POST seats */
|
||||
joinChannel(link: ParsedChannelLink, password?: string): Promise<ChannelInfo>;
|
||||
inviteUser(username: string): Promise<void>;
|
||||
listInvitations(): Promise<AccountInvitation[]>;
|
||||
revokeInvitation(username: string): Promise<void>;
|
||||
listSaved(): Promise<ChannelInfo[]>;
|
||||
/** 仅从本机删除保存的链接和终端密钥,不会删服务端频道 */
|
||||
/** 仅从本机删除保存的链接和终端密钥,不会删服务端空间 */
|
||||
forget(id: string): Promise<void>;
|
||||
/** 仅假实现提供:载入演示用的频道 */
|
||||
/** 仅假实现提供:载入演示用的空间 */
|
||||
loadDemo?(): ChannelInfo[];
|
||||
|
||||
/** 订阅某个频道的事件流(对应追加日志的推送),返回取消订阅函数 */
|
||||
/** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */
|
||||
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
|
||||
sendText(text: string): Promise<void>;
|
||||
sendFiles(files: File[]): Promise<void>;
|
||||
sendText(text: string, threadId?: string): Promise<void>;
|
||||
sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string): Promise<void>;
|
||||
createThread(name: string, groupId?: string): Promise<string>;
|
||||
createGroup(name: string): Promise<string>;
|
||||
renameGroup(id: string, name: string): Promise<void>;
|
||||
groupThread(id: string, groupId?: string): Promise<void>;
|
||||
renameThread(threadId: string, name: string): Promise<void>;
|
||||
createFolder(threadId: string, name: string, parentId?: string): Promise<void>;
|
||||
renameFolder(folderId: string, threadId: string, name: string): Promise<void>;
|
||||
moveItems(itemIds: string[], threadId: string, folderId?: string): Promise<void>;
|
||||
/** 修改 = 追加一个替代旧条目的新版本,后写者胜 */
|
||||
editText(id: string, text: string): Promise<void>;
|
||||
editText(id: string, text: string, threadId?: string): Promise<void>;
|
||||
resolveConflict(id: string, choice: 'mine' | 'theirs'): Promise<void>;
|
||||
remove(id: string): Promise<void>;
|
||||
setSnapshot(text: string): Promise<void>;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { callWorker } from './workerClient';
|
||||
import type { ChannelInfo } from './types';
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
const apiBase = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
export interface ChannelMember { username: string; role: 'owner' | 'member'; joined_at: number }
|
||||
export const channelAccessApi = {
|
||||
members: (id: string) => callWorker<ChannelMember[]>('channelMembers', { id, apiBase }),
|
||||
preview: (link: ParsedChannelLink) => callWorker<ChannelInfo>('previewChannel', { link, apiBase }),
|
||||
};
|
||||
@@ -6,8 +6,16 @@ export type ManifestEntry =
|
||||
| { t: 'text'; localId: string; name?: string; text: string; ts: number };
|
||||
|
||||
export interface SavedResource {
|
||||
creatorAccount?: string;
|
||||
localScope?: string;
|
||||
modifiedAt?: number;
|
||||
needsJoin?: boolean;
|
||||
id: string;
|
||||
kind: 'channel' | 'share';
|
||||
/** 服务端空间设置名称的本地缓存,打开空间时会用最新设置刷新。 */
|
||||
name?: string;
|
||||
/** 本终端在席位列表中的自定义显示名称。 */
|
||||
deviceLabel?: string;
|
||||
mode: 'enc' | 'raw';
|
||||
/** Keys stay in IndexedDB. The page URL and localStorage never retain them. */
|
||||
link: string;
|
||||
@@ -47,32 +55,88 @@ interface DraftFile {
|
||||
}
|
||||
|
||||
interface WebDropDb extends DBSchema {
|
||||
resourceDeletes: { key: string; value: { id: string; modifiedAt: number } };
|
||||
resources: { key: string; value: SavedResource };
|
||||
outbox: { key: string; value: QueuedFrame; indexes: { 'by-resource': string } };
|
||||
outbox: { key: string; value: QueuedFrame; indexes: { 'by-resource': string; 'by-resource-created': [string, number] } };
|
||||
draftFiles: { key: string; value: DraftFile; indexes: { 'by-resource': string } };
|
||||
}
|
||||
|
||||
let dbPromise: Promise<IDBPDatabase<WebDropDb>> | undefined;
|
||||
const databases = new Map<string, Promise<IDBPDatabase<WebDropDb>>>();
|
||||
let resourceScope = 'guest';
|
||||
export function currentResourceScope(): string { return resourceScope; }
|
||||
export function setResourceScope(username: string | null): void { resourceScope = username ? `account:${username}` : 'guest'; }
|
||||
let resourceChangeListener: (() => void) | undefined;
|
||||
|
||||
export function setResourceChangeListener(listener: (() => void) | undefined): void {
|
||||
resourceChangeListener = listener;
|
||||
}
|
||||
|
||||
function db(): Promise<IDBPDatabase<WebDropDb>> {
|
||||
dbPromise ??= openDB<WebDropDb>('webdrop-local', 2, {
|
||||
upgrade(database, oldVersion) {
|
||||
let dbPromise = databases.get(resourceScope);
|
||||
dbPromise ??= openDB<WebDropDb>(`webdrop-2-${resourceScope}`, 2, {
|
||||
upgrade(database, oldVersion, _newVersion, tx) {
|
||||
if (oldVersion < 1) {
|
||||
database.createObjectStore('resources', { keyPath: 'id' });
|
||||
const outbox = database.createObjectStore('outbox', { keyPath: 'key' });
|
||||
outbox.createIndex('by-resource', 'resourceId');
|
||||
}
|
||||
if (oldVersion < 2) {
|
||||
const draftFiles = database.createObjectStore('draftFiles', { keyPath: 'key' });
|
||||
draftFiles.createIndex('by-resource', 'resourceId');
|
||||
database.createObjectStore('resources', { keyPath: 'id' });
|
||||
const outbox = database.createObjectStore('outbox', { keyPath: 'key' });
|
||||
outbox.createIndex('by-resource', 'resourceId');
|
||||
const draftFiles = database.createObjectStore('draftFiles', { keyPath: 'key' });
|
||||
draftFiles.createIndex('by-resource', 'resourceId');
|
||||
database.createObjectStore('resourceDeletes', { keyPath: 'id' });
|
||||
}
|
||||
if (oldVersion < 2) tx.objectStore('outbox').createIndex('by-resource-created', ['resourceId', 'createdAt']);
|
||||
},
|
||||
});
|
||||
databases.set(resourceScope, dbPromise);
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
export async function saveResource(resource: SavedResource): Promise<void> {
|
||||
await (await db()).put('resources', resource);
|
||||
if (resource.localScope && resource.localScope !== resourceScope) throw new Error('账号已切换,操作已停止');
|
||||
resource.localScope = resourceScope;
|
||||
resource.modifiedAt = Date.now();
|
||||
const database = await db();
|
||||
const tx = database.transaction(['resources', 'resourceDeletes'], 'readwrite');
|
||||
await tx.objectStore('resources').put(resource);
|
||||
await tx.objectStore('resourceDeletes').delete(resource.id);
|
||||
await tx.done;
|
||||
resourceChangeListener?.();
|
||||
}
|
||||
|
||||
export async function listAllResources(): Promise<SavedResource[]> {
|
||||
return (await db()).getAll('resources');
|
||||
}
|
||||
|
||||
export async function listResourceDeletes(): Promise<Array<{ id: string; modifiedAt: number }>> {
|
||||
return (await db()).getAll('resourceDeletes');
|
||||
}
|
||||
|
||||
export async function importResourceDeletes(deletes: Array<{ id: string; modifiedAt: number }>): Promise<void> {
|
||||
const database = await db();
|
||||
const tx = database.transaction(['resources', 'resourceDeletes', 'outbox', 'draftFiles'], 'readwrite');
|
||||
for (const entry of deletes) {
|
||||
const resource = await tx.objectStore('resources').get(entry.id);
|
||||
if (resource && (resource.modifiedAt ?? resource.createdAt) > entry.modifiedAt) continue;
|
||||
await tx.objectStore('resourceDeletes').put(entry);
|
||||
await tx.objectStore('resources').delete(entry.id);
|
||||
for (const key of await tx.objectStore('outbox').index('by-resource').getAllKeys(entry.id)) await tx.objectStore('outbox').delete(key);
|
||||
for (const key of await tx.objectStore('draftFiles').index('by-resource').getAllKeys(entry.id)) await tx.objectStore('draftFiles').delete(key);
|
||||
}
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export async function importResources(resources: SavedResource[]): Promise<void> {
|
||||
const database = await db();
|
||||
const tx = database.transaction('resources', 'readwrite');
|
||||
for (const resource of resources) {
|
||||
const existing = await tx.store.get(resource.id);
|
||||
await tx.store.put({ ...resource, localScope: resourceScope,
|
||||
endpointSecret: existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32)),
|
||||
deviceLabel: existing?.deviceLabel, localItems: existing?.localItems,
|
||||
pendingManifestId: existing?.pendingManifestId,
|
||||
needsJoin: existing?.needsJoin ?? (!existing && resource.kind === 'channel'),
|
||||
});
|
||||
}
|
||||
await tx.done;
|
||||
}
|
||||
|
||||
export async function getResource(id: string): Promise<SavedResource | undefined> {
|
||||
@@ -86,13 +150,15 @@ export async function listResources(kind: SavedResource['kind']): Promise<SavedR
|
||||
|
||||
export async function removeResource(id: string): Promise<void> {
|
||||
const database = await db();
|
||||
const tx = database.transaction(['resources', 'outbox', 'draftFiles'], 'readwrite');
|
||||
const tx = database.transaction(['resources', 'outbox', 'draftFiles', 'resourceDeletes'], 'readwrite');
|
||||
await tx.objectStore('resources').delete(id);
|
||||
await tx.objectStore('resourceDeletes').put({ id, modifiedAt: Date.now() });
|
||||
const keys = await tx.objectStore('outbox').index('by-resource').getAllKeys(id);
|
||||
await Promise.all(keys.map((key) => tx.objectStore('outbox').delete(key)));
|
||||
const fileKeys = await tx.objectStore('draftFiles').index('by-resource').getAllKeys(id);
|
||||
await Promise.all(fileKeys.map((key) => tx.objectStore('draftFiles').delete(key)));
|
||||
await tx.done;
|
||||
resourceChangeListener?.();
|
||||
}
|
||||
|
||||
export async function saveDraftFile(resourceId: string, itemId: string, file: File): Promise<void> {
|
||||
@@ -130,10 +196,17 @@ export async function dequeueFrame(key: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function nextQueuedFrame(resourceId: string): Promise<QueuedFrame | undefined> {
|
||||
const values = await (await db()).getAllFromIndex('outbox', 'by-resource', resourceId, 1);
|
||||
const range = IDBKeyRange.bound([resourceId, 0], [resourceId, Number.MAX_SAFE_INTEGER]);
|
||||
const values = await (await db()).getAllFromIndex('outbox', 'by-resource-created', range, 1);
|
||||
return values[0];
|
||||
}
|
||||
|
||||
export async function lastQueuedAt(resourceId: string): Promise<number> {
|
||||
const range = IDBKeyRange.bound([resourceId, 0], [resourceId, Number.MAX_SAFE_INTEGER]);
|
||||
const cursor = await (await db()).transaction('outbox').store.index('by-resource-created').openCursor(range, 'prev');
|
||||
return cursor?.value.createdAt ?? 0;
|
||||
}
|
||||
|
||||
export async function dequeueItem(resourceId: string, itemId: string): Promise<void> {
|
||||
const database = await db();
|
||||
const tx = database.transaction('outbox', 'readwrite');
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
const messages: Record<string, string> = {
|
||||
not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。',
|
||||
seats_full: '频道席位已满,请联系所有者。', not_joined: '你已被移出此频道。',
|
||||
seats_full: '空间席位已满,请联系所有者。', not_joined: '你已被移出此空间。',
|
||||
visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。',
|
||||
forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。',
|
||||
too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。',
|
||||
captcha_required: '验证码未通过或已失效,请重新创建。',
|
||||
captcha_unavailable: '验证码服务暂时不可用,请稍后重试。',
|
||||
api_contract_mismatch: '服务端 API 有破坏性更新,请刷新或更新前端后再试。',
|
||||
mode_mismatch: '频道加密模式与链接不符,已拒绝访问。', conflict: '上传内容发生冲突,已停止发送。',
|
||||
mode_mismatch: '空间加密模式与链接不符,已拒绝访问。', conflict: '上传内容发生冲突,已停止发送。',
|
||||
username_unavailable: '这个用户名已被使用。', vault_conflict: '账号资源在其他设备上刚刚更新,请再次同步。',
|
||||
account_invalid: '用户名或密码错误,或登录已过期。', account_required: '请先登录账号。',
|
||||
invitation_required: '该空间仅允许受邀账号加入,请登录受邀账号或联系空间主。',
|
||||
channel_password_required: '请输入空间加入密码。', channel_password_invalid: '空间加入密码不正确。',
|
||||
};
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
|
||||
+74
-10
@@ -3,7 +3,7 @@ import { previewKindOf } from '@/lib/fileKind';
|
||||
import type { WorkspaceApi } from './api';
|
||||
import { b64url } from './mockLinks';
|
||||
import { PHONE, SELF, archiveEntries, channel as demoChannel, devices as demoDevices, makeSvgBlob, makeVideoBlob, seedItems, snapshot as demoSnapshot } from './mockSeed';
|
||||
import type { ChannelInfo, ConnectionState, CreateOptions, Device, FileItem, Item, Snapshot, WorkspaceEvent } from './types';
|
||||
import type { ChannelInfo, ConnectionState, CreateOptions, Device, FileItem, FileUploadOptions, Item, Snapshot, WorkspaceEvent } from './types';
|
||||
|
||||
const wait = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -28,9 +28,9 @@ interface Runtime {
|
||||
demo: boolean;
|
||||
}
|
||||
|
||||
const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', online: true, self: true };
|
||||
const selfDevice: Device = { id: SELF, name: '本机', kind: 'desktop', self: true, joinedAt: Date.now() };
|
||||
|
||||
/** 频道的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */
|
||||
/** 空间的假实现:不联网,数据都在内存里,刷新即丢。接口见 api.ts */
|
||||
export function createMockApi(): WorkspaceApi {
|
||||
const runtimes = new Map<string, Runtime>();
|
||||
const timers = new Set<number>();
|
||||
@@ -68,7 +68,7 @@ export function createMockApi(): WorkspaceApi {
|
||||
return rt;
|
||||
}
|
||||
|
||||
/** 演示频道里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */
|
||||
/** 演示空间里的实时动静:iPhone 发来消息、改了文件、短暂编辑便签 */
|
||||
function runDemoActivity(rt: Runtime) {
|
||||
later(9000, () => put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: PHONE, state: { kind: 'complete' }, text: '到楼下了' }));
|
||||
later(14000, () => {
|
||||
@@ -82,13 +82,16 @@ export function createMockApi(): WorkspaceApi {
|
||||
}
|
||||
|
||||
return {
|
||||
async inviteUser() {},
|
||||
async listInvitations() { return []; },
|
||||
async revokeInvitation() {},
|
||||
async listSaved() { return []; },
|
||||
async forget() {},
|
||||
async createChannel(opts) {
|
||||
await wait(300);
|
||||
const id = b64url(16);
|
||||
const createdAt = Date.now();
|
||||
const info: ChannelInfo = { id, mode: opts.mode, role: 'owner', seats: opts.seats, createdAt, expiresAt: createdAt + opts.ttlSeconds * 1000, ...buildLinks(id, opts) };
|
||||
const info: ChannelInfo = { id, name: opts.name?.trim() || undefined, mode: opts.mode, role: 'owner', seats: opts.seats, seatsUsed: 1, membersCanUpload: opts.membersCanUpload, maxDownloads: opts.fileMaxDownloads ?? 10, ttlSeconds: opts.ttlSeconds, createdAt, expiresAt: createdAt + opts.ttlSeconds * 1000, ...buildLinks(id, opts) };
|
||||
newRuntime(info, [selfDevice]);
|
||||
return info;
|
||||
},
|
||||
@@ -106,12 +109,16 @@ export function createMockApi(): WorkspaceApi {
|
||||
mode: link.mode,
|
||||
role: link.isOwner ? 'owner' : 'member',
|
||||
seats: 3,
|
||||
seatsUsed: 1,
|
||||
membersCanUpload: true,
|
||||
maxDownloads: 10,
|
||||
ttlSeconds: 86400,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 86400_000,
|
||||
memberLink: member ? `${base}#${member}` : base,
|
||||
ownerLink: link.isOwner ? `${base}#${frag(link.keys)}` : undefined,
|
||||
};
|
||||
newRuntime(info, [selfDevice, { id: 'peer', name: '创建者的设备', kind: 'desktop', online: true }]);
|
||||
newRuntime(info, [selfDevice, { id: 'peer', name: '创建者的设备', kind: 'desktop', joinedAt: Date.now() }]);
|
||||
return info;
|
||||
},
|
||||
|
||||
@@ -140,16 +147,48 @@ export function createMockApi(): WorkspaceApi {
|
||||
};
|
||||
},
|
||||
|
||||
async sendText(text) {
|
||||
put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text });
|
||||
async sendText(text, threadId) {
|
||||
put({ id: uid(), kind: 'text', ts: Date.now(), deviceId: SELF, state: { kind: 'complete' }, text, ...(threadId ? { threadId } : {}) });
|
||||
},
|
||||
|
||||
async sendFiles(files) {
|
||||
async updateChannelSettings(patch) {
|
||||
if (!current || current.info.role !== 'owner') throw new Error('只有所有者可以修改空间设置');
|
||||
current.info = {
|
||||
...current.info,
|
||||
...(patch.name === undefined ? {} : { name: patch.name.trim() || undefined }),
|
||||
...(patch.seats === undefined ? {} : { seats: patch.seats }),
|
||||
...(patch.membersCanUpload === undefined ? {} : { membersCanUpload: patch.membersCanUpload, canUpload: current.info.role === 'owner' || patch.membersCanUpload }),
|
||||
...(patch.ttlSeconds === undefined ? {} : { expiresAt: Date.now() + patch.ttlSeconds * 1000 }),
|
||||
...(patch.fileMaxDownloads === undefined ? {} : { maxDownloads: patch.fileMaxDownloads }),
|
||||
};
|
||||
emit({ t: 'channel', info: current.info });
|
||||
},
|
||||
|
||||
async renameDevice(endpointHash, name) {
|
||||
if (!current) throw new Error('空间未打开');
|
||||
const device = current.devices.find((entry) => entry.id === endpointHash);
|
||||
if (!device) throw new Error('设备不存在');
|
||||
if (current.info.role !== 'owner' && !device.self) throw new Error('只有所有者可以重命名其他设备');
|
||||
current.devices = current.devices.map((entry) => entry.id === endpointHash ? { ...entry, name: name.trim() } : entry);
|
||||
emit({ t: 'devices', devices: current.devices });
|
||||
},
|
||||
|
||||
async releaseDevice(endpointHash) {
|
||||
if (!current || current.info.role !== 'owner') throw new Error('只有所有者可以移除设备');
|
||||
current.devices = current.devices.filter((entry) => entry.id !== endpointHash);
|
||||
current.info = { ...current.info, seatsUsed: current.devices.length };
|
||||
emit({ t: 'devices', devices: current.devices });
|
||||
emit({ t: 'channel', info: current.info });
|
||||
},
|
||||
|
||||
async sendFiles(files, options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId, folderId) {
|
||||
for (const file of files) {
|
||||
const id = uid();
|
||||
const base: FileItem = {
|
||||
id, kind: 'file', ts: Date.now(), deviceId: SELF, state: { kind: 'receiving', progress: 0 },
|
||||
name: file.name, mime: file.type, size: file.size, remaining: 10,
|
||||
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
|
||||
name: file.name, mime: file.type, size: file.size, remaining: options.directPreview || options.maxDownloads === 0 ? null : options.maxDownloads,
|
||||
gated: !options.directPreview,
|
||||
preview: previewKindOf(file.type, file.name),
|
||||
};
|
||||
current?.blobs.set(id, file);
|
||||
@@ -163,6 +202,31 @@ export function createMockApi(): WorkspaceApi {
|
||||
}
|
||||
},
|
||||
|
||||
async createThread(name, groupId) {
|
||||
const id = crypto.randomUUID();
|
||||
emit({ t: 'thread-created', thread: { id, name: name.trim(), createdAt: Date.now(), createdBy: SELF, ...(groupId ? { groupId } : {}) } });
|
||||
return id;
|
||||
},
|
||||
|
||||
async renameThread(threadId, name) { emit({ t: 'thread-renamed', id: threadId, name: name.trim() }); },
|
||||
async createGroup(name) { const id = crypto.randomUUID(); emit({ t: 'group-created', group: { id, name: name.trim(), createdAt: Date.now() } }); return id; },
|
||||
async renameGroup(id, name) { emit({ t: 'group-renamed', id, name: name.trim() }); },
|
||||
async groupThread(id, groupId) { emit({ t: 'thread-grouped', id, ...(groupId ? { groupId } : {}) }); },
|
||||
|
||||
async createFolder(threadId, name, parentId) {
|
||||
emit({ t: 'folder-created', folder: { id: crypto.randomUUID(), threadId, ...(parentId ? { parentId } : {}), name: name.trim() } });
|
||||
},
|
||||
|
||||
async renameFolder(folderId, _threadId, name) { emit({ t: 'folder-renamed', id: folderId, name: name.trim() }); },
|
||||
|
||||
async moveItems(itemIds, threadId, folderId) {
|
||||
for (const id of itemIds) {
|
||||
const item = current?.items.get(id);
|
||||
if (item) put({ ...item, threadId, folderId });
|
||||
}
|
||||
emit({ t: 'items-moved', itemIds, threadId, ...(folderId ? { folderId } : {}) });
|
||||
},
|
||||
|
||||
async editText(id, text) {
|
||||
const cur = current?.items.get(id);
|
||||
if (cur?.kind !== 'text') return;
|
||||
|
||||
@@ -9,9 +9,9 @@ export const DEMO_ID = 'k3Fq9xWm2LpA7sTdQ8vZ1n';
|
||||
const KEY = 'Zm9vYmFyYmF6cXV4Zm9vYmFyYmF6cXV4Zm9vYmFyYmF';
|
||||
|
||||
export const devices: Device[] = [
|
||||
{ id: SELF, name: '本机 · Chrome', kind: 'desktop', online: true, self: true },
|
||||
{ id: PHONE, name: 'iPhone', kind: 'phone', online: true },
|
||||
{ id: 'tablet', name: 'iPad', kind: 'tablet', online: false, lastSeen: Date.now() - 3 * 3600_000 },
|
||||
{ id: SELF, name: '本机 · Chrome', kind: 'desktop', self: true, joinedAt: Date.now() },
|
||||
{ id: PHONE, name: 'iPhone', kind: 'phone', joinedAt: Date.now() - 3600_000 },
|
||||
{ id: 'tablet', name: 'iPad', kind: 'tablet', joinedAt: Date.now() - 3 * 3600_000 },
|
||||
];
|
||||
|
||||
export const channel: ChannelInfo = {
|
||||
@@ -19,6 +19,7 @@ export const channel: ChannelInfo = {
|
||||
mode: 'enc',
|
||||
role: 'owner',
|
||||
seats: 3,
|
||||
seatsUsed: 3,
|
||||
createdAt: Date.now() - 3 * 3600_000,
|
||||
expiresAt: Date.now() + 21 * 3600_000,
|
||||
memberLink: `${location.origin}/c/${DEMO_ID}#k=${KEY}`,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
import type { AccountInvitation } from './account';
|
||||
import type { ParsedShareLink } from '@/lib/shareLink';
|
||||
import type { WorkspaceApi } from './api';
|
||||
import type { ShareApi } from './shareApi';
|
||||
import { callWorker, subscribeWorker } from './workerClient';
|
||||
import type { ArchiveEntry, ChannelInfo, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types';
|
||||
import type { ArchiveEntry, ChannelInfo, ChannelSettingsPatch, ConnectionState, CreateOptions, Share, ShareEvent, ShareSettings, WorkspaceEvent } from './types';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
|
||||
|
||||
@@ -15,7 +16,13 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) =
|
||||
|
||||
export const realApi: WorkspaceApi = {
|
||||
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, apiBase: API_BASE }),
|
||||
joinChannel: (link: ParsedChannelLink) => callWorker<ChannelInfo>('joinChannel', { link, apiBase: API_BASE }),
|
||||
updateChannelSettings: (patch: ChannelSettingsPatch) => callWorker<void>('updateChannelSettings', { id: currentChannelId(), patch, apiBase: API_BASE }),
|
||||
renameDevice: (endpointHash, name) => callWorker<void>('renameDevice', { id: currentChannelId(), endpointHash, name, apiBase: API_BASE }),
|
||||
releaseDevice: (endpointHash) => callWorker<void>('releaseDevice', { id: currentChannelId(), endpointHash, apiBase: API_BASE }),
|
||||
joinChannel: (link: ParsedChannelLink, password?: string) => callWorker<ChannelInfo>('joinChannel', { link, password, apiBase: API_BASE }),
|
||||
inviteUser: (username) => callWorker<void>('inviteChannelUser', { id: currentChannelId(), username, apiBase: API_BASE }),
|
||||
listInvitations: () => callWorker<AccountInvitation[]>('listChannelInvitations', { id: currentChannelId(), apiBase: API_BASE }),
|
||||
revokeInvitation: (username) => callWorker<void>('revokeChannelInvitation', { id: currentChannelId(), username, apiBase: API_BASE }),
|
||||
listSaved: () => callWorker<ChannelInfo[]>('listChannels', { apiBase: API_BASE }),
|
||||
forget: (id) => callWorker<void>('forgetChannel', { id, apiBase: API_BASE }),
|
||||
|
||||
@@ -30,9 +37,17 @@ export const realApi: WorkspaceApi = {
|
||||
};
|
||||
},
|
||||
|
||||
sendText: (text) => callWorker<void>('sendText', { id: currentChannelId(), text, apiBase: API_BASE }),
|
||||
sendFiles: (files) => callWorker<void>('sendFiles', { id: currentChannelId(), files, apiBase: API_BASE }),
|
||||
editText: (id, text) => callWorker<void>('editChannelText', { id: currentChannelId(), itemId: id, text, apiBase: API_BASE }),
|
||||
sendText: (text, threadId) => callWorker<void>('sendText', { id: currentChannelId(), text, threadId, apiBase: API_BASE }),
|
||||
sendFiles: (files, options, threadId, folderId) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, apiBase: API_BASE }),
|
||||
createThread: (name, groupId) => callWorker<string>('createThread', { id: currentChannelId(), name, groupId, apiBase: API_BASE }),
|
||||
createGroup: (name) => callWorker<string>('createGroup', { id: currentChannelId(), name, apiBase: API_BASE }),
|
||||
renameGroup: (groupId, name) => callWorker<void>('renameGroup', { id: currentChannelId(), groupId, name, apiBase: API_BASE }),
|
||||
groupThread: (threadId, groupId) => callWorker<void>('groupThread', { id: currentChannelId(), threadId, groupId, apiBase: API_BASE }),
|
||||
renameThread: (threadId, name) => callWorker<void>('renameThread', { id: currentChannelId(), threadId, name, apiBase: API_BASE }),
|
||||
createFolder: (threadId, name, parentId) => callWorker<void>('createFolder', { id: currentChannelId(), threadId, name, parentId, apiBase: API_BASE }),
|
||||
renameFolder: (folderId, threadId, name) => callWorker<void>('renameFolder', { id: currentChannelId(), folderId, threadId, name, apiBase: API_BASE }),
|
||||
moveItems: (itemIds, threadId, folderId) => callWorker<void>('moveItems', { id: currentChannelId(), itemIds, threadId, folderId, apiBase: API_BASE }),
|
||||
editText: (id, text, threadId) => callWorker<void>('editChannelText', { id: currentChannelId(), itemId: id, text, threadId, apiBase: API_BASE }),
|
||||
resolveConflict: (id, choice) => callWorker<void>('resolveTextConflict', { id: currentChannelId(), itemId: id, choice, apiBase: API_BASE }),
|
||||
remove: (id) => callWorker<void>('removeChannelItem', { id: currentChannelId(), itemId: id, apiBase: API_BASE }),
|
||||
setSnapshot: (text) => callWorker<void>('updateSnapshot', { id: currentChannelId(), text, apiBase: API_BASE }),
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ParsedShareLink } from '@/lib/shareLink';
|
||||
import type { ArchiveEntry, Share, ShareEvent, ShareSettings } from './types';
|
||||
|
||||
/**
|
||||
* 便捷分享的后端边界。和频道分开:分享没有设备、便签、冲突与历史,只有
|
||||
* 便捷分享的后端边界。和空间分开:分享没有设备、便签、冲突与历史,只有
|
||||
* 「草稿 → 封存 → 过期」。线路上仍是 channels(成员不可上传),所以由同一个 core 会话实现。
|
||||
*/
|
||||
export interface ShareApi {
|
||||
|
||||
@@ -6,19 +6,29 @@ export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: DeviceKind;
|
||||
online: boolean;
|
||||
self?: boolean;
|
||||
/** 正在编辑便签 */
|
||||
editing?: boolean;
|
||||
lastSeen?: number;
|
||||
joinedAt?: number;
|
||||
}
|
||||
|
||||
/** 常驻频道:自己多台设备之间长期同步 */
|
||||
/** 常驻空间:自己多台设备之间长期同步 */
|
||||
export interface ChannelInfo {
|
||||
creatorAccount?: string;
|
||||
joinPolicy?: 'open' | 'invite';
|
||||
allowGuests?: boolean;
|
||||
passwordRequired?: boolean;
|
||||
id: string;
|
||||
/** 服务端 channel settings 中的共享名称,加入者可见。 */
|
||||
name?: string;
|
||||
mode: 'enc' | 'raw';
|
||||
role: 'owner' | 'member';
|
||||
seats: number;
|
||||
seatsUsed?: number;
|
||||
membersCanUpload?: boolean;
|
||||
ttlSeconds?: number;
|
||||
/** 每个新上传文件的默认下载次数上限。 */
|
||||
maxDownloads?: number;
|
||||
memberLink: string;
|
||||
/** 只有所有者有 */
|
||||
ownerLink?: string;
|
||||
@@ -27,12 +37,34 @@ export interface ChannelInfo {
|
||||
canUpload?: boolean;
|
||||
}
|
||||
|
||||
/** 新建频道的选项(protocol §12 设置的子集) */
|
||||
/** 新建空间的选项(protocol §12 设置的子集) */
|
||||
export interface CreateOptions {
|
||||
joinPolicy?: 'open' | 'invite';
|
||||
allowGuests?: boolean;
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
mode: 'enc' | 'raw';
|
||||
seats: number;
|
||||
ttlSeconds: number;
|
||||
membersCanUpload: boolean;
|
||||
fileMaxDownloads?: number;
|
||||
}
|
||||
|
||||
export interface FileUploadOptions {
|
||||
/** 单帧文件可随空间同步,加入者能立即预览;多帧文件仍由空间上限保护。 */
|
||||
directPreview: boolean;
|
||||
maxDownloads: number;
|
||||
}
|
||||
|
||||
export interface ChannelSettingsPatch {
|
||||
joinPolicy?: 'open' | 'invite';
|
||||
allowGuests?: boolean;
|
||||
joinPassword?: string;
|
||||
name?: string;
|
||||
seats?: number;
|
||||
membersCanUpload?: boolean;
|
||||
ttlSeconds?: number;
|
||||
fileMaxDownloads?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,12 +103,17 @@ export type ItemState =
|
||||
| { kind: 'tampered' };
|
||||
|
||||
interface ItemBase {
|
||||
author?: SenderInfo;
|
||||
id: string;
|
||||
ts: number;
|
||||
deviceId: string;
|
||||
state: ItemState;
|
||||
threadId?: string;
|
||||
folderId?: string;
|
||||
}
|
||||
|
||||
export interface SenderInfo { username: string; displayName: string; avatar?: string }
|
||||
|
||||
export interface TextItem extends ItemBase {
|
||||
kind: 'text';
|
||||
text: string;
|
||||
@@ -94,6 +131,8 @@ export interface FileItem extends ItemBase {
|
||||
remaining: number | null;
|
||||
preview: PreviewKind;
|
||||
gated?: boolean;
|
||||
/** announce-only placeholder; omit thread/folder/name until explicit item read. */
|
||||
metadataHidden?: boolean;
|
||||
/**
|
||||
* 旧版本,新的在前,不含当前版本。
|
||||
* 约定(待写入 protocol §7):新版本是一个新条目,其加密的 meta_json 带 `replaces`,
|
||||
@@ -119,13 +158,17 @@ export interface ArchiveEntry {
|
||||
dir: boolean;
|
||||
}
|
||||
|
||||
/** protocol §9:频道唯一的快照槽,最后写入者胜 */
|
||||
/** protocol §9:空间唯一的快照槽,最后写入者胜 */
|
||||
export interface Snapshot {
|
||||
text: string;
|
||||
updatedBy: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface ThreadInfo { id: string; name: string; createdAt: number; createdBy: string; groupId?: string }
|
||||
export interface ChannelGroupInfo { id: string; name: string; createdAt: number }
|
||||
export interface FolderInfo { id: string; threadId: string; parentId?: string; name: string }
|
||||
|
||||
export type WorkspaceEvent =
|
||||
| { t: 'reset' }
|
||||
| { t: 'error'; message: string }
|
||||
@@ -134,7 +177,17 @@ export type WorkspaceEvent =
|
||||
| { t: 'channel'; info: ChannelInfo }
|
||||
| { t: 'devices'; devices: Device[] }
|
||||
| { t: 'item'; item: Item }
|
||||
| { t: 'file-catalog'; item: FileItem }
|
||||
| { t: 'item-author'; id: string; author: SenderInfo }
|
||||
| { t: 'removed'; id: string }
|
||||
| { t: 'thread-created'; thread: ThreadInfo }
|
||||
| { t: 'thread-renamed'; id: string; name: string }
|
||||
| { t: 'group-created'; group: ChannelGroupInfo }
|
||||
| { t: 'group-renamed'; id: string; name: string }
|
||||
| { t: 'thread-grouped'; id: string; groupId?: string }
|
||||
| { t: 'folder-created'; folder: FolderInfo }
|
||||
| { t: 'folder-renamed'; id: string; name: string }
|
||||
| { t: 'items-moved'; itemIds: string[]; threadId: string; folderId?: string }
|
||||
| { t: 'snapshot'; snapshot: Snapshot };
|
||||
|
||||
export type ShareEvent =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { setApiBase } from './worker/http';
|
||||
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
|
||||
import { createChannel, forgetChannel, joinChannel, listChannels } from './worker/channelResources';
|
||||
import { createChannel, forgetChannel, joinChannel, listChannels, releaseDevice, renameDevice, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
|
||||
import { editChannelText, sendFiles, sendText, updateSnapshot } from './worker/channelSend';
|
||||
import { stopChannel } from './worker/channelTransport';
|
||||
import { addShareFiles, addShareText, editShareText, removeShareItem } from './worker/shareDraft';
|
||||
@@ -10,6 +10,9 @@ import { sealShare } from './worker/shareSeal';
|
||||
import { setEventSink } from './worker/port';
|
||||
import { serial } from './worker/serial';
|
||||
import { errorMessage } from './errors';
|
||||
import { accountStatus, loginAccount, logoutAccount, registerAccount, syncAccount, listAccountInvitations, acceptInvitation, inviteChannelUser, listChannelInvitations, revokeChannelInvitation } from './worker/account';
|
||||
import { createFolder, createThread, createGroup, renameGroup, groupThread, moveItems, renameFolder, renameThread } from './worker/channelThreads';
|
||||
import { restoreAccount, updatePersonal, listAccountDevices, renameAccountDevice, revokeAccountDevice } from './worker/account';
|
||||
|
||||
interface WorkerRequest { requestId: number; operation: string; payload: unknown }
|
||||
interface WorkerScope {
|
||||
@@ -23,20 +26,48 @@ setEventSink((resourceId, event) => scope.postMessage({ resourceId, event }));
|
||||
async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
const payload = input as Record<string, unknown>;
|
||||
if (typeof payload.apiBase === 'string') setApiBase(payload.apiBase);
|
||||
await restoreAccount();
|
||||
const id = typeof payload.id === 'string' ? payload.id : '';
|
||||
switch (operation) {
|
||||
case 'updatePersonal': await updatePersonal(String(payload.displayName ?? ''), typeof payload.avatar === 'string' ? payload.avatar : undefined); return accountStatus();
|
||||
case 'accountDevices': return listAccountDevices();
|
||||
case 'renameAccountDevice': await renameAccountDevice(id, String(payload.name ?? '')); return true;
|
||||
case 'revokeAccountDevice': await revokeAccountDevice(id); return accountStatus();
|
||||
case 'registerAccount': return registerAccount(String(payload.username ?? ''), String(payload.password ?? ''));
|
||||
case 'loginAccount': return loginAccount(String(payload.username ?? ''), String(payload.password ?? ''));
|
||||
case 'accountStatus': return accountStatus();
|
||||
case 'logoutAccount': await logoutAccount(); return true;
|
||||
case 'syncAccount': await syncAccount(); return accountStatus();
|
||||
case 'accountInvitations': return listAccountInvitations();
|
||||
case 'acceptInvitation': return acceptInvitation(id);
|
||||
case 'inviteChannelUser': return inviteChannelUser(id, String(payload.username ?? ''));
|
||||
case 'listChannelInvitations': return listChannelInvitations(id);
|
||||
case 'revokeChannelInvitation': return revokeChannelInvitation(id, String(payload.username ?? ''));
|
||||
case 'createChannel': return createChannel(payload.options as Parameters<typeof createChannel>[0], typeof payload.captchaToken === 'string' ? payload.captchaToken : undefined);
|
||||
case 'updateChannelSettings': return updateChannelSettings(id, payload.patch as Parameters<typeof updateChannelSettings>[1]);
|
||||
case 'createThread': return createThread(id, String(payload.name ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined);
|
||||
case 'createGroup': return createGroup(id, String(payload.name ?? ''));
|
||||
case 'renameGroup': return renameGroup(id, String(payload.groupId ?? ''), String(payload.name ?? ''));
|
||||
case 'groupThread': return groupThread(id, String(payload.threadId ?? ''), typeof payload.groupId === 'string' ? payload.groupId : undefined);
|
||||
case 'renameThread': return renameThread(id, String(payload.threadId ?? ''), String(payload.name ?? ''));
|
||||
case 'createFolder': return createFolder(id, String(payload.threadId ?? ''), String(payload.name ?? ''), typeof payload.parentId === 'string' ? payload.parentId : undefined);
|
||||
case 'renameFolder': return renameFolder(id, String(payload.folderId ?? ''), String(payload.name ?? ''), String(payload.threadId ?? ''));
|
||||
case 'moveItems': return moveItems(id, payload.itemIds as string[], String(payload.threadId ?? ''), typeof payload.folderId === 'string' ? payload.folderId : undefined);
|
||||
case 'renameDevice': return renameDevice(id, String(payload.endpointHash ?? ''), String(payload.name ?? ''));
|
||||
case 'releaseDevice': return releaseDevice(id, String(payload.endpointHash ?? ''));
|
||||
case 'joinChannel': return joinChannel(payload as Parameters<typeof joinChannel>[0]);
|
||||
case 'previewChannel': return previewChannel(payload.link as Parameters<typeof previewChannel>[0]);
|
||||
case 'channelMembers': return channelMembers(id);
|
||||
case 'listChannels': return listChannels();
|
||||
case 'forgetChannel': return forgetChannel(id);
|
||||
case 'connectChannel': void connectChannelResource(id); return true;
|
||||
case 'stopChannel': stopChannel(id); return true;
|
||||
case 'sendText': return sendText(id, String(payload.text ?? ''));
|
||||
case 'sendFiles': return sendFiles(id, payload.files as File[]);
|
||||
case 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined);
|
||||
case 'sendFiles': return sendFiles(id, payload.files as File[], payload.options as Parameters<typeof sendFiles>[2] | undefined, typeof payload.threadId === 'string' ? payload.threadId : undefined, typeof payload.folderId === 'string' ? payload.folderId : undefined);
|
||||
case 'removeChannelItem': return removeChannelItem(id, String(payload.itemId));
|
||||
case 'getChannelBlob': return getChannelBlob(id, String(payload.itemId), typeof payload.versionId === 'string' ? payload.versionId : undefined);
|
||||
case 'updateSnapshot': return updateSnapshot(id, String(payload.text ?? ''));
|
||||
case 'editChannelText': return editChannelText(id, String(payload.itemId), String(payload.text ?? ''));
|
||||
case 'editChannelText': return editChannelText(id, String(payload.itemId), String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined);
|
||||
case 'resolveTextConflict': return resolveTextConflict();
|
||||
case 'markChannelSeen': return markChannelSeen();
|
||||
case 'listArchive': return listArchive(id);
|
||||
@@ -59,11 +90,7 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
|
||||
|
||||
scope.onmessage = (event) => {
|
||||
const request = event.data;
|
||||
const payload = request.payload as { id?: string; link?: { id: string } };
|
||||
const key = payload.id ?? payload.link?.id;
|
||||
const readOnly = /^(connect|stop|list|get)/.test(request.operation);
|
||||
const task = key && !readOnly ? serial(key, () => dispatch(request.operation, request.payload))
|
||||
: dispatch(request.operation, request.payload);
|
||||
const task = serial('account-scope', () => dispatch(request.operation, request.payload));
|
||||
void task.then(
|
||||
(result) => scope.postMessage({ requestId: request.requestId, result }),
|
||||
(error: unknown) => scope.postMessage({
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { importResources, importResourceDeletes, listAllResources, listResourceDeletes, setResourceChangeListener, setResourceScope, type SavedResource } from '../database';
|
||||
import { apiFetch } from './http';
|
||||
import { clearResourceSessions, getSession } from './sessions';
|
||||
import { setAccountIdentity, requireAccount } from './accountSession';
|
||||
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys } from './accountCrypto';
|
||||
import { mergeVault, validateVault, validatePersonal, type VaultProfile, type PersonalProfile } from './accountVault';
|
||||
import { joinChannel } from './channelResources';
|
||||
import { openChannelGrant, sealChannelGrant } from './channelGrant';
|
||||
import { recoverAccountChannels } from './accountChannels';
|
||||
import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName } from './accountPersistence';
|
||||
|
||||
interface VaultBody { revision: number; vault: string }
|
||||
interface AccountSession {
|
||||
username: string; token: string; key: CryptoKey; profile: VaultProfile; revision: number;
|
||||
controller: AbortController; dirty: boolean; syncing?: Promise<void>; error?: string;
|
||||
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
|
||||
}
|
||||
export interface Invitation { channel_id: string; username: string; payload: string; created_at: number }
|
||||
let account: AccountSession | undefined;
|
||||
let restoration: Promise<void> | undefined;
|
||||
function usernameOf(input: string): string {
|
||||
const username = input.trim().toLowerCase();
|
||||
if (!/^[a-z0-9_-]{3,32}$/.test(username)) throw new Error('用户名需为 3–32 位字母、数字、下划线或短横线');
|
||||
return username;
|
||||
}
|
||||
function headers(session: Pick<AccountSession, 'token'>): Record<string, string> { return { 'X-WD-Account': session.token }; }
|
||||
function stillActive(session: AccountSession): void {
|
||||
if (account !== session || session.controller.signal.aborted) throw new Error('账号已切换,操作已停止');
|
||||
}
|
||||
async function applyProfile(profile: VaultProfile): Promise<void> {
|
||||
await importResources(profile.resources.map((row) => ({ ...row, endpointSecret: new Uint8Array(32) } as SavedResource)));
|
||||
await importResourceDeletes(profile.deletes);
|
||||
}
|
||||
async function syncVault(): Promise<void> {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
const session = account;
|
||||
if (session.syncing) { session.dirty = true; return session.syncing; }
|
||||
session.syncing = (async () => {
|
||||
await recoverAccountChannels(session.username, session.profile.keys, session.controller.signal);
|
||||
do {
|
||||
session.dirty = false;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const remote = await apiFetch('/accounts/vault', { headers: headers(session), signal: session.controller.signal }).then((r) => r.json() as Promise<VaultBody>);
|
||||
const profile = validateVault(await openJson(remote.vault, session.key));
|
||||
if (profile.keys.publicKey.x !== session.profile.keys.publicKey.x || profile.keys.publicKey.y !== session.profile.keys.publicKey.y) throw new Error('账号邀请密钥发生冲突,已停止同步');
|
||||
const merged = mergeVault(profile, await listAllResources(), await listResourceDeletes(), session.profile.personal);
|
||||
if (JSON.stringify(merged) === JSON.stringify(profile)) {
|
||||
stillActive(session); await applyProfile(merged);
|
||||
session.profile = merged; session.revision = remote.revision; session.error = undefined;
|
||||
setAccountIdentity(session, merged.keys.publicKey, merged.personal);
|
||||
await rememberAccount({ username: session.username, token: session.token, key: session.key, ...remote });
|
||||
break;
|
||||
}
|
||||
const vault = await sealJson(merged, session.key);
|
||||
stillActive(session);
|
||||
try {
|
||||
const result = await apiFetch('/accounts/vault', { method: 'PUT', headers: { ...headers(session), 'Content-Type': 'application/json' },
|
||||
signal: session.controller.signal, body: JSON.stringify({ revision: remote.revision, vault }) }).then((r) => r.json() as Promise<VaultBody>);
|
||||
stillActive(session);
|
||||
await applyProfile(merged);
|
||||
session.profile = merged; session.revision = result.revision; session.error = undefined;
|
||||
setAccountIdentity(session, merged.keys.publicKey, merged.personal);
|
||||
await rememberAccount({ username: session.username, token: session.token, key: session.key, vault, revision: result.revision });
|
||||
break;
|
||||
} catch (error) {
|
||||
if (attempt === 2 || !(error instanceof Error && 'status' in error && error.status === 409)) throw error;
|
||||
}
|
||||
}
|
||||
} while (session.dirty && account === session);
|
||||
})().catch((error: unknown) => {
|
||||
if (account === session) session.error = error instanceof Error ? error.message : '同步失败';
|
||||
if (account === session && error instanceof Error && 'status' in error && error.status === 401) {
|
||||
account = undefined; clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort();
|
||||
setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null);
|
||||
void forgetAccount();
|
||||
}
|
||||
throw error;
|
||||
}).finally(() => { session.syncing = undefined; });
|
||||
return session.syncing;
|
||||
}
|
||||
|
||||
async function activate(identity: { username: string; token: string }, key: CryptoKey, remote: VaultBody): Promise<AccountSession> {
|
||||
const profile = validateVault(await openJson(remote.vault, key));
|
||||
clearResourceSessions(); setResourceScope(identity.username);
|
||||
const session: AccountSession = { ...identity, key, profile, revision: remote.revision, controller: new AbortController(), dirty: false };
|
||||
account = session; setAccountIdentity(identity, profile.keys.publicKey, profile.personal);
|
||||
await applyProfile(mergeVault(profile, await listAllResources(), await listResourceDeletes()));
|
||||
setResourceChangeListener(scheduleSync);
|
||||
session.polling = setInterval(scheduleSync, 20_000);
|
||||
return session;
|
||||
}
|
||||
|
||||
export function restoreAccount(): Promise<void> {
|
||||
restoration ??= (async () => {
|
||||
const saved = await rememberedAccount();
|
||||
if (!saved || account) return;
|
||||
let remote: VaultBody;
|
||||
try {
|
||||
remote = await apiFetch('/accounts/vault', { headers: headers(saved) }).then((r) => r.json() as Promise<VaultBody>);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && 'status' in error && error.status === 401) { await forgetAccount(); return; }
|
||||
remote = saved;
|
||||
}
|
||||
try {
|
||||
const session = await activate(saved, saved.key, remote);
|
||||
const local = validateVault(await openJson(saved.vault, saved.key));
|
||||
if (local.personal && local.personal.modifiedAt > (session.profile.personal?.modifiedAt ?? -1)) session.profile = { ...session.profile, personal: local.personal };
|
||||
setAccountIdentity(session, session.profile.keys.publicKey, session.profile.personal);
|
||||
await syncVault().catch(() => {});
|
||||
} catch (error) { await logoutAccount(); throw error; }
|
||||
})().catch((error: unknown) => { restoration = undefined; throw error; });
|
||||
return restoration;
|
||||
}
|
||||
function scheduleSync(): void {
|
||||
if (!account) return;
|
||||
account.dirty = true; clearTimeout(account.timer);
|
||||
account.timer = setTimeout(() => { void syncVault().catch(() => {}); }, 700);
|
||||
}
|
||||
|
||||
export async function registerAccount(input: string, password: string): Promise<Awaited<ReturnType<typeof accountStatus>>> {
|
||||
if (account) throw new Error('请先退出当前账号');
|
||||
const username = usernameOf(input);
|
||||
if (password.length < 12 || password.length > 1024) throw new Error('密码长度需为 12–1024 个字符');
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const { verifier, key } = await deriveAccountKeys(password, salt);
|
||||
const profile: VaultProfile = { v: 2, keys: await generateInvitationKeys(), resources: [], deletes: [] };
|
||||
try {
|
||||
await apiFetch('/accounts', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, salt: b64(salt), verifier: b64(verifier), public_key: JSON.stringify(profile.keys.publicKey), vault: await sealJson(profile, key) }) });
|
||||
} finally { verifier.fill(0); }
|
||||
return loginAccount(username, password);
|
||||
}
|
||||
export async function loginAccount(input: string, password: string): Promise<Awaited<ReturnType<typeof accountStatus>>> {
|
||||
if (account) throw new Error('请先退出当前账号');
|
||||
const username = usernameOf(input);
|
||||
const challenge = await apiFetch('/accounts/salt', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) })
|
||||
.then((r) => r.json() as Promise<{ salt: string }>);
|
||||
const { verifier, key } = await deriveAccountKeys(password, unb64(challenge.salt));
|
||||
let identity: { username: string; token: string };
|
||||
try {
|
||||
const device = await localAccountDevice();
|
||||
identity = await apiFetch('/accounts/session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, verifier: b64(verifier), device_id: device.id, device_name: device.name }) })
|
||||
.then((r) => r.json() as Promise<{ username: string; token: string }>);
|
||||
} finally { verifier.fill(0); }
|
||||
try {
|
||||
const remote = await apiFetch('/accounts/vault', { headers: headers(identity) }).then((r) => r.json() as Promise<VaultBody>);
|
||||
const profile = validateVault(await openJson(remote.vault, key));
|
||||
const publicKey = await apiFetch(`/accounts/users/${username}/key`, { headers: headers(identity) }).then((r) => r.json() as Promise<{ public_key: string }>);
|
||||
const published = JSON.parse(publicKey.public_key) as JsonWebKey;
|
||||
if (published.x !== profile.keys.publicKey.x || published.y !== profile.keys.publicKey.y) throw new Error('账号邀请公钥与保险库不匹配');
|
||||
await activate(identity, key, remote);
|
||||
await rememberAccount({ ...identity, key, ...remote });
|
||||
await syncVault().catch(() => {});
|
||||
return accountStatus();
|
||||
} catch (error) {
|
||||
await logoutAccount();
|
||||
void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(identity) }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export async function accountStatus(): Promise<{ username: string | null; revision: number; personal?: PersonalProfile; error?: string }> {
|
||||
return { username: account?.username ?? null, revision: account?.revision ?? 0,
|
||||
...(account?.profile.personal ? { personal: account.profile.personal } : {}),
|
||||
...(account?.error ? { error: '账号资源同步失败,请重试。' } : {}) };
|
||||
}
|
||||
export async function logoutAccount(): Promise<void> {
|
||||
const session = account; account = undefined;
|
||||
if (session) { clearTimeout(session.timer); clearInterval(session.polling); session.controller.abort(); await session.syncing?.catch(() => {}); }
|
||||
setResourceChangeListener(undefined); setAccountIdentity(undefined); clearResourceSessions(); setResourceScope(null);
|
||||
await forgetAccount();
|
||||
if (session) void apiFetch('/accounts/session', { method: 'DELETE', headers: headers(session) }).catch(() => {});
|
||||
}
|
||||
export async function syncAccount(): Promise<void> { await syncVault(); }
|
||||
export async function updatePersonal(displayName: string, avatar?: string): Promise<void> {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
account.profile = { ...account.profile, personal: validatePersonal({ displayName, ...(avatar ? { avatar } : {}), modifiedAt: Date.now() }) };
|
||||
setAccountIdentity(account, account.profile.keys.publicKey, account.profile.personal);
|
||||
// Retain an offline profile edit for the next session restoration.
|
||||
await rememberAccount({ username: account.username, token: account.token, key: account.key, vault: await sealJson(account.profile, account.key), revision: account.revision });
|
||||
await syncVault();
|
||||
}
|
||||
export async function listAccountDevices(): Promise<unknown> { requireAccount(); return apiFetch('/accounts/devices').then((r) => r.json()); }
|
||||
export async function renameAccountDevice(id: string, name: string): Promise<void> {
|
||||
requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) });
|
||||
if ((await localAccountDevice()).id === id) await saveDeviceName(name.trim());
|
||||
}
|
||||
export async function revokeAccountDevice(id: string): Promise<void> {
|
||||
requireAccount(); await apiFetch(`/accounts/devices/${id}`, { method: 'DELETE' });
|
||||
if ((await localAccountDevice()).id === id) await logoutAccount();
|
||||
}
|
||||
export async function listAccountInvitations(): Promise<Invitation[]> {
|
||||
requireAccount(); return apiFetch('/accounts/invitations').then((r) => r.json() as Promise<Invitation[]>);
|
||||
}
|
||||
export async function acceptInvitation(channelId: string): Promise<void> {
|
||||
if (!account) throw new Error('请先登录账号');
|
||||
const session = account;
|
||||
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();
|
||||
}
|
||||
export async function inviteChannelUser(channelId: string, input: string): Promise<void> {
|
||||
requireAccount(); const username = usernameOf(input); const { session, resource } = await getSession(channelId);
|
||||
if (!session.can('owner')) throw new Error('只有空间主可以邀请用户');
|
||||
const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise<{ public_key: string }>);
|
||||
const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey);
|
||||
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload }) });
|
||||
}
|
||||
export async function listChannelInvitations(channelId: string): Promise<Invitation[]> {
|
||||
const { session } = await getSession(channelId); return apiFetch(`/channels/${channelId}/invitations`, { headers: session.authHeaders('owner') }).then((r) => r.json() as Promise<Invitation[]>);
|
||||
}
|
||||
export async function revokeChannelInvitation(channelId: string, input: string): Promise<void> {
|
||||
const { session } = await getSession(channelId); await apiFetch(`/channels/${channelId}/invitations/${usernameOf(input)}`, { method: 'DELETE', headers: session.authHeaders('owner') });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { getResource, importResources, listAllResources, listResourceDeletes, type SavedResource } from '../database';
|
||||
import { openChannelGrant, sealChannelGrant } from './channelGrant';
|
||||
import { makeLink } from './links';
|
||||
import { apiFetch, readJson } from './http';
|
||||
import type { InvitationKeyPair } from './accountCrypto';
|
||||
import type { ChannelWire } from './wire';
|
||||
|
||||
interface AccountChannel { channel_id: string; role: 'owner' | 'member'; payload: string; created_at: number }
|
||||
|
||||
export async function recoverAccountChannels(username: string, keys: InvitationKeyPair, signal: AbortSignal): Promise<void> {
|
||||
const grants = await apiFetch('/accounts/channels', { signal }).then((r) => r.json() as Promise<AccountChannel[]>);
|
||||
const deletes = new Map((await listResourceDeletes()).map((row) => [row.id, row.modifiedAt]));
|
||||
const known = new Set(grants.map((row) => row.channel_id));
|
||||
for (const grant of grants) {
|
||||
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
|
||||
if ((deletes.get(grant.channel_id) ?? -1) >= grant.created_at || await getResource(grant.channel_id)) continue;
|
||||
const parsed = await openChannelGrant(grant.payload, username, grant.channel_id, keys.privateKey, grant.role, self.location.origin);
|
||||
const session = await ChannelSession.open(makeLink(self.location.origin, parsed.id, parsed.keys));
|
||||
try {
|
||||
const info = await readJson<ChannelWire>(`/channels/${session.id}`, session.authHeaders('read'));
|
||||
session.verifyChannelInfo(info);
|
||||
if (grant.role === 'owner' && info.creator_account !== username) throw new Error('空间所属账号不匹配');
|
||||
const resource: SavedResource = { id: session.id, kind: 'channel', mode: session.mode, role: grant.role,
|
||||
creatorAccount: info.creator_account, name: info.settings.name,
|
||||
link: session.link(self.location.origin), ...(grant.role === 'owner' ? { ownerLink: session.link(self.location.origin, 'owner') } : {}),
|
||||
endpointSecret: new Uint8Array(32), createdAt: info.created_at, modifiedAt: grant.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload, maxDownloads: info.settings.file_max_downloads };
|
||||
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
|
||||
await importResources([resource]);
|
||||
} finally { session.dispose(); }
|
||||
}
|
||||
// Populate owner grants for channels created before account-bound delivery existed.
|
||||
for (const resource of await listAllResources()) {
|
||||
if (resource.kind !== 'channel' || resource.role !== 'owner' || !resource.ownerLink || known.has(resource.id)) continue;
|
||||
const session = await ChannelSession.open(resource.ownerLink, { endpointSecret: resource.endpointSecret });
|
||||
try {
|
||||
const payload = await sealChannelGrant(resource.ownerLink, username, keys.publicKey);
|
||||
await apiFetch(`/channels/${resource.id}/key-package`, { method: 'PUT', signal,
|
||||
headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ payload }) });
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error && 'status' in error && error.status === 404)) throw error;
|
||||
} finally { session.dispose(); }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decryptInvitation, encryptInvitation, generateInvitationKeys, openJson, sealJson } from './accountCrypto';
|
||||
describe('encrypted invitation recipient binding', () => {
|
||||
it('only the addressed account and channel can decrypt the member link', async () => {
|
||||
const bob = await generateInvitationKeys(); const carol = await generateInvitationKeys();
|
||||
const payload = await encryptInvitation('https://drop.test/c/channel#k=secret', 'bob', 'channel', bob.publicKey);
|
||||
expect(payload).not.toContain('secret');
|
||||
expect(await decryptInvitation(payload, 'bob', 'channel', bob.privateKey)).toContain('#k=secret');
|
||||
await expect(decryptInvitation(payload, 'carol', 'channel', carol.privateKey)).rejects.toThrow();
|
||||
await expect(decryptInvitation(payload, 'bob', 'other-channel', bob.privateKey)).rejects.toThrow();
|
||||
});
|
||||
it('modified encrypted vault contents are rejected', async () => {
|
||||
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
const encrypted = await sealJson({ resources: [] }, key);
|
||||
const raw = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0)); raw[13] = (raw[13] ?? 0) ^ 1;
|
||||
const corrupted = btoa(String.fromCharCode(...raw));
|
||||
await expect(openJson(corrupted, key)).rejects.toThrow('校验失败');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
const encoder = new TextEncoder();
|
||||
const vaultLabel = 'webdrop-account-vault-v1';
|
||||
const invitationLabel = 'webdrop-invitation-v2';
|
||||
export function bytes(value: Uint8Array): ArrayBuffer { return Uint8Array.from(value).buffer; }
|
||||
export function b64(value: Uint8Array): string {
|
||||
let text = '';
|
||||
for (let i = 0; i < value.length; i += 32768) text += String.fromCharCode(...value.subarray(i, i + 32768));
|
||||
return btoa(text);
|
||||
}
|
||||
export function unb64(value: string): Uint8Array { return Uint8Array.from(atob(value), (c) => c.charCodeAt(0)); }
|
||||
export async function deriveAccountKeys(password: string, salt: Uint8Array): Promise<{ verifier: Uint8Array; key: CryptoKey }> {
|
||||
if (salt.length !== 16 || password.length > 1024) throw new Error('账号凭据格式无效');
|
||||
const base = await crypto.subtle.importKey('raw', encoder.encode(password), 'PBKDF2', false, ['deriveBits']);
|
||||
const root = new Uint8Array(await crypto.subtle.deriveBits({ name: 'PBKDF2', hash: 'SHA-256', salt: bytes(salt), iterations: 600_000 }, base, 256));
|
||||
try {
|
||||
const hmac = await crypto.subtle.importKey('raw', root, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
||||
const verifier = new Uint8Array(await crypto.subtle.sign('HMAC', hmac, encoder.encode('webdrop-account-login-v1')));
|
||||
const hkdf = await crypto.subtle.importKey('raw', root, 'HKDF', false, ['deriveKey']);
|
||||
const key = await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: bytes(salt), info: encoder.encode(vaultLabel) }, hkdf,
|
||||
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
return { verifier, key };
|
||||
} finally { root.fill(0); }
|
||||
}
|
||||
export async function sealJson(value: unknown, key: CryptoKey, context = vaultLabel): Promise<string> {
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const cipher = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData: encoder.encode(context) }, key, encoder.encode(JSON.stringify(value))));
|
||||
const result = new Uint8Array(nonce.length + cipher.length); result.set(nonce); result.set(cipher, 12);
|
||||
if (result.length > 1_300_000) throw new Error('账号保险库已达到大小上限');
|
||||
return b64(result);
|
||||
}
|
||||
export async function openJson(value: string, key: CryptoKey, context = vaultLabel): Promise<unknown> {
|
||||
const input = unb64(value);
|
||||
if (input.length < 28 || input.length > 1_300_000) throw new Error('加密数据格式无效');
|
||||
try {
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes(input.subarray(0, 12)), additionalData: encoder.encode(context) }, key, bytes(input.subarray(12)));
|
||||
return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(plain));
|
||||
} catch { throw new Error('加密数据校验失败,已拒绝导入'); }
|
||||
}
|
||||
export interface InvitationKeyPair { publicKey: JsonWebKey; privateKey: JsonWebKey }
|
||||
export async function generateInvitationKeys(): Promise<InvitationKeyPair> {
|
||||
const pair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
|
||||
return { publicKey: await crypto.subtle.exportKey('jwk', pair.publicKey), privateKey: await crypto.subtle.exportKey('jwk', pair.privateKey) };
|
||||
}
|
||||
async function invitationKey(privateKey: JsonWebKey, publicKey: JsonWebKey, context: string): Promise<CryptoKey> {
|
||||
const own = await crypto.subtle.importKey('jwk', privateKey, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']);
|
||||
const peer = await crypto.subtle.importKey('jwk', publicKey, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
|
||||
const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: 'ECDH', public: peer }, own, 256));
|
||||
try {
|
||||
const root = await crypto.subtle.importKey('raw', shared, 'HKDF', false, ['deriveKey']);
|
||||
return await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: encoder.encode(context), info: encoder.encode(invitationLabel) }, root,
|
||||
{ name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
||||
} finally { shared.fill(0); }
|
||||
}
|
||||
export async function encryptInvitation(link: string, recipient: string, channelId: string, publicKey: JsonWebKey): Promise<string> {
|
||||
const pair = await generateInvitationKeys(); const context = `${invitationLabel}:${recipient}:${channelId}`;
|
||||
const key = await invitationKey(pair.privateKey, publicKey, context);
|
||||
return JSON.stringify({ v: 2, publicKey: pair.publicKey, cipher: await sealJson({ link }, key, context) });
|
||||
}
|
||||
export async function decryptInvitation(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey): Promise<string> {
|
||||
const envelope = JSON.parse(payload) as { v: number; publicKey: JsonWebKey; cipher: string };
|
||||
if (envelope.v !== 2 || typeof envelope.cipher !== 'string') throw new Error('邀请格式无效');
|
||||
const context = `${invitationLabel}:${recipient}:${channelId}`;
|
||||
const key = await invitationKey(privateKey, envelope.publicKey, context);
|
||||
const value = await openJson(envelope.cipher, key, context) as { link?: unknown };
|
||||
if (typeof value.link !== 'string') throw new Error('邀请格式无效');
|
||||
return value.link;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { openDB, type DBSchema } from 'idb';
|
||||
import { apiPath } from './http';
|
||||
|
||||
export interface RememberedAccount {
|
||||
username: string;
|
||||
token: string;
|
||||
key: CryptoKey;
|
||||
vault: string;
|
||||
revision: number;
|
||||
}
|
||||
interface LocalIdentity extends DBSchema {
|
||||
sessions: { key: string; value: RememberedAccount };
|
||||
device: { key: string; value: { id: string; name: string } };
|
||||
}
|
||||
const database = () => openDB<LocalIdentity>('webdrop-account-local-2', 1, { upgrade(db) {
|
||||
db.createObjectStore('sessions'); db.createObjectStore('device');
|
||||
} });
|
||||
const endpoint = () => apiPath('/');
|
||||
export async function rememberedAccount(): Promise<RememberedAccount | undefined> { return (await database()).get('sessions', endpoint()); }
|
||||
export async function rememberAccount(value: RememberedAccount): Promise<void> { await (await database()).put('sessions', value, endpoint()); }
|
||||
export async function forgetAccount(): Promise<void> { await (await database()).delete('sessions', endpoint()); }
|
||||
export async function localAccountDevice(): Promise<{ id: string; name: string }> {
|
||||
const db = await database();
|
||||
const existing = await db.get('device', endpoint());
|
||||
if (existing) return existing;
|
||||
const name = /Android/i.test(navigator.userAgent) ? 'Android 浏览器' : /iPhone|iPad/i.test(navigator.userAgent) ? 'Apple 移动浏览器'
|
||||
: /Windows/i.test(navigator.userAgent) ? 'Windows 浏览器' : /Mac/i.test(navigator.userAgent) ? 'Mac 浏览器' : '浏览器设备';
|
||||
const value = { id: crypto.randomUUID(), name };
|
||||
await db.put('device', value, endpoint());
|
||||
return value;
|
||||
}
|
||||
export async function saveDeviceName(name: string): Promise<void> {
|
||||
const device = await localAccountDevice(); await (await database()).put('device', { ...device, name }, endpoint());
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PersonalProfile } from './accountVault';
|
||||
let identity: { username: string; token: string } | undefined;
|
||||
let publicKey: JsonWebKey | undefined;
|
||||
let personal: PersonalProfile | undefined;
|
||||
export function setAccountIdentity(value: { username: string; token: string } | undefined, key?: JsonWebKey, profile?: PersonalProfile): void { identity = value; publicKey = key; personal = value ? profile : undefined; }
|
||||
export function accountDisplay(): { username: string; displayName: string; avatar?: string } | undefined {
|
||||
return identity ? { username: identity.username, displayName: personal?.displayName || identity.username, ...(personal?.avatar ? { avatar: personal.avatar } : {}) } : undefined;
|
||||
}
|
||||
export function accountPublicKey(): JsonWebKey {
|
||||
if (!identity || !publicKey) throw new Error('请先解锁账号');
|
||||
return publicKey;
|
||||
}
|
||||
export function accountToken(): string | undefined { return identity?.token; }
|
||||
export function requireAccount(): string {
|
||||
if (!identity) throw new Error('请先登录账号');
|
||||
return identity.username;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { exportResource, mergeVault, validatePersonal, type VaultProfile } from './accountVault';
|
||||
import type { SavedResource } from '../database';
|
||||
const resource = (modifiedAt = 10): SavedResource => ({ id: 'AAAAAAAAAAAAAAAAAAAAAA', kind: 'channel', mode: 'enc', role: 'owner',
|
||||
link: 'https://drop.test/c/a#k=secret', endpointSecret: new Uint8Array(32).fill(7), deviceLabel: 'device-a', localScope: 'account:alice',
|
||||
createdAt: 1, modifiedAt, expiresAt: 100, seats: 3, membersCanUpload: true, name: `version-${modifiedAt}` });
|
||||
const profile = (): VaultProfile => ({ v: 2, keys: { publicKey: {}, privateKey: {} }, resources: [exportResource(resource(20))], deletes: [] });
|
||||
describe('account vault portability and merge', () => {
|
||||
it('never synchronizes device identity or local scope', () => {
|
||||
const exported = exportResource(resource());
|
||||
expect(exported).not.toHaveProperty('endpointSecret'); expect(exported).not.toHaveProperty('deviceLabel');
|
||||
expect(exported).not.toHaveProperty('localScope'); expect(exported.link).toContain('secret');
|
||||
});
|
||||
it('a stale device does not overwrite a newer remote resource', () => {
|
||||
expect(mergeVault(profile(), [resource(10)], []).resources[0]?.name).toBe('version-20');
|
||||
});
|
||||
it('resource removal survives a stale device syncing its old copy', () => {
|
||||
const remote = { ...profile(), resources: [], deletes: [{ id: resource().id, modifiedAt: 30 }] };
|
||||
expect(mergeVault(remote, [resource(20)], []).resources).toEqual([]);
|
||||
expect(mergeVault(profile(), [], [{ id: resource().id, modifiedAt: 30 }]).resources).toEqual([]);
|
||||
});
|
||||
it('explicitly rejoining after removal restores the resource', () => {
|
||||
const remote = { ...profile(), resources: [], deletes: [{ id: resource().id, modifiedAt: 30 }] };
|
||||
const merged = mergeVault(remote, [resource(40)], []);
|
||||
expect(merged.resources).toHaveLength(1); expect(merged.deletes).toEqual([]);
|
||||
});
|
||||
it('keeps the newest personal profile across stale devices, including avatar removal', () => {
|
||||
const remote = { ...profile(), personal: { displayName: '最新名称', avatar: 'data:image/png;base64,AA==', modifiedAt: 20 } };
|
||||
expect(mergeVault(remote, [], [], { displayName: '旧名称', modifiedAt: 10 }).personal).toEqual(remote.personal);
|
||||
expect(mergeVault(remote, [], [], { displayName: '新名称', modifiedAt: 30 }).personal).not.toHaveProperty('avatar');
|
||||
});
|
||||
it('rejects remote executable and external avatars', () => {
|
||||
for (const avatar of ['https://tracker.test/avatar.png', 'data:image/svg+xml;base64,AA==']) expect(() => validatePersonal({ displayName: 'Alice', avatar, modifiedAt: 1 })).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { SavedResource } from '../database';
|
||||
import type { InvitationKeyPair } from './accountCrypto';
|
||||
export interface ResourceDelete { id: string; modifiedAt: number }
|
||||
export type VaultResource = Omit<SavedResource, 'endpointSecret' | 'deviceLabel' | 'localItems' | 'localScope' | 'needsJoin' | 'pendingManifestId'>;
|
||||
export interface PersonalProfile { displayName: string; avatar?: string; modifiedAt: number }
|
||||
export interface VaultProfile { v: 2; keys: InvitationKeyPair; resources: VaultResource[]; deletes: ResourceDelete[]; personal?: PersonalProfile }
|
||||
export function exportResource(resource: SavedResource): VaultResource {
|
||||
const entry = { ...resource };
|
||||
delete entry.localScope; delete entry.localItems; delete entry.deviceLabel; delete entry.needsJoin; delete entry.pendingManifestId;
|
||||
const { endpointSecret: _endpointSecret, ...portable } = entry;
|
||||
return portable;
|
||||
}
|
||||
export function validateVault(value: unknown): VaultProfile {
|
||||
if (!value || typeof value !== 'object') throw new Error('账号保险库格式无效');
|
||||
const profile = value as VaultProfile;
|
||||
if (profile.v !== 2 || !profile.keys?.privateKey?.d || profile.keys.privateKey.crv !== 'P-256'
|
||||
|| !profile.keys.publicKey?.x || profile.keys.publicKey.d || !Array.isArray(profile.resources) || !Array.isArray(profile.deletes))
|
||||
throw new Error('账号保险库格式无效');
|
||||
for (const row of profile.resources) {
|
||||
if (!row || !/^[A-Za-z0-9_-]{22}$/.test(row.id) || !['channel', 'share'].includes(row.kind)
|
||||
|| typeof row.link !== 'string' || !['owner', 'member'].includes(row.role) || !Number.isFinite(row.createdAt)
|
||||
|| !Number.isFinite(row.expiresAt) || (row.modifiedAt !== undefined && !Number.isFinite(row.modifiedAt))) throw new Error('账号资源清单格式无效');
|
||||
}
|
||||
for (const row of profile.deletes) if (!row || !/^[A-Za-z0-9_-]{22}$/.test(row.id) || !Number.isFinite(row.modifiedAt)) throw new Error('账号资源清单格式无效');
|
||||
if (profile.personal) validatePersonal(profile.personal);
|
||||
return profile;
|
||||
}
|
||||
export function validatePersonal(value: PersonalProfile): PersonalProfile {
|
||||
if (typeof value.displayName !== 'string' || value.displayName.trim().length > 48 || /[\u0000-\u001f]/.test(value.displayName)
|
||||
|| !Number.isFinite(value.modifiedAt) || (value.avatar !== undefined && (typeof value.avatar !== 'string' || value.avatar.length > 350_000
|
||||
|| !/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/.test(value.avatar)))) throw new Error('个人资料格式无效');
|
||||
return { ...value, displayName: value.displayName.trim() };
|
||||
}
|
||||
export function mergeVault(remote: VaultProfile, resources: SavedResource[], deletes: ResourceDelete[], personal?: PersonalProfile): VaultProfile {
|
||||
const byId = new Map(remote.resources.map((entry) => [entry.id, entry]));
|
||||
const removed = new Map(remote.deletes.map((entry) => [entry.id, entry]));
|
||||
for (const row of resources) {
|
||||
const current = byId.get(row.id);
|
||||
if (!current || (row.modifiedAt ?? row.createdAt) > (current.modifiedAt ?? current.createdAt)) byId.set(row.id, exportResource(row));
|
||||
}
|
||||
for (const row of deletes) if ((removed.get(row.id)?.modifiedAt ?? 0) < row.modifiedAt) removed.set(row.id, row);
|
||||
for (const [id, row] of byId) {
|
||||
if ((removed.get(id)?.modifiedAt ?? -1) >= (row.modifiedAt ?? row.createdAt)) byId.delete(id);
|
||||
else removed.delete(id);
|
||||
}
|
||||
const newest = personal && personal.modifiedAt > (remote.personal?.modifiedAt ?? -1) ? personal : remote.personal;
|
||||
return { ...remote, ...(newest ? { personal: newest } : {}), resources: [...byId.values()], deletes: [...removed.values()] };
|
||||
}
|
||||
@@ -6,21 +6,27 @@ import { blobs, readers } from './sessions';
|
||||
import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation';
|
||||
import { type ChannelWire, toChannelInfo } from './wire';
|
||||
import { apiFetch, readJson } from './http';
|
||||
import { applyControl } from './channelThreads';
|
||||
import { clearFileCatalog, fileCatalogs, visibleFiles } from './fileCatalog';
|
||||
|
||||
export function mapReceived(item: ReceivedItem): Item {
|
||||
const timestamp = item.meta?.ts ?? Date.now();
|
||||
const deviceId = item.meta?.dev ?? '未知设备';
|
||||
const state = item.complete ? { kind: 'complete' as const } : { kind: 'incomplete' as const };
|
||||
if (item.kind === 'file' || item.kind === 'raw') {
|
||||
if (item.kind === 'file') {
|
||||
const name = item.meta?.name ?? '加密文件';
|
||||
return {
|
||||
id: item.itemId, kind: 'file', ts: timestamp, deviceId, state, name,
|
||||
...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}),
|
||||
...(item.meta?.folderId ? { folderId: item.meta.folderId } : {}),
|
||||
mime: item.meta?.mime ?? 'application/octet-stream', size: item.meta?.size ?? item.content.byteLength,
|
||||
remaining: null, preview: previewKindOf(item.meta?.mime ?? '', name),
|
||||
gated: item.gated,
|
||||
};
|
||||
}
|
||||
return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state, text: new TextDecoder().decode(item.content) };
|
||||
return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state,
|
||||
...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}),
|
||||
text: new TextDecoder().decode(item.content) };
|
||||
}
|
||||
|
||||
export async function applyChannelRecord(resource: SavedResource, session: ChannelSession, input: Uint8Array | LogRecord): Promise<void> {
|
||||
@@ -33,7 +39,19 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
for (const event of events) {
|
||||
switch (event.type) {
|
||||
case 'item': {
|
||||
const control = applyControl(event.item);
|
||||
if (control !== false) {
|
||||
if (control && control.t === 'file-catalog') {
|
||||
const key = `${resource.id}:${control.item.id}`;
|
||||
fileCatalogs.set(key, control.item);
|
||||
if (visibleFiles.has(key)) publishWorkspace(resource.id, control);
|
||||
break;
|
||||
}
|
||||
if (control) publishWorkspace(resource.id, control);
|
||||
break;
|
||||
}
|
||||
const item = mapReceived(event.item);
|
||||
if (item.kind === 'file') visibleFiles.add(`${resource.id}:${item.id}`);
|
||||
if (event.item.snapshot) {
|
||||
let snapshot: Snapshot = { text: '', updatedBy: item.deviceId, updatedAt: item.ts };
|
||||
if (item.kind === 'text') {
|
||||
@@ -52,16 +70,22 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
break;
|
||||
}
|
||||
case 'announce': {
|
||||
const key = `${resource.id}:${event.itemId}`;
|
||||
visibleFiles.add(key);
|
||||
const catalog = fileCatalogs.get(key);
|
||||
if (catalog) { publishWorkspace(resource.id, { t: 'file-catalog', item: catalog }); break; }
|
||||
const local = (await getResource(resource.id))?.localItems?.find((item) => item.id === event.itemId);
|
||||
const placeholder: FileItem = local?.kind === 'file' ? local : {
|
||||
id: event.itemId, kind: 'file', ts: event.createdAt, deviceId: '未知设备', state: { kind: 'incomplete' },
|
||||
name: '受限下载内容', mime: 'application/octet-stream', size: 0, remaining: null, preview: 'none',
|
||||
gated: true,
|
||||
gated: true, metadataHidden: true,
|
||||
};
|
||||
publishWorkspace(resource.id, { t: 'item', item: placeholder });
|
||||
break;
|
||||
}
|
||||
case 'removed':
|
||||
visibleFiles.delete(`${resource.id}:${event.itemId}`);
|
||||
fileCatalogs.delete(`${resource.id}:${event.itemId}`);
|
||||
blobs.delete(`${resource.id}:${event.itemId}`);
|
||||
publishWorkspace(resource.id, { t: 'removed', id: event.itemId });
|
||||
break;
|
||||
@@ -79,13 +103,15 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
|
||||
export async function channelMetadata(resource: SavedResource, session: ChannelSession): Promise<void> {
|
||||
const info = await readJson<ChannelWire>(`/channels/${resource.id}`, session.authHeaders('read'));
|
||||
session.verifyChannelInfo(info);
|
||||
resource.name = info.settings.name;
|
||||
resource.membersCanUpload = info.settings.members_can_upload;
|
||||
resource.expiresAt = info.expires_at;
|
||||
const latest = await getResource(resource.id);
|
||||
await saveResource({ ...latest, ...resource, localItems: latest?.localItems });
|
||||
publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
|
||||
const ownId = toBase64Url(session.endpointHash);
|
||||
let devices = [deviceInfo(ownId, Date.now(), deviceIdentity().name, ownId)];
|
||||
const ownName = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
|
||||
let devices = [deviceInfo(ownId, Date.now(), ownName, ownId)];
|
||||
if (session.can('owner')) {
|
||||
const seats = await readJson<Array<{ endpoint_hash: string; joined_at: number; label?: string }>>(
|
||||
`/channels/${resource.id}/seats`, session.authHeaders('owner'));
|
||||
@@ -95,16 +121,15 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
|
||||
try { name = await session.openLabel(seat.label, seat.endpoint_hash); }
|
||||
catch { name = '设备名校验失败'; }
|
||||
}
|
||||
return deviceInfo(seat.endpoint_hash, seat.joined_at, name, ownId);
|
||||
return deviceInfo(seat.endpoint_hash, seat.joined_at, name ?? (seat.endpoint_hash === ownId ? ownName : undefined), ownId);
|
||||
}));
|
||||
const own = devices.find((device) => device.self);
|
||||
if (own) own.name = deviceIdentity().name;
|
||||
}
|
||||
publishWorkspace(resource.id, { t: 'devices', devices });
|
||||
}
|
||||
|
||||
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
|
||||
publishWorkspace(resource.id, { t: 'reset' });
|
||||
clearFileCatalog(resource.id);
|
||||
readers.set(resource.id, session.createItemReader());
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { generateInvitationKeys } from './accountCrypto';
|
||||
import { openChannelGrant, sealChannelGrant } from './channelGrant';
|
||||
|
||||
describe('account-bound channel grants', () => {
|
||||
it('delivers the same channel key to another account without a public secret URL', async () => {
|
||||
const { session } = await ChannelSession.create();
|
||||
const bob = await generateInvitationKeys();
|
||||
const payload = await sealChannelGrant(session.link('https://drop.test'), 'bob', bob.publicKey);
|
||||
expect(payload).not.toContain('#k=');
|
||||
const parsed = await openChannelGrant(payload, 'bob', session.id, bob.privateKey, 'member', 'https://other.test');
|
||||
expect(parsed.mode).toBe('enc'); expect(parsed.isOwner).toBe(false); expect(parsed.keys.a).toBeUndefined();
|
||||
const recovered = await ChannelSession.open(`https://other.test/c/${parsed.id}#k=${parsed.keys.k}`);
|
||||
const plan = session.encryptText('private file message');
|
||||
const reader = recovered.createItemReader();
|
||||
const frames = []; for await (const frame of plan.frames) frames.push(frame.bytes);
|
||||
const events = await reader.pushFrame(frames[0]!);
|
||||
expect(events.some((e) => e.type === 'item' && new TextDecoder().decode(e.item.content) === 'private file message')).toBe(true);
|
||||
session.dispose(); recovered.dispose();
|
||||
});
|
||||
it('binds the envelope to account, channel and owner/member role', async () => {
|
||||
const { session } = await ChannelSession.create(); const alice = await generateInvitationKeys(); const other = await generateInvitationKeys();
|
||||
const payload = await sealChannelGrant(session.link('https://drop.test', 'owner'), 'alice', alice.publicKey);
|
||||
expect((await openChannelGrant(payload, 'alice', session.id, alice.privateKey, 'owner', 'https://drop.test')).isOwner).toBe(true);
|
||||
await expect(openChannelGrant(payload, 'alice', session.id, other.privateKey, 'owner', 'https://drop.test')).rejects.toThrow();
|
||||
await expect(openChannelGrant(payload, 'bob', session.id, alice.privateKey, 'owner', 'https://drop.test')).rejects.toThrow();
|
||||
await expect(openChannelGrant(payload, 'alice', session.id, alice.privateKey, 'member', 'https://drop.test')).rejects.toThrow();
|
||||
await expect(openChannelGrant(payload, 'alice', 'AAAAAAAAAAAAAAAAAAAAAA', alice.privateKey, 'owner', 'https://drop.test')).rejects.toThrow();
|
||||
session.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { parseChannelLink, type ParsedChannelLink } from '@/lib/channelLink';
|
||||
import { makeLink } from './links';
|
||||
import { decryptInvitation, encryptInvitation } from './accountCrypto';
|
||||
|
||||
interface ChannelGrant {
|
||||
v: 1;
|
||||
channelId: string;
|
||||
mode: 'enc' | 'raw';
|
||||
role: 'owner' | 'member';
|
||||
keys: ParsedChannelLink['keys'];
|
||||
}
|
||||
|
||||
export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey): Promise<string> {
|
||||
const parsed = parseChannelLink(link);
|
||||
if (!parsed.ok) throw new Error('空间凭据格式无效');
|
||||
const grant: ChannelGrant = { v: 1, channelId: parsed.link.id, mode: parsed.link.mode,
|
||||
role: parsed.link.isOwner ? 'owner' : 'member', keys: parsed.link.keys };
|
||||
return encryptInvitation(JSON.stringify(grant), recipient, grant.channelId, publicKey);
|
||||
}
|
||||
|
||||
export async function openChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey,
|
||||
role: 'owner' | 'member', origin: string): Promise<ParsedChannelLink> {
|
||||
const plain = await decryptInvitation(payload, recipient, channelId, privateKey);
|
||||
let parsed: ReturnType<typeof parseChannelLink>;
|
||||
if (plain.startsWith('{')) {
|
||||
const grant = JSON.parse(plain) as ChannelGrant;
|
||||
if (grant.v !== 1 || grant.channelId !== channelId || grant.role !== role || !['enc', 'raw'].includes(grant.mode)
|
||||
|| !grant.keys || typeof grant.keys !== 'object') throw new Error('空间授权包校验失败');
|
||||
parsed = parseChannelLink(makeLink(origin, channelId, grant.keys));
|
||||
if (!parsed.ok || parsed.link.mode !== grant.mode) throw new Error('空间授权包模式不匹配');
|
||||
} else {
|
||||
// Existing invitations hold an encrypted link; their secret never becomes a navigation URL.
|
||||
parsed = parseChannelLink(plain);
|
||||
}
|
||||
if (!parsed.ok || parsed.link.id !== channelId || parsed.link.isOwner !== (role === 'owner')) throw new Error('空间授权包权限不匹配');
|
||||
return parsed.link;
|
||||
}
|
||||
@@ -10,6 +10,9 @@ import { stopChannel, connectChannel } from './channelTransport';
|
||||
import { downloadDecrypted } from './transfer';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { IntegrityError } from '@webdrop/core';
|
||||
import { fileCatalogs, verifyFileCatalog } from './fileCatalog';
|
||||
import { dequeueItem, saveResource } from '../database';
|
||||
import { serial } from './serial';
|
||||
|
||||
export function connectChannelResource(id: string): () => void {
|
||||
void connectChannel(id);
|
||||
@@ -17,10 +20,14 @@ export function connectChannelResource(id: string): () => void {
|
||||
}
|
||||
|
||||
export async function removeChannelItem(resourceId: string, itemId: string): Promise<void> {
|
||||
const { session } = await getSession(resourceId);
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const role = session.can('owner') ? 'owner' : session.can('write') ? 'write' : 'read';
|
||||
await apiFetch(`/channels/${resourceId}/items/${itemId}`, {
|
||||
method: 'DELETE', headers: session.authHeaders(role),
|
||||
await serial(`outbox:${resourceId}`, async () => {
|
||||
await apiFetch(`/channels/${resourceId}/items/${itemId}`, { method: 'DELETE', headers: session.authHeaders(role) });
|
||||
await dequeueItem(resourceId, itemId);
|
||||
resource.localItems = resource.localItems?.filter((item) => item.id !== itemId);
|
||||
await saveResource(resource);
|
||||
blobs.delete(`${resourceId}:${itemId}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,22 +38,33 @@ export async function getChannelBlob(resourceId: string, itemId: string, version
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
|
||||
let result: Awaited<ReturnType<typeof downloadDecrypted>>;
|
||||
try { result = await downloadDecrypted(session, resource, itemId); }
|
||||
try {
|
||||
result = await downloadDecrypted(session, resource, itemId);
|
||||
verifyFileCatalog(fileCatalogs.get(`${resourceId}:${itemId}`), result.meta, result.blob.size);
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof IntegrityError) publishWorkspace(resourceId, { t: 'item', item: { id: itemId, kind: 'text', ts: Date.now(), deviceId: '', text: '', state: { kind: 'tampered' } } });
|
||||
throw error;
|
||||
}
|
||||
blobs.set(`${resourceId}:${itemId}`, result.blob);
|
||||
const fileBlob = result.meta?.kind === 'file' && typeof File !== 'undefined'
|
||||
? new File([result.blob], result.meta.name ?? '加密文件', {
|
||||
type: result.meta.mime ?? result.blob.type,
|
||||
lastModified: result.meta.ts,
|
||||
})
|
||||
: result.blob;
|
||||
blobs.set(`${resourceId}:${itemId}`, fileBlob);
|
||||
if (result.meta?.kind === 'file') {
|
||||
const item: FileItem = {
|
||||
id: itemId, kind: 'file', ts: result.meta.ts, deviceId: result.meta.dev ?? '未知设备', state: { kind: 'complete' },
|
||||
name: result.meta.name ?? '加密文件', mime: result.meta.mime ?? result.blob.type, size: result.blob.size,
|
||||
...(result.meta.threadId ? { threadId: result.meta.threadId } : {}),
|
||||
...(result.meta.folderId ? { folderId: result.meta.folderId } : {}),
|
||||
name: result.meta.name ?? '加密文件', mime: result.meta.mime ?? result.blob.type, size: fileBlob.size,
|
||||
remaining: null, preview: 'none', gated: true,
|
||||
};
|
||||
item.preview = previewKindOf(item.mime, item.name);
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
}
|
||||
return result.blob;
|
||||
return fileBlob;
|
||||
}
|
||||
|
||||
export async function resolveTextConflict(): Promise<void> {
|
||||
|
||||
@@ -1,47 +1,63 @@
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { ChannelSession, toBase64Url } from '@webdrop/core';
|
||||
import { getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
|
||||
import type { ChannelInfo, CreateOptions } from '../types';
|
||||
import type { ChannelInfo, ChannelSettingsPatch, CreateOptions } from '../types';
|
||||
import { apiFetch, readJson } from './http';
|
||||
import { sessions } from './sessions';
|
||||
import { getSession, sessions } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { makeLink } from './links';
|
||||
import { toChannelInfo, type ChannelWire } from './wire';
|
||||
import { stopChannel } from './channelTransport';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { channelMetadata } from './channelEvents';
|
||||
import { requireAccount, accountPublicKey } from './accountSession';
|
||||
import { sealChannelGrant } from './channelGrant';
|
||||
import { createThread } from './channelThreads';
|
||||
|
||||
|
||||
|
||||
export async function createChannel(options: CreateOptions, captchaToken?: string): Promise<ChannelInfo> {
|
||||
const username = requireAccount();
|
||||
const endpointSecret = crypto.getRandomValues(new Uint8Array(32));
|
||||
const { session, request } = await ChannelSession.create({
|
||||
mode: options.mode,
|
||||
endpointSecret,
|
||||
settings: {
|
||||
name: options.name?.trim() || undefined,
|
||||
seats: options.seats,
|
||||
ttl_seconds: options.ttlSeconds,
|
||||
members_can_upload: options.membersCanUpload,
|
||||
delete_policy: 'own',
|
||||
max_items: 1000,
|
||||
file_max_downloads: 10,
|
||||
file_max_downloads: options.fileMaxDownloads ?? 10,
|
||||
ui_restrict_copy_download: false,
|
||||
join_policy: options.joinPolicy ?? 'invite', allow_guests: options.allowGuests ?? false,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const keyPackage = await sealChannelGrant(session.link(self.location.origin, 'owner'), username, accountPublicKey());
|
||||
const info = await apiFetch('/channels', {
|
||||
method: 'POST', headers: { ...request.headers, 'Content-Type': 'application/json', ...(captchaToken ? { 'X-WD-Cap': captchaToken } : {}) }, body: JSON.stringify(request.body),
|
||||
method: 'POST', headers: { ...request.headers, 'Content-Type': 'application/json', ...(captchaToken ? { 'X-WD-Cap': captchaToken } : {}) }, body: JSON.stringify({ ...request.body, key_package: keyPackage, join_password: options.joinPassword || undefined }),
|
||||
}).then((response) => response.json() as Promise<ChannelWire>);
|
||||
session.verifyChannelInfo(info);
|
||||
const resource: SavedResource = {
|
||||
id: session.id,
|
||||
creatorAccount: info.creator_account,
|
||||
kind: 'channel', mode: session.mode, role: 'owner',
|
||||
name: info.settings.name,
|
||||
link: session.link(self.location.origin), ownerLink: session.link(self.location.origin, 'owner'),
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: [],
|
||||
};
|
||||
const ownerLabel = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
await apiFetch(`/channels/${session.id}/seats/me`, {
|
||||
method: 'PUT', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/octet-stream' }, body: ownerLabel,
|
||||
});
|
||||
await saveResource(resource);
|
||||
sessions.set(session.id, session);
|
||||
await createThread(session.id, '常规');
|
||||
return toChannelInfo(info, resource);
|
||||
} catch (error) {
|
||||
session.dispose();
|
||||
@@ -49,7 +65,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
||||
}
|
||||
}
|
||||
|
||||
export async function joinChannel(input: { link: ParsedChannelLink }): Promise<ChannelInfo> {
|
||||
export async function joinChannel(input: { link: ParsedChannelLink; password?: string }): Promise<ChannelInfo> {
|
||||
const { id, keys } = input.link;
|
||||
const existing = await getResource(id);
|
||||
const endpointSecret = existing?.endpointSecret ?? crypto.getRandomValues(new Uint8Array(32));
|
||||
@@ -57,15 +73,20 @@ export async function joinChannel(input: { link: ParsedChannelLink }): Promise<C
|
||||
try {
|
||||
const info = await readJson<ChannelWire>(`/channels/${id}`, session.authHeaders('read'));
|
||||
session.verifyChannelInfo(info);
|
||||
await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read') });
|
||||
const joined = await apiFetch(`/channels/${id}/seats`, {
|
||||
method: 'POST', headers: { ...session.authHeaders(session.can('owner') ? 'owner' : 'read'), 'Content-Type': 'application/json' }, body: JSON.stringify({ password: input.password }),
|
||||
}).then((response) => response.json() as Promise<{ seats_used: number }>);
|
||||
info.seats_used = joined.seats_used;
|
||||
const resource: SavedResource = {
|
||||
id, kind: 'channel', mode: session.mode, role: session.role,
|
||||
creatorAccount: info.creator_account,
|
||||
name: info.settings.name,
|
||||
link: session.link(self.location.origin), ownerLink: session.can('owner') ? session.link(self.location.origin, 'owner') : undefined,
|
||||
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: info.created_at, expiresAt: info.expires_at,
|
||||
seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload,
|
||||
maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [],
|
||||
};
|
||||
const label = await session.sealLabel(deviceIdentity().name);
|
||||
const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
await apiFetch(`/channels/${id}/seats/me`, {
|
||||
method: 'PUT', headers: { ...session.authHeaders('read'), 'Content-Type': 'application/octet-stream' }, body: label,
|
||||
});
|
||||
@@ -78,14 +99,87 @@ export async function joinChannel(input: { link: ParsedChannelLink }): Promise<C
|
||||
}
|
||||
}
|
||||
|
||||
export async function previewChannel(link: ParsedChannelLink): Promise<ChannelInfo> {
|
||||
const session = await ChannelSession.open(makeLink(self.location.origin, link.id, link.keys));
|
||||
try {
|
||||
const info = await readJson<ChannelWire>(`/channels/${link.id}`, session.authHeaders('read'));
|
||||
session.verifyChannelInfo(info);
|
||||
return toChannelInfo(info, { id: link.id, kind: 'channel', mode: session.mode, role: session.role,
|
||||
endpointSecret: session.endpointSecret, link: session.link(self.location.origin), createdAt: info.created_at,
|
||||
expiresAt: info.expires_at, seats: info.settings.seats, membersCanUpload: info.settings.members_can_upload });
|
||||
} finally { session.dispose(); }
|
||||
}
|
||||
|
||||
export async function channelMembers(id: string): Promise<unknown> {
|
||||
const { session } = await getSession(id);
|
||||
return readJson(`/channels/${id}/members`, session.authHeaders(session.can('owner') ? 'owner' : 'read'));
|
||||
}
|
||||
|
||||
export async function listChannels(): Promise<ChannelInfo[]> {
|
||||
const saved = await listResources('channel');
|
||||
return saved.map((resource) => ({
|
||||
id: resource.id, mode: resource.mode, role: resource.role, seats: resource.seats,
|
||||
id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, seats: resource.seats, maxDownloads: resource.maxDownloads ?? undefined,
|
||||
creatorAccount: resource.creatorAccount,
|
||||
memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateChannelSettings(resourceId: string, patch: ChannelSettingsPatch): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
|
||||
const body: Record<string, unknown> = {};
|
||||
if (patch.name !== undefined) body.name = patch.name.trim();
|
||||
if (patch.seats !== undefined) body.seats = patch.seats;
|
||||
if (patch.membersCanUpload !== undefined) body.members_can_upload = patch.membersCanUpload;
|
||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
|
||||
if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy;
|
||||
if (patch.allowGuests !== undefined) body.allow_guests = patch.allowGuests;
|
||||
if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword;
|
||||
const info = await apiFetch(`/channels/${resourceId}/settings`, {
|
||||
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then((response) => response.json() as Promise<ChannelWire>);
|
||||
session.verifyChannelInfo(info);
|
||||
resource.name = info.settings.name;
|
||||
resource.seats = info.settings.seats;
|
||||
resource.membersCanUpload = info.settings.members_can_upload;
|
||||
resource.maxDownloads = info.settings.file_max_downloads;
|
||||
resource.expiresAt = info.expires_at;
|
||||
await saveResource(resource);
|
||||
publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, resource) });
|
||||
}
|
||||
|
||||
export async function renameDevice(resourceId: string, endpointHash: string, name: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const ownEndpointHash = toBase64Url(session.endpointHash);
|
||||
const isSelf = endpointHash === ownEndpointHash;
|
||||
if (!isSelf && !session.can('owner')) throw new Error('只有所有者可以重命名其他设备');
|
||||
const normalized = name.trim();
|
||||
if (!normalized) throw new Error('设备名称不能为空');
|
||||
const label = await session.sealLabel(normalized, { endpointHash });
|
||||
const path = isSelf
|
||||
? `/channels/${resourceId}/seats/me`
|
||||
: `/channels/${resourceId}/seats/${endpointHash}/label`;
|
||||
const role = !isSelf || session.can('owner') ? 'owner' : 'read';
|
||||
await apiFetch(path, {
|
||||
method: 'PUT', headers: { ...session.authHeaders(role), 'Content-Type': 'application/octet-stream' }, body: label,
|
||||
});
|
||||
if (isSelf) {
|
||||
resource.deviceLabel = normalized;
|
||||
await saveResource(resource);
|
||||
}
|
||||
await channelMetadata(resource, session);
|
||||
}
|
||||
|
||||
export async function releaseDevice(resourceId: string, endpointHash: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (!session.can('owner')) throw new Error('只有所有者可以移除设备');
|
||||
if (endpointHash === toBase64Url(session.endpointHash)) throw new Error('不能移除当前设备');
|
||||
await apiFetch(`/channels/${resourceId}/seats/${endpointHash}`, { method: 'DELETE', headers: session.authHeaders('owner') });
|
||||
await channelMetadata(resource, session);
|
||||
}
|
||||
|
||||
export async function forgetChannel(id: string): Promise<void> {
|
||||
stopChannel(id);
|
||||
sessions.get(id)?.dispose();
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { removeChannelItem } from './channelItems';
|
||||
import { queueFileCatalog, queueItemAuthor } from './channelThreads';
|
||||
import { senderSnapshot } from './senderSnapshot';
|
||||
import { errorMessage } from '../errors';
|
||||
|
||||
import { saveResource } from '../database';
|
||||
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import type { FileItem, Item } from '../types';
|
||||
import type { FileItem, FileUploadOptions, Item } from '../types';
|
||||
|
||||
import { blobs, getSession } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
@@ -14,38 +16,53 @@ import { deviceIdentity } from './presentation';
|
||||
import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPlan } from './transfer';
|
||||
import { publishWorkspace } from './presentation';
|
||||
|
||||
export async function sendText(resourceId: string, text: string): Promise<{ itemId: string; uploaded: boolean }> {
|
||||
export async function sendText(resourceId: string, text: string, threadId?: string): Promise<{ itemId: string; uploaded: boolean }> {
|
||||
if (!threadId) throw new Error('请先选择频道');
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
|
||||
const device = deviceIdentity();
|
||||
const identity = await deviceIdentity(session.endpointSecret);
|
||||
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
|
||||
session.deviceName = device.name;
|
||||
const ts = Date.now();
|
||||
const plan = session.encryptText(text, { dev: device.name, ts });
|
||||
const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0 }, text };
|
||||
const author = await senderSnapshot();
|
||||
const plan = session.encryptText(text, { dev: device.name, ts, threadId });
|
||||
const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}) };
|
||||
blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' }));
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
await persistPlan(resource, plan);
|
||||
if (author) await queueItemAuthor(resource, session, plan.itemId, author, threadId, ts);
|
||||
const uploaded = await flushOutbox(resource, session);
|
||||
publishWorkspace(resourceId, { t: 'item', item: { ...item, state: uploaded ? { kind: 'complete' } : { kind: 'incomplete' } } });
|
||||
if (!uploaded) publishWorkspace(resourceId, { t: 'connection', state: 'offline' });
|
||||
return { itemId: plan.itemId, uploaded };
|
||||
}
|
||||
|
||||
export async function sendFiles(resourceId: string, files: File[]): Promise<void> {
|
||||
export async function sendFiles(resourceId: string, files: File[], options: FileUploadOptions = { directPreview: true, maxDownloads: 10 }, threadId?: string, folderId?: string): Promise<void> {
|
||||
if (!threadId) throw new Error('请先选择频道');
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (resource.kind !== 'channel' || (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload))) throw new Error('此链接没有上传权限');
|
||||
const device = deviceIdentity();
|
||||
const identity = await deviceIdentity(session.endpointSecret);
|
||||
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
|
||||
session.deviceName = device.name;
|
||||
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
|
||||
const author = await senderSnapshot();
|
||||
for (const file of files) {
|
||||
const ts = Date.now();
|
||||
let item: FileItem | undefined;
|
||||
try {
|
||||
const plan = session.encryptFile(file, {}, { maxDownloads: resource.maxDownloads ?? 10, dev: device.name, ts, maxFrameData });
|
||||
const plan = session.encryptFile(file, {}, {
|
||||
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
|
||||
dev: device.name, ts, maxFrameData, threadId, folderId,
|
||||
});
|
||||
item = {
|
||||
id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
|
||||
...(author ? { author } : {}),
|
||||
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
|
||||
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
|
||||
remaining: resource.maxDownloads || null, preview: previewKindOf(file.type, file.name), gated: true,
|
||||
remaining: plan.gated
|
||||
? (options.directPreview ? resource.maxDownloads ?? 10 : options.maxDownloads) || null
|
||||
: null,
|
||||
preview: previewKindOf(file.type, file.name), gated: plan.gated,
|
||||
};
|
||||
resource.localItems = [...(resource.localItems ?? []).filter((entry) => entry.id !== item?.id), item];
|
||||
await saveResource(resource);
|
||||
@@ -69,6 +86,7 @@ export async function sendFiles(resourceId: string, files: File[]): Promise<void
|
||||
updateProgress(file.size === 0 ? 0.5 : Math.min(0.5, processed / file.size * 0.5), 'encrypting');
|
||||
});
|
||||
const uploadedFrames = new Set<number>();
|
||||
await queueFileCatalog(resource, session, item);
|
||||
let uploadedBytes = 0;
|
||||
const uploadStartedAt = performance.now();
|
||||
const uploaded = await flushOutbox(resource, session, (frame, loadedBytes, totalBytes, complete) => {
|
||||
@@ -85,7 +103,7 @@ export async function sendFiles(resourceId: string, files: File[]): Promise<void
|
||||
resource.localItems = resource.localItems.map((entry) => entry.id === item?.id ? item! : entry);
|
||||
await saveResource(resource);
|
||||
publishWorkspace(resourceId, { t: 'item', item });
|
||||
if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开频道并连接网络后,可以继续发送已加密内容。' });
|
||||
if (!uploaded) publishWorkspace(resourceId, { t: 'error', message: '上传暂停了。重新打开空间并连接网络后,可以继续发送已加密内容。' });
|
||||
} catch (error) {
|
||||
if (item) {
|
||||
item = { ...item, state: { kind: 'incomplete' } };
|
||||
@@ -101,13 +119,14 @@ export async function sendFiles(resourceId: string, files: File[]): Promise<void
|
||||
|
||||
export async function updateSnapshot(resourceId: string, text: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
const plan = session.encryptJson({ text, updatedBy: deviceIdentity().name, updatedAt: Date.now() }, { snapshot: true, dev: deviceIdentity().name });
|
||||
const name = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
|
||||
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name });
|
||||
await persistPlan(resource, plan);
|
||||
await flushOutbox(resource, session);
|
||||
}
|
||||
|
||||
export async function editChannelText(resourceId: string, itemId: string, text: string): Promise<void> {
|
||||
const created = await sendText(resourceId, text);
|
||||
export async function editChannelText(resourceId: string, itemId: string, text: string, threadId?: string): Promise<void> {
|
||||
const created = await sendText(resourceId, text, threadId);
|
||||
if (!created.uploaded) throw new Error('新版本已排队,网络恢复前保留旧内容。');
|
||||
await removeChannelItem(resourceId, itemId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { ChannelSession, ReceivedItem } from '@webdrop/core';
|
||||
import type { SavedResource } from '../database';
|
||||
import type { FileItem, FolderInfo, SenderInfo, ThreadInfo, WorkspaceEvent } from '../types';
|
||||
import { readSender } from '@/lib/sender';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { publishWorkspace } from './presentation';
|
||||
import { getSession } from './sessions';
|
||||
import { deviceIdentity } from './presentation';
|
||||
import { flushOutbox, persistPlan } from './transfer';
|
||||
|
||||
const CONTROL_APP = 'webdrop.workspace';
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
type Control =
|
||||
| { op: 'item.author'; itemId: string; author: SenderInfo }
|
||||
| { op: 'file.published'; item: FileItem }
|
||||
| { op: 'thread.create'; id: string; name: string; groupId?: string }
|
||||
| { op: 'group.create'; id: string; name: string }
|
||||
| { op: 'group.rename'; id: string; name: string }
|
||||
| { op: 'thread.group'; id: string; groupId: string | null }
|
||||
| { op: 'thread.rename'; id: string; name: string }
|
||||
| { op: 'folder.create'; id: string; threadId: string; parentId?: string; name: string }
|
||||
| { op: 'folder.rename'; id: string; name: string }
|
||||
| { op: 'items.move'; itemIds: string[]; threadId: string; folderId: string | null };
|
||||
|
||||
function cleanName(input: string): string {
|
||||
const name = input.trim();
|
||||
if (!name || name.length > 80 || /[\u0000-\u001f]/.test(name)) throw new Error('名称需为 1–80 个字符,不能包含控制字符');
|
||||
return name;
|
||||
}
|
||||
|
||||
async function sendControl(resourceId: string, control: Control, threadId?: string): Promise<void> {
|
||||
const { session, resource } = await getSession(resourceId);
|
||||
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
|
||||
if (!session.can('owner') && (!session.can('write') || !resource.membersCanUpload)) throw new Error('此链接没有上传权限');
|
||||
const device = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
|
||||
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, ...control })), {
|
||||
kind: 'other', threadId, dev: device,
|
||||
}, { ts: Date.now(), dev: device, control: true });
|
||||
await persistPlan(resource, plan);
|
||||
const event = controlEvent(control, Date.now(), device);
|
||||
if (event) publishWorkspace(resourceId, event);
|
||||
await flushOutbox(resource, session);
|
||||
}
|
||||
|
||||
export async function createThread(resourceId: string, input: string, groupId?: string): Promise<string> {
|
||||
if (groupId !== undefined && !isId(groupId)) throw new Error('频道分组格式无效');
|
||||
const id = crypto.randomUUID();
|
||||
await sendControl(resourceId, { op: 'thread.create', id, name: cleanName(input), ...(groupId ? { groupId } : {}) });
|
||||
return id;
|
||||
}
|
||||
|
||||
export async function createGroup(resourceId: string, input: string): Promise<string> {
|
||||
const id = crypto.randomUUID(); await sendControl(resourceId, { op: 'group.create', id, name: cleanName(input) }); return id;
|
||||
}
|
||||
export async function renameGroup(resourceId: string, id: string, input: string): Promise<void> {
|
||||
if (!isId(id)) throw new Error('频道分组格式无效');
|
||||
await sendControl(resourceId, { op: 'group.rename', id, name: cleanName(input) });
|
||||
}
|
||||
export async function groupThread(resourceId: string, id: string, groupId?: string): Promise<void> {
|
||||
if (!isId(id) || groupId !== undefined && !isId(groupId)) throw new Error('频道分组格式无效');
|
||||
await sendControl(resourceId, { op: 'thread.group', id, groupId: groupId ?? null }, id);
|
||||
}
|
||||
|
||||
export async function renameThread(resourceId: string, id: string, input: string): Promise<void> {
|
||||
await sendControl(resourceId, { op: 'thread.rename', id, name: cleanName(input) }, id);
|
||||
}
|
||||
|
||||
export async function createFolder(resourceId: string, threadId: string, input: string, parentId?: string): Promise<void> {
|
||||
await sendControl(resourceId, { op: 'folder.create', id: crypto.randomUUID(), threadId, ...(parentId ? { parentId } : {}), name: cleanName(input) }, threadId);
|
||||
}
|
||||
|
||||
export async function renameFolder(resourceId: string, id: string, input: string, threadId: string): Promise<void> {
|
||||
await sendControl(resourceId, { op: 'folder.rename', id, name: cleanName(input) }, threadId);
|
||||
}
|
||||
|
||||
export async function moveItems(resourceId: string, itemIds: string[], threadId: string, folderId?: string): Promise<void> {
|
||||
if (itemIds.length < 1 || itemIds.length > 100) throw new Error('一次最多移动 100 个文件');
|
||||
await sendControl(resourceId, { op: 'items.move', itemIds, threadId, folderId: folderId ?? null }, threadId);
|
||||
}
|
||||
|
||||
export async function queueFileCatalog(resource: SavedResource, session: ChannelSession, item: FileItem): Promise<void> {
|
||||
const control: Control = { op: 'file.published', item: { ...item, state: { kind: 'complete' } } };
|
||||
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, ...control })),
|
||||
{ kind: 'other', threadId: item.threadId, dev: item.deviceId }, { ts: item.ts, control: true });
|
||||
await persistPlan(resource, plan);
|
||||
}
|
||||
|
||||
export async function queueItemAuthor(resource: SavedResource, session: ChannelSession, itemId: string, author: SenderInfo, threadId: string, ts: number): Promise<void> {
|
||||
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: CONTROL_APP, v: 1, op: 'item.author', itemId, author })),
|
||||
{ kind: 'other', threadId }, { ts, control: true });
|
||||
await persistPlan(resource, plan);
|
||||
}
|
||||
|
||||
function isId(value: unknown): value is string {
|
||||
return typeof value === 'string' && /^[0-9a-f-]{36}$/i.test(value);
|
||||
}
|
||||
function isItemId(value: unknown): value is string { return typeof value === 'string' && /^[A-Za-z0-9_-]{22}$/.test(value); }
|
||||
function validName(value: unknown): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0 && value.length <= 80 && !/[\u0000-\u001f]/.test(value);
|
||||
}
|
||||
|
||||
function controlEvent(control: Control, ts: number, author: string): WorkspaceEvent | null {
|
||||
switch (control.op) {
|
||||
case 'item.author': return { t: 'item-author', id: control.itemId, author: control.author };
|
||||
case 'file.published': return { t: 'file-catalog', item: control.item };
|
||||
case 'thread.create': return { t: 'thread-created', thread: { id: control.id, name: control.name, createdAt: ts, createdBy: author, ...(control.groupId ? { groupId: control.groupId } : {}) } };
|
||||
case 'group.create': return { t: 'group-created', group: { id: control.id, name: control.name, createdAt: ts } };
|
||||
case 'group.rename': return { t: 'group-renamed', id: control.id, name: control.name };
|
||||
case 'thread.group': return { t: 'thread-grouped', id: control.id, ...(control.groupId ? { groupId: control.groupId } : {}) };
|
||||
case 'thread.rename': return { t: 'thread-renamed', id: control.id, name: control.name };
|
||||
case 'folder.create': return { t: 'folder-created', folder: { id: control.id, threadId: control.threadId, ...(control.parentId ? { parentId: control.parentId } : {}), name: control.name } };
|
||||
case 'folder.rename': return { t: 'folder-renamed', id: control.id, name: control.name };
|
||||
case 'items.move': return { t: 'items-moved', itemIds: control.itemIds, threadId: control.threadId, ...(control.folderId ? { folderId: control.folderId } : {}) };
|
||||
}
|
||||
}
|
||||
|
||||
export function applyControl(item: ReceivedItem): WorkspaceEvent | false {
|
||||
if (item.meta?.kind !== 'other') return false;
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(new TextDecoder().decode(item.content)); } catch { return false; }
|
||||
if (!value || typeof value !== 'object' || (value as { app?: unknown }).app !== CONTROL_APP) return false;
|
||||
const row = value as Record<string, unknown>;
|
||||
if (row.v !== 1 || typeof row.op !== 'string') return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
const author = item.meta.dev ?? '未知设备';
|
||||
switch (row.op) {
|
||||
case 'item.author': {
|
||||
const sender = readSender(row.author);
|
||||
return isItemId(row.itemId) && sender ? { t: 'item-author', id: row.itemId, author: sender } : { t: 'error', message: '发送者资料格式无效,已拒绝应用。' };
|
||||
}
|
||||
case 'file.published': {
|
||||
const file = row.item as FileItem | undefined;
|
||||
const sender = readSender(file?.author);
|
||||
if (!file || !isItemId(file.id) || !isId(file.threadId) || (file.folderId !== undefined && !isId(file.folderId))
|
||||
|| file.kind !== 'file' || typeof file.name !== 'string' || file.name.length > 1024 || typeof file.mime !== 'string' || file.mime.length > 256
|
||||
|| !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean' || file.author !== undefined && !sender) return { t: 'error', message: '文件目录记录格式无效,已拒绝应用。' };
|
||||
return { t: 'file-catalog', item: { id: file.id, kind: 'file', threadId: file.threadId, ...(file.folderId ? { folderId: file.folderId } : {}),
|
||||
name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts, deviceId: item.meta.dev ?? '未知设备',
|
||||
...(sender ? { author: sender } : {}),
|
||||
gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } };
|
||||
}
|
||||
case 'thread.create':
|
||||
return isId(row.id) && validName(row.name) && (row.groupId === undefined || isId(row.groupId))
|
||||
? { t: 'thread-created', thread: { id: row.id, name: row.name, createdAt: item.meta.ts, createdBy: author,
|
||||
...(row.groupId ? { groupId: row.groupId as string } : {}) } satisfies ThreadInfo } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
case 'group.create':
|
||||
return isId(row.id) && validName(row.name) ? { t: 'group-created', group: { id: row.id, name: row.name, createdAt: item.meta.ts } }
|
||||
: { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' };
|
||||
case 'group.rename':
|
||||
return isId(row.id) && validName(row.name) ? { t: 'group-renamed', id: row.id, name: row.name }
|
||||
: { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' };
|
||||
case 'thread.group':
|
||||
return isId(row.id) && (row.groupId === null || isId(row.groupId)) ? { t: 'thread-grouped', id: row.id, ...(row.groupId ? { groupId: row.groupId as string } : {}) }
|
||||
: { t: 'error', message: '频道分组记录格式无效,已拒绝应用。' };
|
||||
case 'thread.rename':
|
||||
return isId(row.id) && typeof row.name === 'string' && row.name.length > 0 && row.name.length <= 80
|
||||
? { t: 'thread-renamed', id: row.id, name: row.name } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
case 'folder.create':
|
||||
if (!isId(row.id) || !isId(row.threadId) || (row.parentId !== undefined && !isId(row.parentId))
|
||||
|| typeof row.name !== 'string' || row.name.length < 1 || row.name.length > 80) return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
return { t: 'folder-created', folder: { id: row.id, threadId: row.threadId, ...(row.parentId ? { parentId: row.parentId as string } : {}), name: row.name } satisfies FolderInfo };
|
||||
case 'folder.rename':
|
||||
return isId(row.id) && typeof row.name === 'string' && row.name.length > 0 && row.name.length <= 80
|
||||
? { t: 'folder-renamed', id: row.id, name: row.name } : { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
case 'items.move':
|
||||
if (!Array.isArray(row.itemIds) || row.itemIds.length > 100 || !row.itemIds.every(isItemId) || !isId(row.threadId)
|
||||
|| (row.folderId !== null && !isId(row.folderId))) return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
return { t: 'items-moved', itemIds: row.itemIds, threadId: row.threadId, ...(row.folderId ? { folderId: row.folderId as string } : {}) };
|
||||
default:
|
||||
return { t: 'error', message: '空间结构记录格式无效,已拒绝应用。' };
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import { channelMetadata, processChannelBytes, syncChannel } from './channelEven
|
||||
import { flushOutbox } from './transfer';
|
||||
import { socketStream, sseStream, delay, type StreamConsumer } from './transports';
|
||||
import { errorMessage } from '../errors';
|
||||
import { saveResource } from '../database';
|
||||
import { deviceIdentity } from './presentation';
|
||||
|
||||
export async function connectChannel(id: string): Promise<void> {
|
||||
active.get(id)?.abort();
|
||||
@@ -17,6 +19,15 @@ export async function connectChannel(id: string): Promise<void> {
|
||||
const { session, resource } = await getSession(id);
|
||||
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}/seats`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
|
||||
if (resource.needsJoin) {
|
||||
if (session.can('owner')) await channelMetadata(resource, session);
|
||||
const label = await session.sealLabel((await deviceIdentity(session.endpointSecret)).name);
|
||||
await apiFetch(`/channels/${id}/seats/me`, { method: 'PUT', headers: session.authHeaders('read'), body: label, signal });
|
||||
resource.needsJoin = false;
|
||||
await saveResource(resource);
|
||||
}
|
||||
const stream: StreamConsumer = {
|
||||
cursor: 0,
|
||||
async record(bytes) { stream.cursor = Math.max(stream.cursor, await processChannelBytes(resource, session, bytes)); },
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { storeVerifiedDownload } from './downloadStorage';
|
||||
|
||||
function content(chunks: number[][]) { return new ReadableStream<Uint8Array>({ start(c) { for (const chunk of chunks) c.enqueue(new Uint8Array(chunk)); c.close(); } }); }
|
||||
describe('verified download delivery', () => {
|
||||
it('writes each chunk separately and closes only after verified FINAL', async () => {
|
||||
const write = vi.fn(async () => {}); const close = vi.fn(async () => {});
|
||||
const temporary = { write, close, abort: vi.fn(async () => {}), remove: vi.fn(async () => {}), file: vi.fn(async () => new Blob([new Uint8Array([1, 2, 3])])) };
|
||||
const download = await storeVerifiedDownload({ content: content([[1], [2, 3]]), result: Promise.resolve({ complete: true, nextIndex: 2, meta: { kind: 'file', size: 3, ts: 0 } }) }, 3, async () => temporary);
|
||||
expect(download.blob.size).toBe(3); expect(write).toHaveBeenCalledTimes(2); expect(close).toHaveBeenCalledOnce(); expect(temporary.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each([false, true])('deletes incomplete or size-mismatched temporary plaintext (FINAL=%s)', async (complete) => {
|
||||
const temporary = { write: vi.fn(async () => {}), close: vi.fn(async () => {}), abort: vi.fn(async () => {}), remove: vi.fn(async () => {}), file: vi.fn(async () => new Blob()) };
|
||||
await expect(storeVerifiedDownload({ content: content([[1, 2]]), result: Promise.resolve({ complete, nextIndex: 1, meta: { kind: 'file', size: 3, ts: 0 } }) }, undefined, async () => temporary)).rejects.toThrow();
|
||||
expect(temporary.abort).toHaveBeenCalledOnce(); expect(temporary.remove).toHaveBeenCalledOnce(); expect(temporary.file).not.toHaveBeenCalled();
|
||||
});
|
||||
it('never delivers content after an authentication failure', async () => {
|
||||
const error = new Error('authentication failed');
|
||||
await expect(storeVerifiedDownload({ content: new ReadableStream({ start(c) { c.error(error); } }), result: Promise.reject(error) }, undefined, async () => undefined)).rejects.toThrow('authentication failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { IntegrityError, type ItemDecodeResult } from '@webdrop/core';
|
||||
|
||||
interface TemporaryFile {
|
||||
write(bytes: Uint8Array): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
abort(): Promise<void>;
|
||||
file(): Promise<Blob>;
|
||||
remove(): Promise<void>;
|
||||
}
|
||||
const FALLBACK_LIMIT = 64 * 1024 * 1024;
|
||||
let directory: Promise<{ root: FileSystemDirectoryHandle; dir: FileSystemDirectoryHandle; name: string }> | undefined;
|
||||
|
||||
async function temporaryFile(): Promise<TemporaryFile | undefined> {
|
||||
if (!navigator.storage?.getDirectory) return undefined;
|
||||
directory ??= (async () => {
|
||||
const root = await navigator.storage.getDirectory();
|
||||
const name = `webdrop-download-${crypto.randomUUID()}`;
|
||||
return { root, name, dir: await root.getDirectoryHandle(name, { create: true }) };
|
||||
})();
|
||||
const { dir } = await directory;
|
||||
const name = crypto.randomUUID();
|
||||
const handle = await dir.getFileHandle(name, { create: true });
|
||||
let writer: FileSystemWritableFileStream;
|
||||
try { writer = await handle.createWritable(); }
|
||||
catch (error) { await dir.removeEntry(name); throw error; }
|
||||
return {
|
||||
write: (bytes) => writer.write(Uint8Array.from(bytes).buffer),
|
||||
close: () => writer.close(), abort: () => writer.abort(), file: () => handle.getFile(),
|
||||
remove: () => dir.removeEntry(name),
|
||||
};
|
||||
}
|
||||
|
||||
export async function clearTemporaryDownloads(): Promise<void> {
|
||||
const current = directory; directory = undefined;
|
||||
if (current) { const { root, name } = await current; await root.removeEntry(name, { recursive: true }); }
|
||||
}
|
||||
|
||||
/** Deliver a file only after FINAL, contiguous frames and expected sizes are authenticated. */
|
||||
export async function storeVerifiedDownload(
|
||||
decoded: { content: ReadableStream<Uint8Array>; result: Promise<ItemDecodeResult> },
|
||||
expectedSize?: number,
|
||||
createTemporary: () => Promise<TemporaryFile | undefined> = temporaryFile,
|
||||
): Promise<{ blob: Blob; meta: ItemDecodeResult['meta'] }> {
|
||||
const reader = decoded.content.getReader();
|
||||
let temporary: TemporaryFile | undefined;
|
||||
const parts: ArrayBuffer[] = [];
|
||||
let size = 0;
|
||||
let closed = false;
|
||||
// A decoder may fail before its consumer starts; always observe both channels.
|
||||
void decoded.result.catch(() => {});
|
||||
try {
|
||||
temporary = await createTemporary();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
size += value.byteLength;
|
||||
if (expectedSize !== undefined && size > expectedSize) throw new IntegrityError('bad_meta');
|
||||
if (temporary) await temporary.write(value);
|
||||
else {
|
||||
if (size > FALLBACK_LIMIT) throw new Error('此浏览器不支持大文件临时存储,请使用支持 OPFS 的浏览器下载超过 64 MB 的文件');
|
||||
parts.push(Uint8Array.from(value).buffer);
|
||||
}
|
||||
}
|
||||
const result = await decoded.result;
|
||||
if (!result.complete) throw new Error('文件传输不完整');
|
||||
if (expectedSize !== undefined && size !== expectedSize || result.meta?.size !== undefined && size !== result.meta.size) throw new IntegrityError('bad_meta');
|
||||
if (temporary) { await temporary.close(); closed = true; }
|
||||
return { blob: temporary ? await temporary.file() : new Blob(parts), meta: result.meta };
|
||||
} catch (error) {
|
||||
await reader.cancel(error).catch(() => {});
|
||||
if (temporary) {
|
||||
if (!closed) await temporary.abort().catch(() => {});
|
||||
await temporary.remove().catch(() => {});
|
||||
}
|
||||
throw error;
|
||||
} finally { reader.releaseLock(); }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { FileItem } from '../types';
|
||||
import { IntegrityError, type ItemMeta } from '@webdrop/core';
|
||||
|
||||
export const fileCatalogs = new Map<string, FileItem>();
|
||||
export const visibleFiles = new Set<string>();
|
||||
export function clearFileCatalog(resourceId?: string): void {
|
||||
if (!resourceId) { fileCatalogs.clear(); visibleFiles.clear(); return; }
|
||||
for (const key of fileCatalogs.keys()) if (key.startsWith(`${resourceId}:`)) fileCatalogs.delete(key);
|
||||
for (const key of visibleFiles) if (key.startsWith(`${resourceId}:`)) visibleFiles.delete(key);
|
||||
}
|
||||
export function verifyFileCatalog(catalog: FileItem | undefined, meta: ItemMeta | undefined, size: number): void {
|
||||
if (!catalog) return;
|
||||
if (!meta || meta.kind !== 'file' || meta.name !== catalog.name || (meta.mime ?? 'application/octet-stream') !== catalog.mime
|
||||
|| size !== catalog.size || meta.threadId !== catalog.threadId || meta.folderId !== catalog.folderId) throw new IntegrityError('bad_meta');
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ChannelSession, collectFrames } from '@webdrop/core';
|
||||
import { applyControl } from './channelThreads';
|
||||
|
||||
const groupId = 'd7a19ad2-690c-44f2-bd0a-1b30b309bf92';
|
||||
const threadId = '15e7fb09-984d-47d4-8e74-477c7d69c58c';
|
||||
async function decode(control: Record<string, unknown>) {
|
||||
const { session } = await ChannelSession.create();
|
||||
try {
|
||||
const frames = await collectFrames(session.encryptBytes(new TextEncoder().encode(JSON.stringify({ app: 'webdrop.workspace', v: 1, ...control })),
|
||||
{ kind: 'other' }, { control: true, ts: 1 }));
|
||||
const event = (await session.createItemReader().pushFrame(frames[0]!.bytes)).find((row) => row.type === 'item');
|
||||
if (!event || event.type !== 'item') throw new Error('missing decoded control');
|
||||
return applyControl(event.item);
|
||||
} finally { session.dispose(); }
|
||||
}
|
||||
describe('encrypted channel groups', () => {
|
||||
it('replays the sender profile from an encrypted identity record', async () => {
|
||||
const author = { username: 'alice', displayName: '小白', avatar: 'data:image/png;base64,AA==' };
|
||||
expect(await decode({ op: 'item.author', itemId: 'AAAAAAAAAAAAAAAAAAAAAA', author })).toEqual({ t: 'item-author', id: 'AAAAAAAAAAAAAAAAAAAAAA', author });
|
||||
expect(await decode({ op: 'item.author', itemId: 'AAAAAAAAAAAAAAAAAAAAAA', author: { ...author, avatar: 'https://tracker.test/avatar' } })).toMatchObject({ t: 'error' });
|
||||
});
|
||||
it('replays a group and a channel created inside it', async () => {
|
||||
expect(await decode({ op: 'group.create', id: groupId, name: '项目' })).toEqual({ t: 'group-created', group: { id: groupId, name: '项目', createdAt: 1 } });
|
||||
expect(await decode({ op: 'thread.create', id: threadId, name: '讨论', groupId })).toMatchObject({ t: 'thread-created', thread: { id: threadId, groupId } });
|
||||
});
|
||||
it('replays moves and returning to ungrouped', async () => {
|
||||
expect(await decode({ op: 'thread.group', id: threadId, groupId })).toEqual({ t: 'thread-grouped', id: threadId, groupId });
|
||||
expect(await decode({ op: 'thread.group', id: threadId, groupId: null })).toEqual({ t: 'thread-grouped', id: threadId });
|
||||
expect(await decode({ op: 'group.rename', id: groupId, name: '资料' })).toEqual({ t: 'group-renamed', id: groupId, name: '资料' });
|
||||
});
|
||||
it.each([
|
||||
{ op: 'group.create', id: 'invalid', name: '项目' },
|
||||
{ op: 'group.rename', id: groupId, name: ' ' },
|
||||
{ op: 'group.create', id: groupId, name: '\u0000project' },
|
||||
{ op: 'thread.group', id: threadId, groupId: 'invalid' },
|
||||
{ op: 'thread.group', id: threadId },
|
||||
{ op: 'thread.create', id: threadId, name: '讨论', groupId: 'invalid' },
|
||||
])('rejects malformed control %#', async (control) => {
|
||||
expect(await decode(control)).toMatchObject({ t: 'error' });
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ describe('API contract preflight', () => {
|
||||
});
|
||||
|
||||
it('blocks API requests when the server has a newer breaking major', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '2.0.0' }), { status: 200 }));
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '3.0.0' }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
setApiBase('http://api.test/api/v1');
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('API contract preflight', () => {
|
||||
|
||||
it('checks the contract once and then sends the API request', async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '1.2.0' }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '2.2.0' }), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { supportsApiContract } from '../contract';
|
||||
import { accountToken } from './accountSession';
|
||||
|
||||
export type ApiError = Error & { status?: number; code?: string };
|
||||
let apiBase = '/api/v1';
|
||||
@@ -38,8 +39,12 @@ function ensureApiContract(): Promise<void> {
|
||||
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
||||
try {
|
||||
if (path !== '/info') await ensureApiContract();
|
||||
const headers = new Headers(init.headers);
|
||||
const token = accountToken();
|
||||
if (token && !headers.has('X-WD-Account')) headers.set('X-WD-Account', token);
|
||||
const response = await fetch(apiPath(path), {
|
||||
...init,
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
referrerPolicy: 'no-referrer',
|
||||
@@ -73,6 +78,8 @@ export async function apiUpload(
|
||||
xhr.withCredentials = false;
|
||||
xhr.responseType = 'text';
|
||||
xhr.setRequestHeader('Cache-Control', 'no-store');
|
||||
const token = accountToken();
|
||||
if (token) xhr.setRequestHeader('X-WD-Account', token);
|
||||
for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) onProgress(event.loaded, event.total);
|
||||
|
||||
@@ -2,13 +2,77 @@ import type { ItemEvent } from '@webdrop/core';
|
||||
import type { Device, FileItem, ShareEvent, WorkspaceEvent } from '../types';
|
||||
import { previewKindOf } from '@/lib/fileKind';
|
||||
import { publish } from './port';
|
||||
export function deviceIdentity(): { name: string; kind: Device['kind'] } {
|
||||
const mobile = /Android|iPhone|iPad|Mobile/i.test(navigator.userAgent);
|
||||
return { name: mobile ? '本机 · 移动设备' : '本机 · 浏览器', kind: mobile ? 'phone' : 'desktop' };
|
||||
interface ClientHints {
|
||||
mobile?: boolean;
|
||||
model?: string;
|
||||
platform?: string;
|
||||
platformVersion?: string;
|
||||
getHighEntropyValues?: (hints: string[]) => Promise<ClientHints>;
|
||||
}
|
||||
|
||||
let clientHintsPromise: Promise<ClientHints> | undefined;
|
||||
|
||||
async function readClientHints(): Promise<ClientHints> {
|
||||
const hints = (navigator as Navigator & { userAgentData?: ClientHints }).userAgentData;
|
||||
if (!hints?.getHighEntropyValues) return hints ?? {};
|
||||
clientHintsPromise ??= hints.getHighEntropyValues(['model', 'platform', 'platformVersion']).catch(() => hints);
|
||||
return clientHintsPromise;
|
||||
}
|
||||
|
||||
function clean(value: string | undefined): string | undefined {
|
||||
const result = value?.replace(/[<>\u0000-\u001f]/g, '').trim().slice(0, 48);
|
||||
return result || undefined;
|
||||
}
|
||||
|
||||
function version(value: string | undefined): string | undefined {
|
||||
return value?.split('.').slice(0, 2).join('.') || undefined;
|
||||
}
|
||||
|
||||
export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: Device['kind'] }> {
|
||||
const ua = navigator.userAgent;
|
||||
const hints = await readClientHints();
|
||||
const android = /Android/i.test(ua) || hints.platform === 'Android';
|
||||
const iphone = /iPhone|iPod/i.test(ua);
|
||||
const ipad = /iPad/i.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const tablet = ipad || (android && (hints.mobile === false || (!hints.mobile && !/Mobile/i.test(ua))));
|
||||
const mobile = !tablet && (android || iphone || /Mobile/i.test(ua));
|
||||
const kind: Device['kind'] = tablet ? 'tablet' : mobile ? 'phone' : 'desktop';
|
||||
const iosVersion = ua.match(/(?:iPhone )?OS ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
|
||||
const windowsVersion = version(hints.platformVersion);
|
||||
const macVersion = ua.match(/Mac OS X ([\d_]+)/i)?.[1]?.replaceAll('_', '.');
|
||||
const androidVersion = ua.match(/Android ([\d.]+)/i)?.[1];
|
||||
const platformVersion = version(hints.platformVersion);
|
||||
let identity: string;
|
||||
if (clean(hints.model)) {
|
||||
identity = `${clean(hints.model)} (${clean(hints.platform) ?? 'Android'}${platformVersion ? ` ${platformVersion}` : ''})`;
|
||||
} else if (iphone) {
|
||||
identity = `iPhone${iosVersion ? ` (iOS ${iosVersion})` : ''}`;
|
||||
} else if (ipad) {
|
||||
identity = `iPad${iosVersion ? ` (iPadOS ${iosVersion})` : ''}`;
|
||||
} else if (/Windows/i.test(ua) || hints.platform === 'Windows') {
|
||||
identity = `Windows${windowsVersion ? ` ${Number(windowsVersion.split('.')[0]) >= 13 ? '11' : '10'}` : ''}`;
|
||||
} else if (android) {
|
||||
identity = `Android${platformVersion ?? androidVersion ? ` ${platformVersion ?? androidVersion}` : ''}`;
|
||||
} else if (/Macintosh|Mac OS X/i.test(ua) || hints.platform === 'macOS') {
|
||||
identity = `Mac${platformVersion ?? macVersion ? ` (macOS ${platformVersion ?? macVersion})` : ''}`;
|
||||
} else if (/CrOS/i.test(ua) || hints.platform === 'Chrome OS') {
|
||||
identity = 'ChromeOS';
|
||||
} else if (/Linux/i.test(ua) || hints.platform === 'Linux') {
|
||||
identity = 'Linux';
|
||||
} else {
|
||||
identity = '未知设备';
|
||||
}
|
||||
const suffix = Array.from(endpointSecret.subarray(0, 4), (b) => b.toString(16).padStart(2, '0')).join('').toUpperCase();
|
||||
return { name: `${identity} · ${suffix}`, kind };
|
||||
}
|
||||
|
||||
export function deviceInfo(id: string, joinedAt: number, label: string | undefined, selfId: string): Device {
|
||||
return { id, name: label ?? '未知设备', kind: 'desktop', online: id === selfId, self: id === selfId, lastSeen: joinedAt };
|
||||
const name = label ?? '未知设备';
|
||||
const kind: Device['kind'] = /iPhone|iPod/i.test(name) ? 'phone'
|
||||
: /iPad|Galaxy Tab|Pixel Tablet|Android.*Tablet/i.test(name) ? 'tablet'
|
||||
: /Android|Pixel|Galaxy|OnePlus|Xiaomi|Redmi|OPPO|vivo|Huawei/i.test(name) ? 'phone'
|
||||
: 'desktop';
|
||||
return { id, name, kind, self: id === selfId, joinedAt };
|
||||
}
|
||||
export function showRejected(resourceId: string, event: Extract<ItemEvent, { type: 'rejected' }>, share = false): void {
|
||||
if (share) publish(resourceId, { t: 'integrity', message: '分享清单校验失败,内容已拒绝显示。' });
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SenderInfo } from '../types';
|
||||
import { accountDisplay } from './accountSession';
|
||||
|
||||
let lastAvatar: string | undefined;
|
||||
let thumbnail: Promise<string | undefined> | undefined;
|
||||
async function compactAvatar(value: string): Promise<string | undefined> {
|
||||
if (typeof OffscreenCanvas === 'undefined' || typeof createImageBitmap === 'undefined') return value.length <= 8000 ? value : undefined;
|
||||
try {
|
||||
const mime = value.slice(5, value.indexOf(';')); const raw = atob(value.slice(value.indexOf(',') + 1));
|
||||
const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0));
|
||||
const image = await createImageBitmap(new Blob([bytes], { type: mime }));
|
||||
try {
|
||||
const canvas = new OffscreenCanvas(64, 64); const context = canvas.getContext('2d'); if (!context) return undefined;
|
||||
const size = Math.min(image.width, image.height);
|
||||
context.drawImage(image, (image.width - size) / 2, (image.height - size) / 2, size, size, 0, 0, 64, 64);
|
||||
const encoded = await canvas.convertToBlob({ type: 'image/webp', quality: 0.7 });
|
||||
const data = new Uint8Array(await encoded.arrayBuffer());
|
||||
const result = `data:${encoded.type};base64,${btoa(String.fromCharCode(...data))}`;
|
||||
return result.length <= 8000 ? result : undefined;
|
||||
} finally { image.close(); }
|
||||
} catch { return undefined; }
|
||||
}
|
||||
export async function senderSnapshot(): Promise<SenderInfo | undefined> {
|
||||
const sender = accountDisplay(); if (!sender) return undefined;
|
||||
if (!sender.avatar) return { username: sender.username, displayName: sender.displayName };
|
||||
if (lastAvatar !== sender.avatar || !thumbnail) { lastAvatar = sender.avatar; thumbnail = compactAvatar(sender.avatar); }
|
||||
const avatar = await thumbnail;
|
||||
return { username: sender.username, displayName: sender.displayName, ...(avatar ? { avatar } : {}) };
|
||||
}
|
||||
@@ -1,12 +1,21 @@
|
||||
import { ChannelSession } from '@webdrop/core';
|
||||
import { getResource, type SavedResource } from '../database';
|
||||
import { getResource, currentResourceScope, type SavedResource } from '../database';
|
||||
import { fail } from './http';
|
||||
import { toCoreLink } from './links';
|
||||
import { clearFileCatalog } from './fileCatalog';
|
||||
import { clearTemporaryDownloads } from './downloadStorage';
|
||||
export const sessions = new Map<string, ChannelSession>();
|
||||
export const readers = new Map<string, ReturnType<ChannelSession['createItemReader']>>();
|
||||
export const active = new Map<string, AbortController>();
|
||||
export const blobs = new Map<string, Blob>();
|
||||
const opening = new Map<string, Promise<ChannelSession>>();
|
||||
export function clearResourceSessions(): void {
|
||||
clearFileCatalog();
|
||||
void clearTemporaryDownloads().catch(() => {});
|
||||
for (const controller of active.values()) controller.abort();
|
||||
for (const session of sessions.values()) session.dispose();
|
||||
active.clear(); sessions.clear(); readers.clear(); blobs.clear(); opening.clear();
|
||||
}
|
||||
export async function getSession(id: string): Promise<{ session: ChannelSession; resource: SavedResource }> {
|
||||
const resource = await getResource(id);
|
||||
if (!resource) throw fail(404, 'not_found');
|
||||
@@ -19,6 +28,7 @@ export async function getSession(id: string): Promise<{ session: ChannelSession;
|
||||
opening.set(id, task);
|
||||
}
|
||||
try { session = await task; } finally { opening.delete(id); }
|
||||
if (resource.localScope !== currentResourceScope()) { session.dispose(); throw new Error('账号已切换'); }
|
||||
sessions.set(id, session);
|
||||
}
|
||||
return { session, resource };
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function addShareFiles(id: string, files: File[]): Promise<void> {
|
||||
if (resource.kind !== 'share' || !session.can('owner') || resource.state !== 'draft') throw new Error('此分享已封存');
|
||||
await invalidateManifest(session, resource);
|
||||
const downloadLimit = resource.maxDownloads ?? 0;
|
||||
const maxFrameData = deviceIdentity().kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
|
||||
const maxFrameData = (await deviceIdentity(session.endpointSecret)).kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
|
||||
for (const file of files) {
|
||||
const ts = Date.now();
|
||||
let item: FileItem | undefined;
|
||||
|
||||
@@ -32,8 +32,10 @@ export async function connectShare(id: string): Promise<void> {
|
||||
publishShare(id, { t: 'share', share });
|
||||
|
||||
if (raw.state === 'draft' && resource.mine) {
|
||||
const resumed = await flushOutbox(resource, session);
|
||||
if (resumed) resource.draftItems = resource.draftItems?.map((item) => item.state.kind === 'incomplete' ? { ...item, state: { kind: 'complete' } } : item);
|
||||
await flushOutbox(resource, session);
|
||||
const current = await readShareInfo(session, resource);
|
||||
resource.draftItems = resource.draftItems?.map((item) => item.kind === 'file'
|
||||
? { ...item, state: { kind: current.items.some((row) => row.item_id === item.id && row.complete) ? 'complete' : 'incomplete' } } : item);
|
||||
await saveResource(resource);
|
||||
if (controller.signal.aborted) return;
|
||||
for (const item of resource.draftItems ?? []) publishShare(id, { t: 'item', item });
|
||||
@@ -93,6 +95,11 @@ export async function getShareBlob(id: string, itemId: string): Promise<Blob> {
|
||||
blobs.set(`${id}:${itemId}`, draftFile);
|
||||
return draftFile;
|
||||
}
|
||||
const entry = resource.manifestEntries?.find((row) => row.t === 'file' && row.id === itemId);
|
||||
if (!entry || entry.t !== 'file') throw fail(404, 'not_found');
|
||||
const result = await downloadDecrypted(session, resource, itemId, entry.size);
|
||||
blobs.set(`${id}:${itemId}`, result.blob);
|
||||
return result.blob;
|
||||
}
|
||||
const raw = await readJson<ShareWire>(`/shares/${id}`, session.authHeaders('read'));
|
||||
const manifest = await fetchManifest(session, id, raw);
|
||||
|
||||
@@ -6,11 +6,13 @@ import { active, blobs, getSession, sessions } from './sessions';
|
||||
import { apiFetch, fail, readJson } from './http';
|
||||
import { apiShareSettings, toShareInfo, type ShareWire } from './wire';
|
||||
import { makeLink, toShareLink } from './links';
|
||||
import { requireAccount } from './accountSession';
|
||||
|
||||
|
||||
|
||||
|
||||
export async function createShare(settings: ShareSettings, captchaToken?: string): Promise<Share> {
|
||||
requireAccount();
|
||||
const endpointSecret = crypto.getRandomValues(new Uint8Array(32));
|
||||
const { session, request } = await ChannelSession.create({ mode: 'enc', endpointSecret });
|
||||
try {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { fromBase64Url, IntegrityError, MAX_FRAME_DATA, type ChannelSession, type ItemDecodeResult, type ItemPlan } from '@webdrop/core';
|
||||
import { dequeueFrame, queueFrame, nextQueuedFrame, type SavedResource } from '../database';
|
||||
import { dequeueFrame, queueFrame, nextQueuedFrame, lastQueuedAt, type SavedResource } from '../database';
|
||||
import { apiFetch, apiUpload, type ApiError } from './http';
|
||||
import { serial } from './serial';
|
||||
import { storeVerifiedDownload } from './downloadStorage';
|
||||
|
||||
// Frame plaintext includes a 4-byte length prefix before padding to the 256 KiB tier.
|
||||
export const MOBILE_UPLOAD_FRAME_DATA_BYTES = 256 * 1024 - 4;
|
||||
@@ -13,7 +14,7 @@ export async function persistPlan(
|
||||
plan: ItemPlan,
|
||||
onProgress?: (frames: number, bytes: number) => void,
|
||||
): Promise<{ frames: number; bytes: number }> {
|
||||
const createdAt = Date.now();
|
||||
const createdAt = Math.max(Date.now(), (await lastQueuedAt(resource.id)) + 1);
|
||||
let frames = 0;
|
||||
let bytes = 0;
|
||||
for await (const frame of plan.frames) {
|
||||
@@ -94,7 +95,7 @@ export async function downloadDecrypted(
|
||||
itemId: string,
|
||||
expectedSize?: number,
|
||||
): Promise<{ blob: Blob; meta: ItemDecodeResult['meta'] }> {
|
||||
const role = session.can('read') ? 'read' : 'owner';
|
||||
const role = resource.kind === 'share' && resource.state === 'draft' ? 'owner' : session.can('read') ? 'read' : 'owner';
|
||||
const path = resource.kind === 'share'
|
||||
? `/shares/${resource.id}/items/${itemId}/frames?from=0`
|
||||
: `/channels/${resource.id}/items/${itemId}/frames?from=0`;
|
||||
@@ -103,10 +104,5 @@ export async function downloadDecrypted(
|
||||
const expectedId = fromBase64Url(itemId, 16);
|
||||
if (!expectedId) throw new IntegrityError('bad_meta');
|
||||
const decoded = session.decryptItemStream(response.body, { itemId: expectedId });
|
||||
const blobPromise = new Response(decoded.content).blob();
|
||||
const [result, blob] = await Promise.all([decoded.result, blobPromise]);
|
||||
if (!result.complete) throw new Error('文件传输不完整');
|
||||
if (result.meta?.size !== undefined && result.meta.size !== blob.size) throw new IntegrityError('bad_meta');
|
||||
if (expectedSize !== undefined && expectedSize !== blob.size) throw new IntegrityError('bad_meta');
|
||||
return { blob, meta: result.meta };
|
||||
return storeVerifiedDownload(decoded, expectedSize);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fromBase64Url, toBase64Url, type ChannelSession } from '@webdrop/core';
|
||||
import { apiFetch, apiPath } from './http';
|
||||
import { accountToken } from './accountSession';
|
||||
|
||||
export interface StreamConsumer {
|
||||
cursor: number;
|
||||
@@ -13,7 +14,8 @@ export function socketStream(id: string, session: ChannelSession, signal: AbortS
|
||||
const address = new URL(apiPath(`/channels/${id}/ws?from=${consumer.cursor}`), self.location.origin);
|
||||
address.protocol = address.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = session.authHeaders('read').Authorization?.slice(7);
|
||||
const socket = new WebSocket(address, ['wd.v1', 'wd.role.read', ...(token ? [`wd.auth.${token}`] : []), `wd.ep.${toBase64Url(session.endpointSecret)}`]);
|
||||
const identity = accountToken()?.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
const socket = new WebSocket(address, ['wd.v1', 'wd.role.read', ...(token ? [`wd.auth.${token}`] : []), ...(identity ? [`wd.account.${identity}`] : []), `wd.ep.${toBase64Url(session.endpointSecret)}`]);
|
||||
socket.binaryType = 'arraybuffer';
|
||||
return new Promise((resolve, reject) => {
|
||||
let chain = Promise.resolve();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { SavedResource } from '../database';
|
||||
import type { ChannelInfo, Share, ShareSettings } from '../types';
|
||||
export interface ChannelWire {
|
||||
creator_account?: string;
|
||||
password_required?: boolean;
|
||||
id: string;
|
||||
mode: 'enc' | 'raw';
|
||||
settings: { seats: number; members_can_upload: boolean; delete_policy: 'owner_only' | 'own' | 'anyone'; ttl_seconds: number; max_items: number; file_max_downloads: number };
|
||||
settings: { name?: string; join_policy?: 'open' | 'invite'; allow_guests?: boolean; seats: number; members_can_upload: boolean; delete_policy: 'owner_only' | 'own' | 'anyone'; ttl_seconds: number; max_items: number; file_max_downloads: number };
|
||||
seats_used: number;
|
||||
oldest_seq: number;
|
||||
last_seq: number;
|
||||
@@ -11,7 +13,7 @@ export interface ChannelWire {
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
export interface ShareWireItem { item_id: string; bytes: number; frames: number; max_downloads: number; downloads_left: number | null }
|
||||
export interface ShareWireItem { item_id: string; bytes: number; frames: number; complete?: boolean; max_downloads: number; downloads_left: number | null }
|
||||
export interface ShareWire {
|
||||
id: string;
|
||||
state: 'draft' | 'sealed';
|
||||
@@ -25,10 +27,17 @@ export interface ShareWire {
|
||||
export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelInfo {
|
||||
return {
|
||||
id: raw.id,
|
||||
creatorAccount: raw.creator_account,
|
||||
joinPolicy: raw.settings.join_policy ?? 'open', allowGuests: raw.settings.allow_guests ?? true, passwordRequired: raw.password_required ?? false,
|
||||
name: raw.settings.name,
|
||||
mode: raw.mode,
|
||||
role: saved.role,
|
||||
canUpload: saved.role === 'owner' || raw.settings.members_can_upload,
|
||||
seats: raw.settings.seats,
|
||||
seatsUsed: raw.seats_used,
|
||||
membersCanUpload: raw.settings.members_can_upload,
|
||||
ttlSeconds: raw.settings.ttl_seconds,
|
||||
maxDownloads: raw.settings.file_max_downloads,
|
||||
memberLink: saved.link,
|
||||
ownerLink: saved.ownerLink,
|
||||
createdAt: raw.created_at,
|
||||
|
||||
@@ -7,7 +7,7 @@ import styles from './ActionBar.module.css';
|
||||
interface Props {
|
||||
/** dock:手机底部动作栏;toolbar:电脑列表上方的工具条 */
|
||||
layout: 'dock' | 'toolbar';
|
||||
/** 添加文件、文本:频道发到频道里,分享放进草稿 */
|
||||
/** 添加文件、文本:空间发到空间里,分享放进草稿 */
|
||||
onFiles: (files: File[]) => void;
|
||||
onText: (text: string) => void;
|
||||
onCompose: () => void;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
|
||||
import type { AccountInvitation } from '@/core-bridge/account';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './ChannelSettingsSheet.module.css';
|
||||
|
||||
export function ChannelInvitations({ owner }: { owner: boolean }) {
|
||||
const channelId = useWorkspace((s) => s.channel?.id);
|
||||
const invite = useWorkspace((s) => s.inviteUser);
|
||||
const list = useWorkspace((s) => s.listInvitations);
|
||||
const revoke = useWorkspace((s) => s.revokeInvitation);
|
||||
const [members, setMembers] = useState<ChannelMember[]>([]);
|
||||
const [invitations, setInvitations] = useState<AccountInvitation[]>([]);
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
async function refresh() {
|
||||
if (channelId) setMembers(await channelAccessApi.members(channelId));
|
||||
if (owner) setInvitations(await list());
|
||||
}
|
||||
useEffect(() => { void refresh().catch(() => setError('成员清单读取失败')); }, [channelId, owner, list]);
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault(); setBusy(true); setError('');
|
||||
try { await invite(username); setUsername(''); await refresh(); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '邀请失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
async function remove(username: string) {
|
||||
setBusy(true); setError('');
|
||||
try { await revoke(username); await refresh(); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '移除失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <section className={styles.form} aria-label="空间成员与邀请"><strong>空间成员</strong>
|
||||
{members.map((member) => <div className={styles.member} key={member.username}>
|
||||
<span>{member.username} · {member.role === 'owner' ? '空间主' : '成员'}</span>
|
||||
{owner && member.role !== 'owner' ? <Button variant="ghost" disabled={busy} onClick={() => { void remove(member.username); }}>移除成员</Button> : null}
|
||||
</div>)}
|
||||
<p className={styles.note}>未登录访客以独立设备身份加入,可在设备面板移除。</p>
|
||||
{owner ? <><strong>邀请用户</strong><form className={styles.member} onSubmit={(event) => { void submit(event); }}>
|
||||
<input aria-label="被邀请用户的用户名" maxLength={32} value={username} onChange={(event) => setUsername(event.target.value)} placeholder="已注册用户的用户名" required />
|
||||
<Button type="submit" disabled={busy || !username.trim()}>发送邀请</Button>
|
||||
</form>
|
||||
{invitations.map((row) => <div className={styles.member} key={row.username}><span>{row.username} · 已邀请</span><Button variant="ghost" disabled={busy} onClick={() => { void remove(row.username); }}>撤销邀请</Button></div>)}
|
||||
<p className={styles.note}>移除会取消加入授权并断开该账号的设备。已读取的内容无法收回。</p></> : null}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
.member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.member input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.check input {
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.field input:disabled,
|
||||
.field select:disabled {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--danger);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import type { ChannelInfo, ChannelSettingsPatch } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import styles from './ChannelSettingsSheet.module.css';
|
||||
import { ChannelInvitations } from './ChannelInvitations';
|
||||
|
||||
const TTL_OPTIONS = [
|
||||
{ label: '1 小时', value: 3600 },
|
||||
{ label: '24 小时', value: 86400 },
|
||||
{ label: '7 天', value: 604800 },
|
||||
];
|
||||
const DOWNLOAD_OPTIONS = [0, 1, 3, 5, 10, 20];
|
||||
|
||||
interface Props {
|
||||
channel: ChannelInfo;
|
||||
onClose: () => void;
|
||||
onSave: (patch: ChannelSettingsPatch) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ChannelSettingsSheet({ channel, onClose, onSave }: Props) {
|
||||
const owner = channel.role === 'owner';
|
||||
const [name, setName] = useState(channel.name ?? '');
|
||||
const [seats, setSeats] = useState(channel.seats);
|
||||
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload ?? channel.canUpload ?? true);
|
||||
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds ?? 86400);
|
||||
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads ?? 10);
|
||||
const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy ?? 'open');
|
||||
const [allowGuests, setAllowGuests] = useState(channel.allowGuests ?? true);
|
||||
const [joinPassword, setJoinPassword] = useState('');
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const ttlOptions = TTL_OPTIONS.some((option) => option.value === ttlSeconds)
|
||||
? TTL_OPTIONS
|
||||
: [{ label: `当前设置 (${ttlSeconds} 秒)`, value: ttlSeconds }, ...TTL_OPTIONS];
|
||||
const downloadOptions = [...new Set([...DOWNLOAD_OPTIONS, fileMaxDownloads])].sort((a, b) => a - b);
|
||||
|
||||
async function save() {
|
||||
if (!owner || saving) return;
|
||||
setSaving(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
await onSave({ name: name.trim(), seats, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
|
||||
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
||||
onClose();
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet title="空间设置" onClose={onClose}>
|
||||
<div className={styles.form}>
|
||||
<label className={styles.field}>
|
||||
<span>空间名称</span>
|
||||
<input type="text" maxLength={48} value={name} disabled={!owner} placeholder="例如:工作设备" onChange={(event) => setName(event.target.value)} />
|
||||
<span className={styles.note}>空间名称和设置对加入者及服务端可见。</span>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>设备席位</span>
|
||||
<input type="number" min={1} max={64} step={1} value={seats} disabled={!owner} onChange={(event) => setSeats(Number(event.target.value))} />
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>内容保留时间</span>
|
||||
<select value={ttlSeconds} disabled={!owner} onChange={(event) => setTtlSeconds(Number(event.target.value))}>
|
||||
{ttlOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>受限文件下载次数</span>
|
||||
<select value={fileMaxDownloads} disabled={!owner} onChange={(event) => setFileMaxDownloads(Number(event.target.value))}>
|
||||
{downloadOptions.map((value) => <option key={value} value={value}>{value === 0 ? '不限' : `${value} 次`}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.check}>
|
||||
<input type="checkbox" checked={membersCanUpload} disabled={!owner} onChange={(event) => setMembersCanUpload(event.target.checked)} />
|
||||
<span>允许成员上传</span>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}><span>加入方式</span>
|
||||
<select value={joinPolicy} disabled={!owner} onChange={(event) => setJoinPolicy(event.target.value as 'open' | 'invite')}>
|
||||
<option value="open">持链接加入</option><option value="invite">仅受邀账号加入</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className={styles.check}><input type="checkbox" checked={allowGuests} disabled={!owner || joinPolicy === 'invite'} onChange={(event) => setAllowGuests(event.target.checked)} /><span>允许未登录的陌生人加入</span></label>
|
||||
<label className={styles.field}><span>{channel.passwordRequired ? '更换空间加入密码' : '空间加入密码(可选)'}</span>
|
||||
<input type="password" autoComplete="new-password" minLength={8} maxLength={256} value={joinPassword} disabled={!owner || clearPassword} placeholder="至少 8 个字符,留空不修改" onChange={(event) => setJoinPassword(event.target.value)} />
|
||||
<span className={styles.note}>空间主及受邀用户无需加入密码。修改规则不移除已加入成员。</span>
|
||||
</label>
|
||||
{owner && channel.passwordRequired ? <label className={styles.check}><input type="checkbox" checked={clearPassword} onChange={(event) => setClearPassword(event.target.checked)} /><span>移除空间加入密码</span></label> : null}
|
||||
<ChannelInvitations owner={owner} />
|
||||
{owner ? (
|
||||
<>
|
||||
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
|
||||
<Button variant="primary" disabled={saving || seats < 1 || seats > 64 || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
|
||||
</>
|
||||
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { channelPath, navigate } from '@/app/navigation';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import styles from './ThreadSidebar.module.css';
|
||||
|
||||
export function ChannelSwitcher({ id, onCreate }: { id?: string; onCreate: () => void }) {
|
||||
const channels = useChannels((s) => s.list);
|
||||
const username = useAccount((s) => s.username);
|
||||
const personal = useAccount((s) => s.personal);
|
||||
return <section className={styles.panel} aria-label="我的空间导航">
|
||||
<button className={styles.identity} type="button" onClick={() => navigate('/')} aria-label="账号与设备设置">
|
||||
{personal?.avatar ? <img src={personal.avatar} className={styles.avatar} alt="" /> : <Icon name="desktop" size={20} />}
|
||||
<span>{personal?.displayName || username || '访客'}</span>
|
||||
</button>
|
||||
<div className={styles.heading}><strong>空间</strong><Button variant="ghost" icon="edit" label="新建空间" disabled={!username} onClick={onCreate} /></div>
|
||||
<nav className={styles.channels} aria-label="空间列表">{channels.map((channel) => <button key={channel.id} type="button"
|
||||
className={`${styles.thread} ${channel.id === id ? styles.selected : ''}`} aria-current={channel.id === id ? 'page' : undefined}
|
||||
onClick={() => navigate(channelPath(channel.id))}><Icon name={channel.mode === 'enc' ? 'lock' : 'file'} size={14} /><span>{channel.name || channel.id.slice(0, 8)}</span></button>)}</nav>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
import { iconOf } from '@/features/item/summary';
|
||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './ChatPane.module.css';
|
||||
|
||||
export function ChatMessage({ id, onOpenFile, onDelete }: { id: string; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
const owner = useWorkspace((s) => s.channel?.role === 'owner');
|
||||
const username = useAccount((s) => s.username);
|
||||
const download = useDownload();
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
if (!item) return null;
|
||||
const sender = item.author;
|
||||
const name = sender?.displayName || sender?.username || item.deviceId || '未知用户';
|
||||
const self = sender?.username !== undefined && sender.username === username;
|
||||
const ready = item.state.kind === 'complete';
|
||||
async function save() {
|
||||
if (!item || downloading) return;
|
||||
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
|
||||
}
|
||||
return <article className={`${styles.message} ${self ? styles.mine : ''}`} aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`}>
|
||||
{sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
|
||||
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
|
||||
<div className={styles.bubble}>
|
||||
<header><strong className={styles.senderName} title={sender ? `${sender.username} · ${item.deviceId}` : item.deviceId}>{name}</strong><time>{formatTime(item.ts)}</time>
|
||||
{owner || self ? <Button variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
|
||||
</header>
|
||||
{item.state.kind === 'tampered' ? <p className={styles.integrity}>内容校验失败,已拒绝显示。</p> : item.kind === 'text' ? <p className={styles.messageText}>{item.text}</p>
|
||||
: <div className={styles.fileMessage}>
|
||||
<button type="button" className={styles.fileCard} aria-label={`预览文件 ${item.name}`} disabled={!ready} onClick={() => onOpenFile(id)}>
|
||||
<span className={styles.fileIcon}><Icon name={iconOf(item)} size={28} /></span>
|
||||
<span className={styles.fileInfo}><strong>{item.name}</strong><span>{formatSize(item.size)} · {item.mime}</span></span>
|
||||
</button>
|
||||
<div className={styles.fileActions}><Button variant="ghost" icon="eye" disabled={!ready} onClick={() => onOpenFile(id)}>预览</Button>
|
||||
<Button variant="ghost" icon="download" disabled={!ready || downloading} onClick={() => { void save(); }}>{downloading ? '下载中…' : '下载'}</Button>
|
||||
</div>
|
||||
</div>}
|
||||
{item.state.kind === 'receiving' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
|
||||
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
|
||||
</div>
|
||||
</article>;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
.pane {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: var(--bar-h);
|
||||
padding: 0 var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.head span,
|
||||
.empty,
|
||||
.message time {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: var(--space-4);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.empty {
|
||||
margin: var(--space-2) 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.mine {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-avatar);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatarFallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--surface-2);
|
||||
color: var(--text-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
min-width: 0;
|
||||
max-width: min(85%, 560px);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
.mine .bubble {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.message header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.senderName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.message header time {
|
||||
margin-inline-end: auto;
|
||||
}
|
||||
|
||||
.messageText {
|
||||
margin: var(--space-1) 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fileMessage {
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.fileCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
width: 100%;
|
||||
padding: var(--space-3) 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fileInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fileInfo strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fileInfo > span,
|
||||
.delivery {
|
||||
color: var(--text-dim);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.fileActions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.delivery {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.delivery progress {
|
||||
width: 100%;
|
||||
height: var(--space-1);
|
||||
accent-color: var(--busy);
|
||||
}
|
||||
|
||||
.integrity {
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.composer {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.composer textarea {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 42px;
|
||||
max-height: 112px;
|
||||
padding: var(--space-2);
|
||||
resize: vertical;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 0 var(--space-3) var(--space-2);
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 899px) {
|
||||
.pane {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from 'react';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import styles from './ChatPane.module.css';
|
||||
|
||||
interface Props { threadName: string; ids: string[]; enabled: boolean; onSend: (text: string) => Promise<void>; onDelete: (id: string) => void; onOpenFile: (id: string) => void; onFiles?: (files: File[]) => void }
|
||||
|
||||
export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFile, onFiles }: Props) {
|
||||
const [text, setText] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const messages = useRef<HTMLDivElement>(null);
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const chronological = ids.slice().reverse();
|
||||
|
||||
useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]);
|
||||
|
||||
async function submit() {
|
||||
const value = text.trim();
|
||||
if (!value || busy || !enabled) return;
|
||||
setBusy(true);
|
||||
setError('');
|
||||
try { await onSend(value); setText(''); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '发送失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
|
||||
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void submit(); }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.pane} aria-label={`${threadName} 聊天`}>
|
||||
<div className={styles.head}><strong>{threadName}</strong><span>频道聊天</span></div>
|
||||
<div className={styles.messages} ref={messages} aria-live="polite">
|
||||
{chronological.length === 0 ? <p className={styles.empty}>在这里发送消息或文件,成员可以看到你的名称和头像。</p> : null}
|
||||
{chronological.map((id) => <ChatMessage key={id} id={id} onOpenFile={onOpenFile} onDelete={onDelete} />)}
|
||||
</div>
|
||||
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); void submit(); }}>
|
||||
{onFiles ? <><input ref={fileInput} type="file" multiple hidden onChange={(event) => { if (event.target.files?.length) onFiles([...event.target.files]); event.target.value = ''; }} />
|
||||
<Button variant="ghost" icon="clip" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
|
||||
<textarea value={text} disabled={!enabled || busy} rows={2} placeholder={enabled ? '输入消息,Enter 发送,Shift+Enter 换行' : '先创建或加入一个频道'} onChange={(event) => setText(event.target.value)} onKeyDown={keyDown} />
|
||||
<Button variant="primary" icon="send" label="发送消息" disabled={!enabled || busy || !text.trim()} onClick={() => void submit()} />
|
||||
</form>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -24,40 +24,87 @@
|
||||
|
||||
.device {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 40px;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.deviceRow,
|
||||
.deviceIdentity,
|
||||
.deviceActions,
|
||||
.deviceEditor,
|
||||
.deviceMetaRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.deviceIdentity {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.deviceMetaRow {
|
||||
min-height: 32px;
|
||||
padding-left: var(--space-6);
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.online,
|
||||
.offline {
|
||||
.current,
|
||||
.deviceMeta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.online {
|
||||
.current {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
.online::before {
|
||||
content: "";
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--ok);
|
||||
animation: breathe 2.4s ease-in-out infinite;
|
||||
.deviceMeta {
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.offline {
|
||||
color: var(--text-dim);
|
||||
.deviceEditor {
|
||||
padding-left: var(--space-6);
|
||||
margin-top: var(--space-1);
|
||||
}
|
||||
|
||||
.deviceActions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.deviceActions > button {
|
||||
min-height: 36px;
|
||||
padding-inline: var(--space-2);
|
||||
}
|
||||
|
||||
.deviceInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.deviceError {
|
||||
padding-left: var(--space-6);
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.link {
|
||||
@@ -97,15 +144,3 @@
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@keyframes breathe {
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.online::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -11,24 +11,93 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
const devices = useWorkspace((s) => s.devices);
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const simulate = useWorkspace((s) => s.simulateConnection);
|
||||
const renameDevice = useWorkspace((s) => s.renameDevice);
|
||||
const releaseDevice = useWorkspace((s) => s.releaseDevice);
|
||||
const [showOwner, setShowOwner] = useState(false);
|
||||
const [showQr, setShowQr] = useState(qrOpen);
|
||||
const online = devices.filter((d) => d.online).length;
|
||||
const [editingId, setEditingId] = useState<string>();
|
||||
const [nameDraft, setNameDraft] = useState('');
|
||||
const [confirmKickId, setConfirmKickId] = useState<string>();
|
||||
const [busyId, setBusyId] = useState<string>();
|
||||
const [deviceError, setDeviceError] = useState<{ id: string; message: string }>();
|
||||
const owner = channel?.role === 'owner';
|
||||
const seatsUsed = channel?.seatsUsed ?? '—';
|
||||
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
|
||||
|
||||
async function saveDeviceName(id: string) {
|
||||
if (busyId) return;
|
||||
setBusyId(id);
|
||||
setDeviceError(undefined);
|
||||
try {
|
||||
await renameDevice(id, nameDraft);
|
||||
setEditingId(undefined);
|
||||
} catch {
|
||||
setDeviceError({ id, message: '设备名称保存失败,请重试。' });
|
||||
} finally {
|
||||
setBusyId(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
async function kickDevice(id: string) {
|
||||
if (busyId) return;
|
||||
setBusyId(id);
|
||||
setDeviceError(undefined);
|
||||
try {
|
||||
await releaseDevice(id);
|
||||
setConfirmKickId(undefined);
|
||||
} catch {
|
||||
setDeviceError({ id, message: '移除设备失败,请重试。' });
|
||||
} finally {
|
||||
setBusyId(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={styles.panel}>
|
||||
<section>
|
||||
<h2 className={styles.h}>设备 <span className={styles.dim}>{online}/{channel?.seats} 在线</span></h2>
|
||||
<h2 className={styles.h}>设备 <span className={styles.dim}>{seatsUsed}/{channel?.seats} 已加入</span></h2>
|
||||
<ul className={styles.list}>
|
||||
{devices.map((d) => (
|
||||
<li key={d.id} className={styles.device}>
|
||||
<Icon name={d.kind} size={18} />
|
||||
<span className={styles.name}>
|
||||
{d.name}
|
||||
</span>
|
||||
<span className={d.online ? styles.online : styles.offline}>
|
||||
{d.online ? '在线' : d.lastSeen ? formatAgo(d.lastSeen) : '离线'}
|
||||
</span>
|
||||
<div className={styles.deviceRow}>
|
||||
<div className={styles.deviceIdentity}>
|
||||
<Icon name={d.kind} size={18} />
|
||||
<span className={styles.name}>{d.name}</span>
|
||||
</div>
|
||||
{(owner || d.self) && editingId !== d.id ? (
|
||||
<Button variant="ghost" icon="edit" label={`重命名 ${d.name}`} onClick={() => { setEditingId(d.id); setNameDraft(d.name); setDeviceError(undefined); }} />
|
||||
) : null}
|
||||
{owner && !d.self ? (
|
||||
confirmKickId !== d.id ? <Button variant="ghost" icon="trash" label={`移除 ${d.name}`} onClick={() => { setConfirmKickId(d.id); setDeviceError(undefined); }} /> : null
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.deviceMetaRow}>
|
||||
<span className={d.self ? styles.current : styles.deviceMeta}>
|
||||
{d.self ? '当前设备' : d.joinedAt ? `加入于${formatAgo(d.joinedAt)}` : '已加入'}
|
||||
</span>
|
||||
{owner && confirmKickId === d.id ? (
|
||||
<span className={styles.deviceActions}>
|
||||
<Button variant="ghost" disabled={busyId === d.id} onClick={() => void kickDevice(d.id)}>{busyId === d.id ? '移除中' : '确认移除'}</Button>
|
||||
<Button variant="ghost" disabled={busyId === d.id} onClick={() => setConfirmKickId(undefined)}>取消</Button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{editingId === d.id ? (
|
||||
<div className={styles.deviceEditor}>
|
||||
<input
|
||||
className={styles.deviceInput}
|
||||
type="text"
|
||||
maxLength={48}
|
||||
value={nameDraft}
|
||||
onChange={(event) => setNameDraft(event.target.value)}
|
||||
onKeyDown={(event) => { if (event.key === 'Enter') void saveDeviceName(d.id); }}
|
||||
aria-label={`设备名称 ${d.name}`}
|
||||
/>
|
||||
<Button variant="ghost" disabled={busyId === d.id || !nameDraft.trim()} onClick={() => void saveDeviceName(d.id)}>{busyId === d.id ? '保存中' : '保存'}</Button>
|
||||
<Button variant="ghost" onClick={() => setEditingId(undefined)}>取消</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{deviceError?.id === d.id ? <span className={styles.deviceError} role="alert">{deviceError.message}</span> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -36,6 +105,13 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
|
||||
<section>
|
||||
<h2 className={styles.h}>分享</h2>
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>账号空间地址</div>
|
||||
<code className={styles.code}>{accountAddress}</code>
|
||||
<CopyButton text={accountAddress} />
|
||||
<p className={styles.note}>此地址不包含密钥。在空间设置中邀请账号后,成员登录即可自动领取密钥包。</p>
|
||||
</div>
|
||||
{channel?.joinPolicy === 'open' ? <>
|
||||
<div className={styles.link}>
|
||||
<div className={styles.label}>成员链接</div>
|
||||
<code className={styles.code}>{channel?.memberLink}</code>
|
||||
@@ -58,8 +134,9 @@ export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
|
||||
</div>
|
||||
) : null}
|
||||
<p className={styles.note}>
|
||||
持有链接即可访问,并能读取此频道的全部历史内容。不隐藏 IP 与访问时间。
|
||||
持有链接并符合空间加入规则后,可读取空间历史内容。不隐藏 IP 与访问时间。
|
||||
</p>
|
||||
</> : null}
|
||||
</section>
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { DragEvent, MouseEvent } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { iconOf } from '@/features/item/summary';
|
||||
import styles from './FileExplorer.module.css';
|
||||
|
||||
interface Props {
|
||||
id: string; selected: boolean; checked: boolean; enabled: boolean;
|
||||
onSelect: (id: string, event: MouseEvent) => void; onOpen: (id: string) => void; onToggle: (id: string) => void;
|
||||
onMenu: (id: string, x: number, y: number) => void;
|
||||
onHover: (id: string, rect: DOMRect) => void; onLeave: () => void;
|
||||
onDrag: (id: string, event: DragEvent) => void; onDragEnd: () => void;
|
||||
}
|
||||
export function ExplorerFileRow(props: Props) {
|
||||
const { id } = props;
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
if (!item || item.kind !== 'file') return null;
|
||||
const available = item.state.kind !== 'receiving' && item.state.kind !== 'tampered';
|
||||
return <tr className={props.checked ? styles.selected : undefined} aria-selected={props.checked}
|
||||
onClick={(event) => props.onSelect(id, event)} onDoubleClick={() => { if (available) props.onOpen(id); }}
|
||||
onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); props.onMenu(id, event.clientX, event.clientY); }}
|
||||
onMouseEnter={(event) => props.onHover(id, event.currentTarget.getBoundingClientRect())} onMouseLeave={props.onLeave}
|
||||
draggable={props.enabled && available} onDragStart={(event) => props.onDrag(id, event)} onDragEnd={props.onDragEnd}>
|
||||
<td><input type="checkbox" aria-label={`选择文件 ${item.name}`} checked={props.checked} onChange={() => props.onToggle(id)} onClick={(event) => event.stopPropagation()} /></td>
|
||||
<td><button className={styles.name} type="button" onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); if (available) props.onOpen(id); }
|
||||
if (event.key === 'ContextMenu' || event.shiftKey && event.key === 'F10') { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(id, rect.left, rect.bottom); } }}>
|
||||
<Icon name={item.state.kind === 'tampered' ? 'warn' : iconOf(item)} size={18} /><span>{item.name}</span>
|
||||
</button>{item.state.kind === 'receiving' ? <span className={styles.progress}>{formatTransferProgress(item.state)}</span> : null}
|
||||
{item.state.kind === 'tampered' ? <span className={styles.error}>内容校验失败</span> : null}</td>
|
||||
<td className={styles.numeric}>{formatSize(item.size)}</td><td className={styles.date}>{formatTime(item.ts)}</td>
|
||||
<td><Button variant="ghost" icon="more" label={`文件操作 ${item.name}`} onClick={(event) => { event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(id, rect.right, rect.bottom); }} /></td>
|
||||
</tr>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { DragEvent } from 'react';
|
||||
import type { FolderInfo } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import styles from './FileExplorer.module.css';
|
||||
|
||||
interface Props {
|
||||
folder: FolderInfo; selected: boolean; dropping: boolean; enabled: boolean;
|
||||
onSelect: () => void; onOpen: () => void; onRename: () => void; onMenu: (x: number, y: number) => void;
|
||||
drop: { onDragOver: (e: DragEvent) => void; onDragLeave: (e: DragEvent) => void; onDrop: (e: DragEvent) => void };
|
||||
}
|
||||
export function ExplorerFolderRow(props: Props) {
|
||||
return <tr className={`${props.selected ? styles.selected : ''} ${props.dropping ? styles.dropTarget : ''}`} {...props.drop} data-drop-folder={props.folder.id}
|
||||
onClick={props.onSelect} onDoubleClick={props.onOpen} onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); props.onMenu(event.clientX, event.clientY); }}>
|
||||
<td /><td><button className={styles.name} type="button" onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); props.onOpen(); }
|
||||
if (event.key === 'F2' && props.enabled) { event.preventDefault(); props.onRename(); }
|
||||
}}><Icon name="folder" size={18} /><span>{props.folder.name}</span></button></td>
|
||||
<td className={styles.numeric}>文件夹</td><td className={styles.date}>—</td>
|
||||
<td><Button variant="ghost" icon="more" label={`文件夹操作 ${props.folder.name}`} onClick={(event) => { event.stopPropagation(); const rect = event.currentTarget.getBoundingClientRect(); props.onMenu(rect.right, rect.bottom); }} /></td>
|
||||
</tr>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { useBlobUrl } from '@/features/item/useBlobUrl';
|
||||
import { formatSize, formatTime } from '@/lib/format';
|
||||
import { fitFloatingPoint } from '@/lib/explorer';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { iconOf } from '@/features/item/summary';
|
||||
import styles from './FileExplorer.module.css';
|
||||
|
||||
export function ExplorerHover({ id, point }: { id: string; point: { x: number; y: number } }) {
|
||||
const item = useWorkspace((s) => s.byId[id]);
|
||||
const encrypted = useWorkspace((s) => s.channel?.mode === 'enc');
|
||||
if (!item || item.kind !== 'file') return null;
|
||||
const position = fitFloatingPoint(point.x, point.y, 280, 300, innerWidth, innerHeight);
|
||||
const preview = encrypted && !item.gated && item.state.kind === 'complete' && (item.preview === 'image' || item.preview === 'text');
|
||||
return createPortal(<aside className={styles.hover} role="tooltip" aria-label="悬浮文件预览" style={{ left: position.x, top: position.y }}>
|
||||
<div className={styles.hoverTitle}><Icon name={iconOf(item)} size={20} /><strong>{item.name}</strong></div>
|
||||
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · {item.deviceId}</p>
|
||||
{preview ? <HoverContent id={id} image={item.preview === 'image'} /> : <p>{item.gated ? '受限文件:悬停不读取内容,点击预览后再解密。' : item.state.kind === 'tampered' ? '内容校验失败,已拒绝预览。' : '双击文件打开完整预览。'}</p>}
|
||||
</aside>, document.body);
|
||||
}
|
||||
|
||||
function HoverContent({ id, image }: { id: string; image: boolean }) {
|
||||
const blob = useBlobUrl(id);
|
||||
const [text, setText] = useState('');
|
||||
useEffect(() => {
|
||||
if (image || blob.status !== 'ready') return;
|
||||
let active = true;
|
||||
void blob.blob.slice(0, 4096).text().then((value) => { if (active) setText(value); });
|
||||
return () => { active = false; };
|
||||
}, [blob, image]);
|
||||
if (blob.status === 'loading') return <p>正在生成预览…</p>;
|
||||
if (blob.status === 'error') return <p>预览不可用,可双击文件查看。</p>;
|
||||
return image ? <img className={styles.hoverImage} src={blob.url} alt="文件缩略预览" /> : <pre className={styles.hoverText}>{text}</pre>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import type { FolderInfo } from '@/core-bridge/types';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './FileExplorer.module.css';
|
||||
|
||||
export function ExplorerMoveDialog({ folders, count, currentFolderId, onMove, onClose }: {
|
||||
folders: FolderInfo[]; count: number; currentFolderId?: string; onMove: (id?: string) => Promise<void>; onClose: () => void;
|
||||
}) {
|
||||
const [target, setTarget] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
async function move() {
|
||||
setBusy(true); setError('');
|
||||
try { await onMove(target || undefined); onClose(); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '移动失败'); setBusy(false); }
|
||||
}
|
||||
return <Sheet title={`移动 ${count} 个文件`} onClose={onClose} actions={<Button variant="primary" disabled={busy || (target || undefined) === currentFolderId} onClick={() => { void move(); }}>{busy ? '移动中…' : '移动'}</Button>}>
|
||||
<label className={styles.moveLabel}>目标文件夹<select aria-label="目标文件夹" value={target} onChange={(event) => setTarget(event.target.value)}><option value="">根目录</option>
|
||||
{folders.map((folder) => <option key={folder.id} value={folder.id}>{folder.name}</option>)}
|
||||
</select></label>{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</Sheet>;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.heading,
|
||||
.toolbar,
|
||||
.breadcrumb,
|
||||
.batch,
|
||||
.status,
|
||||
.edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.heading {
|
||||
min-height: var(--bar-h);
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.heading span,
|
||||
.status,
|
||||
.empty span {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.batch {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
min-height: 40px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.breadcrumb span,
|
||||
.breadcrumb button,
|
||||
.name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.breadcrumb button,
|
||||
.table button {
|
||||
padding: var(--space-1);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--surface-2);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.table th:first-child {
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.table th:nth-child(3) {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.table th:nth-child(4) {
|
||||
width: 64px;
|
||||
}
|
||||
|
||||
.table th:last-child {
|
||||
width: 38px;
|
||||
}
|
||||
|
||||
.table tbody tr[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.dropTarget {
|
||||
outline: 2px solid var(--busy);
|
||||
outline-offset: -2px;
|
||||
background: var(--busy-soft);
|
||||
}
|
||||
|
||||
.hover {
|
||||
position: fixed;
|
||||
z-index: 70;
|
||||
width: 280px;
|
||||
max-width: calc(100vw - 16px);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow-pop);
|
||||
pointer-events: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hoverTitle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-2);
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.hover p {
|
||||
margin: var(--space-2) 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hoverImage {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 150px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.hoverText {
|
||||
max-height: 150px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
white-space: pre-wrap;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.moveLabel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.moveLabel select {
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.name {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.name svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.name span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table tbody tr:hover,
|
||||
.selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.numeric {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: var(--space-6);
|
||||
color: var(--text-dim);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
border-top: 1px solid var(--line);
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.status span {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.edit input,
|
||||
.batch select {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.edit input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.progress {
|
||||
display: block;
|
||||
padding: 0 var(--space-1);
|
||||
color: var(--busy);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (width <= 1100px) {
|
||||
.date {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useRef, useState, type FormEvent } from 'react';
|
||||
import type { FolderInfo } from '@/core-bridge/types';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { ExplorerFileRow } from './ExplorerFileRow';
|
||||
import { ExplorerFolderRow } from './ExplorerFolderRow';
|
||||
import { ExplorerHover } from './ExplorerHover';
|
||||
import { ExplorerMoveDialog } from './ExplorerMoveDialog';
|
||||
import { useExplorerInteractions } from './useExplorerInteractions';
|
||||
import { ContextMenu } from '@/ui/ContextMenu';
|
||||
import styles from './FileExplorer.module.css';
|
||||
|
||||
interface Props {
|
||||
scope: string;
|
||||
threadId?: string;
|
||||
threadName: string;
|
||||
folders: FolderInfo[];
|
||||
folderId?: string;
|
||||
ids: string[];
|
||||
selectedId?: string;
|
||||
checked: ReadonlySet<string>;
|
||||
enabled: boolean;
|
||||
onFolder: (id?: string) => void;
|
||||
onSelect: (id?: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
onToggle: (id: string) => void;
|
||||
onSelection: (ids: ReadonlySet<string>) => void;
|
||||
onFiles: (files: File[], folderId?: string) => void;
|
||||
onCreateFolder: (name: string) => Promise<void>;
|
||||
onRenameFolder: (id: string, name: string) => Promise<void>;
|
||||
onDownloadItems: (ids: string[]) => Promise<void>;
|
||||
onDeleteItems: (ids: string[]) => Promise<void>;
|
||||
onMoveItems: (ids: string[], folderId?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function FileExplorer(props: Props) {
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const [sort, setSort] = useState<'name' | 'size' | 'date'>('name');
|
||||
const [edit, setEdit] = useState<string>();
|
||||
const [name, setName] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [selectedFolder, setSelectedFolder] = useState<string>();
|
||||
const byId = useWorkspace.getState().byId;
|
||||
const ids = props.ids.slice().sort((a, b) => {
|
||||
const first = byId[a]; const second = byId[b];
|
||||
if (first?.kind !== 'file' || second?.kind !== 'file') return 0;
|
||||
return sort === 'size' ? second.size - first.size : sort === 'date' ? second.ts - first.ts : first.name.localeCompare(second.name, 'zh-CN');
|
||||
});
|
||||
const path: FolderInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = props.folders.find((folder) => folder.id === props.folderId);
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id); path.unshift(current); current = props.folders.find((folder) => folder.id === current?.parentId);
|
||||
}
|
||||
const children = props.folders.filter((folder) => folder.parentId === props.folderId).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
||||
function beginFolder(id?: string) { const folder = props.folders.find((row) => row.id === id); setEdit(id ?? 'create'); setName(folder?.name ?? ''); setError(''); }
|
||||
const interaction = useExplorerInteractions({ ...props, ids, onDownload: props.onDownloadItems, onDelete: props.onDeleteItems, onMove: props.onMoveItems,
|
||||
onRenameFolder: (id) => beginFolder(id), onCreateFolder: () => beginFolder(), onUpload: () => input.current?.click(), onFolder: navigateFolder });
|
||||
function navigateFolder(id?: string) { interaction.dismiss(); setSelectedFolder(undefined); props.onFolder(id); }
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault(); if (!edit || busy || !name.trim()) return;
|
||||
setBusy(true); setError('');
|
||||
try { if (edit === 'create') await props.onCreateFolder(name); else await props.onRenameFolder(edit, name); setEdit(undefined); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '目录操作失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <section className={styles.explorer} aria-label="频道文件管理器" tabIndex={0} onKeyDown={interaction.keyDown}>
|
||||
<header className={styles.heading}><strong>频道文件</strong><span>{props.threadName}</span></header>
|
||||
<div className={styles.toolbar}>
|
||||
<input type="file" multiple hidden ref={input} onChange={(event) => { if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} />
|
||||
<Button icon="share" disabled={!props.enabled} onClick={() => input.current?.click()}>上传文件</Button>
|
||||
<Button variant="ghost" icon="folder" disabled={!props.enabled || busy} onClick={() => beginFolder()}>新建文件夹</Button>
|
||||
<Button variant="ghost" icon="eye" disabled={!props.selectedId} onClick={() => { if (props.selectedId) props.onOpen(props.selectedId); }}>预览</Button>
|
||||
</div>
|
||||
<nav className={styles.breadcrumb} aria-label="文件夹路径"><Button variant="ghost" icon="chevronLeft" label="返回上级文件夹" disabled={!props.folderId} onClick={() => navigateFolder(path[path.length - 1]?.parentId)} />
|
||||
<button type="button" data-drop-folder="root" className={interaction.dropFolder === '__root' ? styles.dropTarget : ''} {...interaction.dropHandlers()} onClick={() => navigateFolder()}><Icon name="folder" size={16} />根目录</button>
|
||||
{path.map((folder) => <span key={folder.id}><Icon name="chevronRight" size={12} /><button type="button" className={interaction.dropFolder === folder.id ? styles.dropTarget : ''} {...interaction.dropHandlers(folder.id)} onClick={() => navigateFolder(folder.id)}>{folder.name}</button></span>)}
|
||||
</nav>
|
||||
{edit ? <form className={styles.edit} onSubmit={(event) => { void submit(event); }}><input autoFocus maxLength={80} aria-label="文件夹名称" value={name} onChange={(event) => setName(event.target.value)} />
|
||||
<Button type="submit" disabled={busy || !name.trim()}>{edit === 'create' ? '创建' : '保存'}</Button><Button variant="ghost" onClick={() => setEdit(undefined)}>取消</Button></form> : null}
|
||||
<div className={`${styles.scroll} ${interaction.dropFolder === (props.folderId ?? '__root') ? styles.dropTarget : ''}`} {...interaction.dropHandlers(props.folderId)} onContextMenu={interaction.blankMenu}
|
||||
onClick={(event) => { if (!(event.target as HTMLElement).closest('tr,button,input')) { props.onSelection(new Set()); props.onSelect(undefined); setSelectedFolder(undefined); event.currentTarget.focus(); } }} tabIndex={-1}>
|
||||
<table className={styles.table}><thead><tr><th aria-label="选择" /><th><button type="button" onClick={() => setSort('name')}>名称{sort === 'name' ? ' ↑' : ''}</button></th>
|
||||
<th><button type="button" onClick={() => setSort('size')}>大小{sort === 'size' ? ' ↓' : ''}</button></th><th className={styles.date}><button type="button" onClick={() => setSort('date')}>时间{sort === 'date' ? ' ↓' : ''}</button></th><th aria-label="操作" /></tr></thead>
|
||||
<tbody>{children.map((folder) => <ExplorerFolderRow key={folder.id} folder={folder} selected={folder.id === selectedFolder} enabled={props.enabled}
|
||||
dropping={interaction.dropFolder === folder.id} drop={interaction.dropHandlers(folder.id)} onOpen={() => navigateFolder(folder.id)}
|
||||
onSelect={() => { interaction.dismiss(); setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); }} onRename={() => beginFolder(folder.id)}
|
||||
onMenu={(x, y) => { setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); interaction.showFolderMenu(folder.id, folder.name, x, y); }} />)}
|
||||
{ids.map((id) => <ExplorerFileRow key={id} id={id} selected={id === props.selectedId} checked={props.checked.has(id)} enabled={props.enabled}
|
||||
onSelect={(id, event) => { setSelectedFolder(undefined); interaction.select(id, event); }} onOpen={props.onOpen} onToggle={props.onToggle} onMenu={(id, x, y) => { setSelectedFolder(undefined); interaction.showFileMenu(id, x, y); }}
|
||||
onHover={interaction.hoverFile} onLeave={interaction.leave} onDrag={interaction.drag} onDragEnd={interaction.dismiss} />)}</tbody>
|
||||
</table>{ids.length === 0 && children.length === 0 ? <div className={styles.empty}><Icon name="folder" size={28} /><p>此文件夹还没有文件</p><span>上传文件或将文件拖到窗口中。</span></div> : null}</div>
|
||||
{props.checked.size > 0 ? <div className={styles.batch}><span>已选 {props.checked.size} 项</span><Button variant="ghost" icon="download" onClick={() => { void props.onDownloadItems([...props.checked]); }}>下载所选</Button>
|
||||
{props.enabled ? <select aria-label="移动所选文件到文件夹" value="__choose" onChange={(event) => {
|
||||
const target = event.target.value; if (target !== '__choose') void props.onMoveItems([...props.checked], target === '__root' ? undefined : target).catch((cause: unknown) => setError(cause instanceof Error ? cause.message : '移动失败'));
|
||||
}}><option value="__choose">移动到…</option><option value="__root">根目录</option>{props.folders.map((folder) => <option value={folder.id} key={folder.id}>{folder.name}</option>)}</select> : null}
|
||||
</div> : null}
|
||||
{error || interaction.error ? <p className={styles.error} role="alert">{error || interaction.error}</p> : null}
|
||||
<footer className={styles.status}>{children.length} 个文件夹 · {ids.length} 个文件<span>双击打开 · 右键操作 · 拖动移动</span></footer>
|
||||
{interaction.menu ? <ContextMenu point={interaction.menu} actions={interaction.actions} onClose={interaction.dismiss} /> : null}
|
||||
{interaction.hover && ids.includes(interaction.hover.id) && !interaction.menu ? <ExplorerHover key={interaction.hover.id} {...interaction.hover} /> : null}
|
||||
{interaction.moveIds ? <ExplorerMoveDialog folders={props.folders} count={interaction.moveIds.length} currentFolderId={props.folderId} onClose={() => interaction.setMoveIds(undefined)}
|
||||
onMove={(folderId) => props.onMoveItems(interaction.moveIds!, folderId)} /> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useRef } from 'react';
|
||||
import styles from './Workspace.module.css';
|
||||
|
||||
export function FilePaneResize({ width, onWidth }: { width?: number; onWidth: (width: number) => void }) {
|
||||
const drag = useRef<{ right: number; maximum: number } | undefined>(undefined);
|
||||
function bounds(element: HTMLElement) {
|
||||
const parent = element.parentElement!;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
const navigation = parent.querySelector<HTMLElement>('[data-space-nav]');
|
||||
return { right: rect.right, maximum: Math.max(320, rect.width - (navigation?.offsetWidth ?? 232) - 266) };
|
||||
}
|
||||
return <div className={styles.resize} role="separator" aria-label="调整文件区宽度" aria-orientation="vertical" tabIndex={0}
|
||||
aria-valuemin={320} aria-valuemax={Math.max(320, innerWidth - (innerWidth <= 1100 ? 200 : 232) - 266)} aria-valuenow={width ?? Math.max(320, Math.round(innerWidth * 0.36))}
|
||||
onPointerDown={(event) => { event.preventDefault(); drag.current = bounds(event.currentTarget); event.currentTarget.setPointerCapture(event.pointerId); }}
|
||||
onPointerMove={(event) => { if (drag.current) onWidth(Math.min(drag.current.maximum, Math.max(320, drag.current.right - event.clientX))); }}
|
||||
onPointerUp={(event) => { drag.current = undefined; if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }}
|
||||
onPointerCancel={() => { drag.current = undefined; }}
|
||||
onKeyDown={(event) => {
|
||||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault(); const limit = bounds(event.currentTarget);
|
||||
const current = event.currentTarget.parentElement?.querySelector<HTMLElement>('[data-file-pane]')?.offsetWidth ?? 320;
|
||||
onWidth(event.key === 'Home' ? 320 : event.key === 'End' ? limit.maximum : Math.min(limit.maximum, Math.max(320, current + (event.key === 'ArrowLeft' ? 24 : -24))));
|
||||
}} />;
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import styles from './Notepad.module.css';
|
||||
|
||||
const SAVE_DELAY_MS = 400;
|
||||
|
||||
/** 共享便签:频道唯一的快照槽,最后写入者胜(protocol §9)。默认折叠,不占首屏 */
|
||||
/** 共享便签:空间唯一的快照槽,最后写入者胜(protocol §9)。默认折叠,不占首屏 */
|
||||
export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
|
||||
const snapshot = useWorkspace((s) => s.snapshot);
|
||||
const setSnapshot = useWorkspace((s) => s.setSnapshot);
|
||||
|
||||
@@ -25,11 +25,9 @@
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--ok);
|
||||
.countLabel {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.open {
|
||||
|
||||
@@ -11,8 +11,7 @@ interface Props {
|
||||
|
||||
/** 电脑右侧的设备与分享:低频,默认收成窄栏,需要时展开 */
|
||||
export function SideRail({ open, qrOpen, onToggle }: Props) {
|
||||
const online = useWorkspace((s) => s.devices.filter((d) => d.online).length);
|
||||
const seats = useWorkspace((s) => s.channel?.seats);
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
|
||||
if (open) {
|
||||
return (
|
||||
@@ -29,9 +28,9 @@ export function SideRail({ open, qrOpen, onToggle }: Props) {
|
||||
return (
|
||||
<aside className={styles.rail}>
|
||||
<Button variant="ghost" icon="chevronLeft" label="展开设备与分享" onClick={onToggle} />
|
||||
<span className={styles.count} title="在线设备">
|
||||
<span className={styles.dot} />
|
||||
{online}/{seats}
|
||||
<span className={styles.count} title="已加入席位 / 总席位">
|
||||
{channel?.seatsUsed ?? '—'}/{channel?.seats ?? '—'}
|
||||
<span className={styles.countLabel}>席位</span>
|
||||
</span>
|
||||
<Button variant="ghost" icon="share" label="分享" onClick={onToggle} />
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.channels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
max-height: 28vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.group {
|
||||
min-width: 0;
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.groupHeading,
|
||||
.groupActions,
|
||||
.groupToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.groupToggle {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: var(--space-1) 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.groupToggle span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.groupToggle svg,
|
||||
.groupActions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.groupToggle small {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.groupLabel {
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.channelEditor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.channelEditor label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.channelEditor input,
|
||||
.channelEditor select {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: var(--bar-h);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: var(--bar-h);
|
||||
height: var(--bar-h);
|
||||
border-radius: var(--radius-md);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.heading,
|
||||
.folderHeading,
|
||||
.threadRow,
|
||||
.folderRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.heading,
|
||||
.folderHeading {
|
||||
justify-content: space-between;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.folderHeading {
|
||||
padding-top: var(--space-2);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.threads,
|
||||
.folders {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.threadRow,
|
||||
.folderRow {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.folderRow {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.thread,
|
||||
.folder {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thread span,
|
||||
.folder span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thread small {
|
||||
margin-inline-start: auto;
|
||||
color: var(--text-dim);
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder {
|
||||
padding-inline-start: var(--space-2);
|
||||
}
|
||||
|
||||
.thread:hover,
|
||||
.folder:hover,
|
||||
.selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.empty,
|
||||
.hint,
|
||||
.error {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.edit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.edit input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 0 var(--space-2);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.folders ul {
|
||||
flex: 1 0 100%;
|
||||
margin: 0;
|
||||
padding-inline-start: var(--space-4);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
@media (width <= 899px) {
|
||||
.panel {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
}
|
||||
|
||||
.threads {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.thread {
|
||||
flex: 0 0 auto;
|
||||
max-width: 220px;
|
||||
}
|
||||
|
||||
.folders {
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import type { ChannelGroupInfo, ThreadInfo } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import styles from './ThreadSidebar.module.css';
|
||||
|
||||
interface Props {
|
||||
threads: ThreadInfo[];
|
||||
groups: ChannelGroupInfo[];
|
||||
selectedThreadId?: string;
|
||||
enabled: boolean;
|
||||
onSelectThread: (id: string) => void;
|
||||
onCreateThread: (name: string, groupId?: string) => Promise<string>;
|
||||
onRenameThread: (id: string, name: string) => Promise<void>;
|
||||
onCreateGroup: (name: string) => Promise<string>;
|
||||
onRenameGroup: (id: string, name: string) => Promise<void>;
|
||||
onGroupThread: (id: string, groupId?: string) => Promise<void>;
|
||||
}
|
||||
type Editor = { kind: 'channel' | 'group'; id?: string; groupId?: string };
|
||||
|
||||
export function ThreadSidebar(props: Props) {
|
||||
const [edit, setEdit] = useState<Editor>();
|
||||
const [name, setName] = useState('');
|
||||
const [groupId, setGroupId] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
||||
const knownGroups = new Set(props.groups.map((group) => group.id));
|
||||
const ungrouped = props.threads.filter((thread) => !thread.groupId || !knownGroups.has(thread.groupId));
|
||||
function begin(target: Editor, initial = '') { setEdit(target); setName(initial); setGroupId(target.groupId ?? ''); setError(''); }
|
||||
function toggle(id: string) { setCollapsed((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
||||
function select(id: string) { props.onSelectThread(id); setEdit(undefined); }
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault(); if (!edit || !name.trim() || busy || !props.enabled) return;
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
if (edit.kind === 'group') {
|
||||
if (edit.id) await props.onRenameGroup(edit.id, name); else await props.onCreateGroup(name);
|
||||
} else if (edit.id) {
|
||||
const original = props.threads.find((thread) => thread.id === edit.id);
|
||||
if (original?.name !== name.trim()) await props.onRenameThread(edit.id, name);
|
||||
if ((original?.groupId ?? '') !== groupId) await props.onGroupThread(edit.id, groupId || undefined);
|
||||
} else props.onSelectThread(await props.onCreateThread(name, groupId || undefined));
|
||||
if (groupId) setCollapsed((previous) => { const next = new Set(previous); next.delete(groupId); return next; });
|
||||
setEdit(undefined);
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : '操作失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <section className={styles.panel} aria-label="空间频道导航">
|
||||
<div className={styles.heading}><strong>频道</strong><span className={styles.groupActions}>
|
||||
<Button variant="ghost" icon="folder" label="新建频道分组" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'group' })} />
|
||||
<Button variant="ghost" icon="edit" label="新建频道" disabled={!props.enabled || busy} onClick={() => begin({ kind: 'channel' })} />
|
||||
</span></div>
|
||||
{ungrouped.length ? <div className={styles.groupLabel}>未分组</div> : null}
|
||||
<ChannelRows threads={ungrouped} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} onSelect={select}
|
||||
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} />
|
||||
{props.groups.map((group) => {
|
||||
const threads = props.threads.filter((thread) => thread.groupId === group.id);
|
||||
return <section className={styles.group} aria-label={`频道分组 ${group.name}`} key={group.id}>
|
||||
<div className={styles.groupHeading}><button className={styles.groupToggle} type="button" aria-label={`分组 ${group.name}`} aria-expanded={!collapsed.has(group.id)} onClick={() => toggle(group.id)}>
|
||||
<Icon name={collapsed.has(group.id) ? 'chevronRight' : 'chevronDown'} size={14} /><span>{group.name}</span><small>{threads.length}</small>
|
||||
</button>{props.enabled ? <span className={styles.groupActions}>
|
||||
<Button variant="ghost" icon="edit" label={`重命名分组 ${group.name}`} disabled={busy} onClick={() => begin({ kind: 'group', id: group.id }, group.name)} />
|
||||
<Button variant="ghost" icon="file" label={`在 ${group.name} 新建频道`} disabled={busy} onClick={() => begin({ kind: 'channel', groupId: group.id })} />
|
||||
</span> : null}</div>
|
||||
{!collapsed.has(group.id) ? <ChannelRows threads={threads} selectedId={props.selectedThreadId} enabled={props.enabled} busy={busy} onSelect={select}
|
||||
onEdit={(thread) => begin({ kind: 'channel', id: thread.id, groupId: thread.groupId }, thread.name)} /> : null}
|
||||
{!collapsed.has(group.id) && !threads.length ? <p className={styles.empty}>此分组还没有频道。</p> : null}
|
||||
</section>;
|
||||
})}
|
||||
{edit ? <form className={styles.channelEditor} onSubmit={(event) => { void submit(event); }}>
|
||||
<label>{edit.kind === 'group' ? '分组名称' : '频道名称'}<input autoFocus disabled={busy} aria-label={edit.kind === 'group' ? '分组名称' : '频道名称'} maxLength={80} value={name} onChange={(event) => setName(event.target.value)} /></label>
|
||||
{edit.kind === 'channel' ? <label>所属分组<select aria-label="所属分组" disabled={busy} value={groupId} onChange={(event) => setGroupId(event.target.value)}>
|
||||
<option value="">未分组</option>{props.groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||
</select></label> : null}
|
||||
<div className={styles.groupActions}><Button type="submit" variant="primary" disabled={busy || !name.trim()}>{edit.id ? '保存' : '创建'}</Button>
|
||||
<Button variant="ghost" onClick={() => setEdit(undefined)}>取消</Button></div>
|
||||
</form> : null}
|
||||
{!props.threads.length && !props.groups.length ? <p className={styles.empty}>创建一个频道后开始聊天和上传文件。</p> : null}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>;
|
||||
}
|
||||
|
||||
function ChannelRows({ threads, selectedId, enabled, busy, onSelect, onEdit }: {
|
||||
threads: ThreadInfo[]; selectedId?: string; enabled: boolean; busy: boolean; onSelect: (id: string) => void; onEdit: (thread: ThreadInfo) => void;
|
||||
}) {
|
||||
return <ul className={styles.threads}>{threads.map((thread) => <li className={styles.threadRow} key={thread.id}>
|
||||
<button className={`${styles.thread} ${thread.id === selectedId ? styles.selected : ''}`} type="button" disabled={busy} aria-current={thread.id === selectedId ? 'page' : undefined}
|
||||
onClick={() => onSelect(thread.id)}><span aria-hidden="true">#</span><span>{thread.name}</span></button>
|
||||
{enabled && thread.id === selectedId ? <Button variant="ghost" icon="more" disabled={busy} label={`频道设置 ${thread.name}`} onClick={() => onEdit(thread)} /> : null}
|
||||
</li>)}</ul>;
|
||||
}
|
||||
@@ -42,12 +42,18 @@
|
||||
.name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.idSuffix {
|
||||
color: var(--text-dim);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge,
|
||||
.badgeWarn {
|
||||
display: inline-flex;
|
||||
@@ -86,24 +92,9 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.dots {
|
||||
display: inline-flex;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.on,
|
||||
.off {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.on {
|
||||
background: var(--ok);
|
||||
}
|
||||
|
||||
.off {
|
||||
background: var(--line-strong);
|
||||
.countLabel {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.active {
|
||||
@@ -127,7 +118,7 @@
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* 窄屏(如 iPhone SE、小屏 Android):只留在线点,收起数字与徽标文字 */
|
||||
/* 窄屏(如 iPhone SE、小屏 Android):收起席位数字与徽标文字 */
|
||||
@media (width < 360px) {
|
||||
.count {
|
||||
display: none;
|
||||
|
||||
@@ -10,24 +10,26 @@ interface Props {
|
||||
onBack: () => void;
|
||||
/** 手机上点开设备与分享面板;桌面端面板在右侧栏,不传 */
|
||||
onOpenPanel?: () => void;
|
||||
onOpenSettings: () => void;
|
||||
searchOpen: boolean;
|
||||
/** 有筛选条件在生效 */
|
||||
filtering: boolean;
|
||||
onToggleSearch: () => void;
|
||||
}
|
||||
|
||||
export function TopBar({ onBack, onOpenPanel, searchOpen, filtering, onToggleSearch }: Props) {
|
||||
export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filtering, onToggleSearch }: Props) {
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const connection = useWorkspace((s) => s.connection);
|
||||
const devices = useWorkspace((s) => s.devices);
|
||||
const online = devices.filter((d) => d.online).length;
|
||||
const seatCount = `${channel?.seatsUsed ?? '—'}/${channel?.seats ?? '—'}`;
|
||||
const raw = channel?.mode === 'raw';
|
||||
|
||||
return (
|
||||
<header className={`${styles.bar} ${raw ? styles.raw : ''}`}>
|
||||
<Button variant="ghost" icon="chevronLeft" label="返回频道列表" onClick={onBack} />
|
||||
<Button variant="ghost" icon="chevronLeft" label="返回空间列表" onClick={onBack} />
|
||||
<div className={styles.id}>
|
||||
<span className={styles.name}>{channel?.id.slice(0, 8)}…</span>
|
||||
<span className={styles.name}>{channel?.name || `${channel?.id.slice(0, 8)}…`}</span>
|
||||
{channel?.name ? <span className={styles.idSuffix}>{channel.id.slice(0, 8)}…</span> : null}
|
||||
{channel?.creatorAccount ? <span className={styles.idSuffix}>创建者 {channel.creatorAccount}</span> : null}
|
||||
{raw ? (
|
||||
<span className={styles.badgeWarn}>明文 · 服务端可见</span>
|
||||
) : (
|
||||
@@ -37,13 +39,8 @@ export function TopBar({ onBack, onOpenPanel, searchOpen, filtering, onToggleSea
|
||||
)}
|
||||
</div>
|
||||
<button type="button" className={styles.presence} onClick={onOpenPanel} disabled={!onOpenPanel}>
|
||||
<span className={styles.dots} aria-hidden="true">
|
||||
{devices.map((d) => (
|
||||
<span key={d.id} className={d.online ? styles.on : styles.off} />
|
||||
))}
|
||||
</span>
|
||||
<span className={styles.count}>
|
||||
{online}/{channel?.seats ?? devices.length}
|
||||
<span className={styles.count} title="已加入席位 / 总席位" aria-label={`已加入 ${channel?.seatsUsed ?? '未知'} 个设备,共 ${channel?.seats ?? '未知'} 个席位`}>
|
||||
{seatCount} <span className={styles.countLabel}>席位</span>
|
||||
</span>
|
||||
</button>
|
||||
<span className={`${styles.conn} ${styles[connection]}`} role="status">
|
||||
@@ -58,6 +55,7 @@ export function TopBar({ onBack, onOpenPanel, searchOpen, filtering, onToggleSea
|
||||
onClick={onToggleSearch}
|
||||
/>
|
||||
<ThemeMenu />
|
||||
<Button variant="ghost" icon="settings" label="空间设置" onClick={onOpenSettings} />
|
||||
{onOpenPanel ? <Button variant="ghost" icon="share" label="设备与分享" onClick={onOpenPanel} /> : null}
|
||||
</header>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.check input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field select {
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.note {
|
||||
margin: 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useState } from 'react';
|
||||
import type { FileUploadOptions } from '@/core-bridge/types';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import styles from './UploadOptionsSheet.module.css';
|
||||
|
||||
const LIMITS = [0, 1, 3, 5, 10, 20];
|
||||
|
||||
interface Props {
|
||||
encrypted?: boolean;
|
||||
files: File[];
|
||||
defaultMaxDownloads: number;
|
||||
onClose: () => void;
|
||||
onUpload: (options: FileUploadOptions) => void;
|
||||
}
|
||||
|
||||
export function UploadOptionsSheet({ files, defaultMaxDownloads, onClose, onUpload, encrypted = true }: Props) {
|
||||
const [directPreview, setDirectPreview] = useState(true);
|
||||
const [maxDownloads, setMaxDownloads] = useState(defaultMaxDownloads);
|
||||
|
||||
return (
|
||||
<Sheet
|
||||
title={`上传 ${files.length} 个文件`}
|
||||
onClose={onClose}
|
||||
actions={<Button variant="primary" onClick={() => onUpload({ directPreview, maxDownloads })}>继续上传</Button>}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<label className={styles.check}>
|
||||
<input type="checkbox" checked={directPreview} onChange={(e) => setDirectPreview(e.target.checked)} />
|
||||
<span>单帧文件加入空间后可直接预览</span>
|
||||
</label>
|
||||
{directPreview ? (
|
||||
<p className={styles.note}>图片、文本等单帧文件会随空间同步,加入者可直接预览且不计下载次数。大文件必须分帧,仍会受空间下载上限限制。</p>
|
||||
) : (
|
||||
<>
|
||||
<label className={styles.field}>
|
||||
<span>每个文件最多下载次数</span>
|
||||
<select value={maxDownloads} onChange={(e) => setMaxDownloads(Number(e.target.value))}>
|
||||
{Array.from(new Set([...LIMITS, defaultMaxDownloads])).sort((a, b) => a - b).map((n) => (
|
||||
<option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className={styles.note}>预览和下载内容会消耗次数。{encrypted ? '文件名与类型通过加密目录同步,不消耗次数。' : '此空间未启用端到端加密,服务端能看到文件名与类型。'}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,94 @@
|
||||
/* 电脑:左列表 | 内联预览 | 右侧窄栏 */
|
||||
.desktop {
|
||||
--nav-width: 232px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 380px minmax(0, 1fr) auto;
|
||||
grid-template-columns: var(--nav-width) minmax(260px, 1fr) 6px clamp(320px, var(--files-width, 36%), calc(100% - var(--nav-width) - 266px));
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
.desktop > header {
|
||||
grid-column: 1 / 3;
|
||||
grid-column: 1 / -1;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.list {
|
||||
.navigation,
|
||||
.center,
|
||||
.files,
|
||||
.mobileContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.scroll {
|
||||
flex: 1;
|
||||
.navigation {
|
||||
overflow-y: auto;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.batch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: linear-gradient(90deg, var(--busy-soft), transparent);
|
||||
font-size: 13px;
|
||||
.files {
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.resize {
|
||||
background: var(--line);
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.resize:hover,
|
||||
.resize:focus-visible {
|
||||
background: var(--busy);
|
||||
}
|
||||
|
||||
/* 手机:整列滚动,顶栏与底部动作栏 sticky,内容从它们下方滑过 */
|
||||
.mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
padding-inline: env(safe-area-inset-left) env(safe-area-inset-right);
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
.mobile .navigation {
|
||||
max-height: 30dvh;
|
||||
flex-shrink: 0;
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.grow {
|
||||
.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);
|
||||
}
|
||||
|
||||
.legacy {
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.drop {
|
||||
@@ -80,3 +116,9 @@
|
||||
color: var(--danger);
|
||||
border: 1px solid var(--danger);
|
||||
}
|
||||
|
||||
@media (width >= 900px) and (width <= 1100px) {
|
||||
.desktop {
|
||||
--nav-width: 200px;
|
||||
}
|
||||
}
|
||||
@@ -1,208 +1,132 @@
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type DragEvent } from 'react';
|
||||
import { lazy, Suspense, useCallback, useState, type CSSProperties, type DragEvent } from 'react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { channelPath, navigate, sharePath } from '@/app/navigation';
|
||||
import { useDownload } from '@/features/item/download';
|
||||
import { isFiltering, matchesFilter, NO_FILTER } from '@/lib/filter';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { matchesFilter, isFiltering, NO_FILTER } from '@/lib/filter';
|
||||
import { useGlobalPaste } from '@/lib/useGlobalPaste';
|
||||
import { useMediaQuery } from '@/lib/useMediaQuery';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { Button } from '@/ui/Button';
|
||||
import type { FileUploadOptions } from '@/core-bridge/types';
|
||||
import type { ClipboardPayload } from '@/lib/clipboard';
|
||||
import { Sheet } from '@/ui/Sheet';
|
||||
import { ActionBar } from './ActionBar';
|
||||
import { ComposeSheet } from './ComposeSheet';
|
||||
import { DevicePanel } from './DevicePanel';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { ItemList } from './ItemList';
|
||||
import { LatestCard } from './LatestCard';
|
||||
import { Notepad } from './Notepad';
|
||||
import { SideRail } from './SideRail';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ChannelSwitcher } from './ChannelSwitcher';
|
||||
import { ThreadSidebar } from './ThreadSidebar';
|
||||
import { ChatPane } from './ChatPane';
|
||||
import { FileExplorer } from './FileExplorer';
|
||||
import { FilePaneResize } from './FilePaneResize';
|
||||
import { TopBar } from './TopBar';
|
||||
import { UndoBar } from './UndoBar';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { Notepad } from './Notepad';
|
||||
import { DevicePanel } from './DevicePanel';
|
||||
import { ChannelSettingsSheet } from './ChannelSettingsSheet';
|
||||
import { UploadOptionsSheet } from './UploadOptionsSheet';
|
||||
import styles from './Workspace.module.css';
|
||||
|
||||
const PreviewSheet = lazy(() => import('@/features/item/PreviewSheet'));
|
||||
const PreviewPane = lazy(() => import('@/features/item/PreviewPane'));
|
||||
const ScanSheet = lazy(() => import('@/features/scan/ScanSheet').then((m) => ({ default: m.ScanSheet })));
|
||||
|
||||
const UNDO_MS = 5000;
|
||||
|
||||
export function Workspace() {
|
||||
export function Workspace({ onCreateChannel }: { onCreateChannel: () => void }) {
|
||||
const desktop = useMediaQuery('(width >= 900px) and (height >= 500px)');
|
||||
const sendFiles = useWorkspace((s) => s.sendFiles);
|
||||
const channel = useWorkspace((s) => s.channel);
|
||||
const threads = useWorkspace((s) => s.threads);
|
||||
const groups = useWorkspace((s) => s.groups);
|
||||
const createGroup = useWorkspace((s) => s.createGroup);
|
||||
const renameGroup = useWorkspace((s) => s.renameGroup);
|
||||
const groupThread = useWorkspace((s) => s.groupThread);
|
||||
const folders = useWorkspace((s) => s.folders);
|
||||
const sendText = useWorkspace((s) => s.sendText);
|
||||
const sendFiles = useWorkspace((s) => s.sendFiles);
|
||||
const createThread = useWorkspace((s) => s.createThread);
|
||||
const renameThread = useWorkspace((s) => s.renameThread);
|
||||
const createFolder = useWorkspace((s) => s.createFolder);
|
||||
const renameFolder = useWorkspace((s) => s.renameFolder);
|
||||
const moveItems = useWorkspace((s) => s.moveItems);
|
||||
const remove = useWorkspace((s) => s.remove);
|
||||
const updateSettings = useWorkspace((s) => s.updateChannelSettings);
|
||||
const error = useWorkspace((s) => s.error);
|
||||
const download = useDownload();
|
||||
|
||||
// 刚创建的频道:直接展示分享面板和二维码,让对方马上能扫
|
||||
const [justCreated] = useState(() => useChannels.getState().justCreatedId !== null);
|
||||
const [panelOpen, setPanelOpen] = useState(justCreated); // 手机:设备与分享抽屉
|
||||
const [railOpen, setRailOpen] = useState(justCreated); // 电脑:右侧栏展开
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [filter, setFilter] = useState(NO_FILTER);
|
||||
const [threadId, setThreadId] = useState<string>();
|
||||
const [folderId, setFolderId] = useState<string>();
|
||||
const [selectedId, setSelectedId] = useState<string>();
|
||||
const [sheetId, setSheetId] = useState<string>();
|
||||
const [previewId, setPreviewId] = useState<string>();
|
||||
const [checked, setChecked] = useState<ReadonlySet<string>>(new Set());
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [filter, setFilter] = useState(NO_FILTER);
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [pending, setPending] = useState<string>();
|
||||
const pendingRef = useRef<{ id: string; timer: number }>(undefined);
|
||||
const [mobileView, setMobileView] = useState<'chat' | 'files'>('chat');
|
||||
const [fileWidth, setFileWidth] = useState<number>();
|
||||
const [upload, setUpload] = useState<{ files: File[]; threadId: string; folderId?: string }>();
|
||||
const activeThread = threads.find((thread) => thread.id === threadId) ?? threads.find((thread) => thread.name === '常规') ?? threads[threads.length - 1];
|
||||
const activeThreadId = activeThread?.id;
|
||||
const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId);
|
||||
const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined;
|
||||
const enabled = Boolean(activeThreadId && channel?.canUpload !== false);
|
||||
const files = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
||||
const item = s.byId[id]; return item?.kind === 'file' && !item.metadataHidden && item.threadId === activeThreadId
|
||||
&& (item.folderId ?? undefined) === activeFolderId && matchesFilter(item, filter);
|
||||
})));
|
||||
const messages = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
||||
const item = s.byId[id]; return Boolean(item && item.threadId === activeThreadId && !(item.kind === 'file' && item.metadataHidden));
|
||||
})));
|
||||
const restricted = useWorkspace(useShallow((s) => s.order.filter((id) => {
|
||||
const item = s.byId[id]; return item?.kind === 'file' && item.metadataHidden;
|
||||
})));
|
||||
|
||||
const sendPayload = useCallback(
|
||||
(p: ClipboardPayload) => {
|
||||
if (p.files.length > 0) void sendFiles(p.files);
|
||||
else if (p.text) void sendText(p.text);
|
||||
},
|
||||
[sendFiles, sendText],
|
||||
);
|
||||
useGlobalPaste(sendPayload);
|
||||
|
||||
// 从首页「快速分享」带过来的内容,进入频道后发送
|
||||
useEffect(() => {
|
||||
useChannels.getState().clearJustCreated();
|
||||
}, [sendPayload]);
|
||||
|
||||
const ids = useWorkspace(
|
||||
useShallow((s) =>
|
||||
s.order.filter((id) => {
|
||||
const item = s.byId[id];
|
||||
return id !== pending && item !== undefined && matchesFilter(item, filter);
|
||||
}),
|
||||
),
|
||||
);
|
||||
const filtering = isFiltering(filter);
|
||||
const latestId = !desktop && !filtering ? ids[0] : undefined;
|
||||
const listIds = latestId ? ids.slice(1) : ids;
|
||||
const activeId = selectedId && ids.includes(selectedId) ? selectedId : ids[0];
|
||||
|
||||
const closeSheet = useCallback(() => setSheetId(undefined), []);
|
||||
const closePanel = useCallback(() => setPanelOpen(false), []);
|
||||
const closeCompose = useCallback(() => setComposeOpen(false), []);
|
||||
const closeScan = useCallback(() => setScanOpen(false), []);
|
||||
|
||||
function requestDelete(id: string) {
|
||||
if (pendingRef.current) {
|
||||
clearTimeout(pendingRef.current.timer);
|
||||
void remove(pendingRef.current.id);
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
void remove(id);
|
||||
pendingRef.current = undefined;
|
||||
setPending(undefined);
|
||||
}, UNDO_MS);
|
||||
pendingRef.current = { id, timer };
|
||||
setPending(id);
|
||||
const requestFiles = useCallback((incoming: File[], destination?: { folderId?: string }) => {
|
||||
const target = destination ? destination.folderId : activeFolderId;
|
||||
if (incoming.length && activeThreadId && enabled) setUpload({ files: incoming, threadId: activeThreadId, ...(target ? { folderId: target } : {}) });
|
||||
}, [activeThreadId, activeFolderId, enabled]);
|
||||
const paste = useCallback((payload: ClipboardPayload) => {
|
||||
if (payload.files.length) requestFiles(payload.files);
|
||||
else if (payload.text && activeThreadId && enabled) void sendText(payload.text, activeThreadId);
|
||||
}, [requestFiles, activeThreadId, sendText, enabled]);
|
||||
useGlobalPaste(paste);
|
||||
function selectThread(id: string) { setThreadId(id); setFolderId(undefined); setSelectedId(undefined); setChecked(new Set()); }
|
||||
function selectFolder(id?: string) { setFolderId(id); setSelectedId(undefined); setChecked(new Set()); }
|
||||
function toggle(id: string) { setChecked((previous) => { const next = new Set(previous); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }
|
||||
function confirmUpload(options: FileUploadOptions) {
|
||||
const target = upload; setUpload(undefined);
|
||||
if (target) void sendFiles(target.files, options, target.threadId, target.folderId);
|
||||
}
|
||||
|
||||
function undoDelete() {
|
||||
if (!pendingRef.current) return;
|
||||
clearTimeout(pendingRef.current.timer);
|
||||
pendingRef.current = undefined;
|
||||
setPending(undefined);
|
||||
async function downloadItems(ids: string[]) { for (const id of ids) { const item = useWorkspace.getState().byId[id]; if (item) await download(item); } }
|
||||
async function deleteFiles(ids: string[]) {
|
||||
if (!window.confirm(ids.length === 1 ? '删除这项内容?' : `删除所选的 ${ids.length} 个文件?`)) return;
|
||||
for (const id of ids) await remove(id);
|
||||
setChecked(new Set()); setSelectedId(undefined); setPreviewId(undefined);
|
||||
}
|
||||
|
||||
function toggleSearch() {
|
||||
if (searchOpen) setFilter((f) => ({ ...f, query: '' }));
|
||||
setSearchOpen((v) => !v);
|
||||
}
|
||||
|
||||
function toggleChecked(id: string) {
|
||||
setChecked((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (!next.delete(id)) next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function downloadChecked() {
|
||||
const { byId } = useWorkspace.getState();
|
||||
for (const id of checked) {
|
||||
const item = byId[id];
|
||||
if (item) void download(item);
|
||||
}
|
||||
setChecked(new Set());
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent) {
|
||||
if (!e.dataTransfer.types.includes('Files')) return;
|
||||
e.preventDefault();
|
||||
setDragging(true);
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
const files = [...e.dataTransfer.files];
|
||||
if (files.length) void sendFiles(files);
|
||||
}
|
||||
|
||||
const emptyText = filtering ? '没有符合条件的内容。' : '还没有内容。粘贴、拖入文件,或在其他设备上打开此频道。';
|
||||
const topBar = (
|
||||
<TopBar
|
||||
onBack={() => navigate('/')}
|
||||
onOpenPanel={desktop ? undefined : () => setPanelOpen(true)}
|
||||
searchOpen={searchOpen}
|
||||
filtering={filtering}
|
||||
onToggleSearch={toggleSearch}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={desktop ? styles.desktop : styles.mobile} onDragOver={onDragOver} onDragLeave={() => setDragging(false)} onDrop={onDrop}>
|
||||
{desktop ? (
|
||||
<>
|
||||
{topBar}
|
||||
<div className={styles.list}>
|
||||
<ActionBar layout="toolbar" onFiles={(f) => void sendFiles(f)} onText={(t) => void sendText(t)} onCompose={() => setComposeOpen(true)} onScan={() => setScanOpen(true)} />
|
||||
<Notepad />
|
||||
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
||||
{checked.size > 0 ? (
|
||||
<div className={styles.batch}>
|
||||
<span>已选 {checked.size} 项</span>
|
||||
<span>
|
||||
<Button variant="primary" icon="download" onClick={downloadChecked}>下载</Button>
|
||||
<Button variant="ghost" onClick={() => setChecked(new Set())}>取消</Button>
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.scroll}>
|
||||
<ItemList ids={ids} selectedId={activeId} checked={checked} empty={emptyText} onSelect={setSelectedId} onToggle={toggleChecked} />
|
||||
</div>
|
||||
</div>
|
||||
<Suspense fallback={null}>
|
||||
<PreviewPane id={activeId} onDelete={requestDelete} />
|
||||
</Suspense>
|
||||
<SideRail open={railOpen} qrOpen={justCreated} onToggle={() => setRailOpen((v) => !v)} />
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.column}>
|
||||
{topBar}
|
||||
{latestId ? <LatestCard id={latestId} onOpen={setSheetId} /> : null}
|
||||
<Notepad />
|
||||
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
|
||||
<div className={styles.grow}>
|
||||
<ItemList ids={listIds} empty={emptyText} onSelect={setSheetId} />
|
||||
</div>
|
||||
<ActionBar layout="dock" onFiles={(f) => void sendFiles(f)} onText={(t) => void sendText(t)} onCompose={() => setComposeOpen(true)} onScan={() => setScanOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
||||
{!desktop && panelOpen ? (
|
||||
<Sheet title="设备与分享" onClose={closePanel}>
|
||||
<DevicePanel qrOpen={justCreated} />
|
||||
</Sheet>
|
||||
) : null}
|
||||
<Suspense fallback={null}>
|
||||
{!desktop && sheetId ? <PreviewSheet id={sheetId} onClose={closeSheet} onDelete={requestDelete} /> : null}
|
||||
{scanOpen ? <ScanSheet onClose={closeScan} onChannel={(c) => navigate(channelPath(c.id))} onShare={(sh) => navigate(sharePath(sh.id))} /> : null}
|
||||
</Suspense>
|
||||
{composeOpen ? <ComposeSheet onClose={closeCompose} onSubmit={(t) => void sendText(t)} /> : null}
|
||||
{pending ? <UndoBar onUndo={undoDelete} /> : null}
|
||||
{dragging ? <div className={styles.drop}>松开以发送文件</div> : null}
|
||||
</div>
|
||||
);
|
||||
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 : '删除失败' })); }
|
||||
const navigation = <><ChannelSwitcher id={channel?.id} onCreate={onCreateChannel} />
|
||||
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={channel?.canUpload !== false} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
|
||||
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
|
||||
{desktop ? <Notepad /> : null}<Button variant="ghost" icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button></>;
|
||||
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} />
|
||||
<FileExplorer key={activeThreadId} scope={`${channel?.id}:${activeThreadId}`} threadId={activeThreadId} threadName={activeThread?.name ?? '选择频道'} folders={activeFolders} folderId={activeFolderId} ids={files}
|
||||
selectedId={files.includes(selectedId ?? '') ? selectedId : undefined} checked={checked} enabled={enabled} onFolder={selectFolder} onSelect={setSelectedId} onOpen={setPreviewId} onToggle={toggle} onSelection={setChecked}
|
||||
onFiles={(incoming, target) => requestFiles(incoming, { folderId: target })}
|
||||
onCreateFolder={(name) => activeThreadId ? createFolder(activeThreadId, name, activeFolderId) : Promise.reject(new Error('请先选择频道'))}
|
||||
onRenameFolder={(id, name) => activeThreadId ? renameFolder(id, activeThreadId, name) : Promise.reject(new Error('请先选择频道'))}
|
||||
onDownloadItems={downloadItems} onDeleteItems={deleteFiles} onMoveItems={async (ids, target) => { if (activeThreadId) { await moveItems(ids, activeThreadId, target); setChecked(new Set()); setSelectedId(undefined); } }} />
|
||||
{restricted.length ? <div className={styles.legacy}><span>尚未同步目录的旧文件</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
|
||||
</>;
|
||||
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('/')} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} />
|
||||
{desktop ? <><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>{explorer}</aside></>
|
||||
: <><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></nav><main className={styles.mobileContent}>{mobileView === 'chat' ? chat : explorer}</main></>}
|
||||
{error ? <div className={styles.error} role="alert">{error}</div> : null}
|
||||
{panelOpen ? <Sheet title="设备与账号邀请" onClose={() => setPanelOpen(false)}><DevicePanel /></Sheet> : null}
|
||||
{settingsOpen && channel ? <ChannelSettingsSheet channel={channel} onClose={() => setSettingsOpen(false)} onSave={updateSettings} /> : null}
|
||||
{upload ? <UploadOptionsSheet files={upload.files} encrypted={channel?.mode === 'enc'} defaultMaxDownloads={channel?.maxDownloads ?? 10} onClose={() => setUpload(undefined)} onUpload={confirmUpload} /> : null}
|
||||
<Suspense fallback={null}>{previewId ? <PreviewSheet id={previewId} onClose={() => setPreviewId(undefined)} onDelete={deleteItem} /> : null}
|
||||
{scanOpen ? <ScanSheet onClose={() => setScanOpen(false)} onChannel={(resource) => navigate(channelPath(resource.id))} onShare={(resource) => navigate(sharePath(resource.id))} /> : null}</Suspense>
|
||||
{dragging ? <div className={styles.drop}>松开以上传到当前频道</div> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent, type KeyboardEvent, type MouseEvent } from 'react';
|
||||
import { useWorkspace } from '@/store/workspace';
|
||||
import { FILE_DRAG_TYPE, readFileDrag, selectFiles } from '@/lib/explorer';
|
||||
import type { MenuAction } from '@/ui/ContextMenu';
|
||||
|
||||
interface Options {
|
||||
scope: string; threadId?: string; ids: string[]; checked: ReadonlySet<string>; selectedId?: string; folderId?: string; enabled: boolean;
|
||||
onSelect: (id?: string) => void; onSelection: (ids: ReadonlySet<string>) => void; onOpen: (id: string) => void;
|
||||
onDownload: (ids: string[]) => Promise<void>; onDelete: (ids: string[]) => Promise<void>; onMove: (ids: string[], folder?: string) => Promise<void>;
|
||||
onFiles: (files: File[], folder?: string) => void; onFolder: (id?: string) => void;
|
||||
onRenameFolder: (id: string) => void; onCreateFolder: () => void; onUpload: () => void;
|
||||
}
|
||||
type Menu = { x: number; y: number; kind: 'file' | 'folder' | 'blank'; id?: string; name?: string; targets: string[] };
|
||||
export function useExplorerInteractions(options: Options) {
|
||||
const [menu, setMenu] = useState<Menu>();
|
||||
const [hover, setHover] = useState<{ id: string; point: { x: number; y: number } }>();
|
||||
const [moveIds, setMoveIds] = useState<string[]>();
|
||||
const [dropFolder, setDropFolder] = useState<string>();
|
||||
const [error, setError] = useState('');
|
||||
const anchor = useRef<string | undefined>(undefined);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
const dismiss = useCallback(() => { clearTimeout(timer.current); setMenu(undefined); setHover(undefined); setDropFolder(undefined); }, []);
|
||||
const leave = useCallback(() => { clearTimeout(timer.current); setHover(undefined); }, []);
|
||||
useEffect(() => () => clearTimeout(timer.current), []);
|
||||
function run(task: Promise<void>) { setError(''); void task.catch((cause: unknown) => setError(cause instanceof Error ? cause.message : '操作失败')); }
|
||||
function select(id: string, event: MouseEvent) {
|
||||
dismiss(); options.onSelect(id);
|
||||
options.onSelection(selectFiles(options.ids, options.checked, id, anchor.current, { additive: event.ctrlKey || event.metaKey, range: event.shiftKey }));
|
||||
if (!event.shiftKey) anchor.current = id;
|
||||
}
|
||||
function showFileMenu(id: string, x: number, y: number) {
|
||||
leave(); options.onSelect(id);
|
||||
const targets = options.checked.has(id) ? options.ids.filter((key) => options.checked.has(key)) : [id];
|
||||
if (!options.checked.has(id)) options.onSelection(new Set([id]));
|
||||
setMenu({ x, y, kind: 'file', id, targets });
|
||||
}
|
||||
function showFolderMenu(id: string, name: string, x: number, y: number) { leave(); setMenu({ x, y, kind: 'folder', id, name, targets: [] }); }
|
||||
function blankMenu(event: MouseEvent) {
|
||||
if ((event.target as HTMLElement).closest('input,select,textarea')) return;
|
||||
event.preventDefault(); leave(); setMenu({ x: event.clientX, y: event.clientY, kind: 'blank', targets: [] });
|
||||
}
|
||||
function hoverFile(id: string, rect: DOMRect) {
|
||||
if (menu || !matchMedia('(hover: hover)').matches) return;
|
||||
clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setHover({ id, point: { x: rect.left - 288, y: rect.top } }), 450);
|
||||
}
|
||||
function drag(id: string, event: DragEvent) {
|
||||
dismiss(); const ids = options.checked.has(id) ? options.ids.filter((key) => options.checked.has(key)) : [id];
|
||||
event.dataTransfer.setData(FILE_DRAG_TYPE, JSON.stringify({ scope: options.scope, ids })); event.dataTransfer.effectAllowed = 'move';
|
||||
if (!options.checked.has(id)) { options.onSelection(new Set([id])); options.onSelect(id); }
|
||||
}
|
||||
function dropHandlers(folder?: string) {
|
||||
const marker = folder ?? '__root';
|
||||
return {
|
||||
onDragOver(event: DragEvent) {
|
||||
if (!options.enabled || ![...event.dataTransfer.types].some((type) => type === FILE_DRAG_TYPE || type === 'Files')) return;
|
||||
event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = event.dataTransfer.types.includes(FILE_DRAG_TYPE) ? 'move' : 'copy'; setDropFolder(marker); leave();
|
||||
},
|
||||
onDragLeave(event: DragEvent) { if (!(event.currentTarget as HTMLElement).contains(event.relatedTarget as Node)) setDropFolder(undefined); },
|
||||
onDrop(event: DragEvent) {
|
||||
if (!options.enabled) return;
|
||||
event.preventDefault(); event.stopPropagation(); setDropFolder(undefined);
|
||||
if (event.dataTransfer.types.includes(FILE_DRAG_TYPE)) {
|
||||
const available = new Set(Object.values(useWorkspace.getState().byId).filter((item) => item.kind === 'file' && item.threadId === options.threadId).map((item) => item.id));
|
||||
const ids = readFileDrag(event.dataTransfer.getData(FILE_DRAG_TYPE), options.scope, available);
|
||||
if (!ids) { setError('只能移动当前空间、当前频道中的文件。'); return; }
|
||||
run(options.onMove(ids, folder));
|
||||
} else if (event.dataTransfer.files.length) options.onFiles([...event.dataTransfer.files], folder);
|
||||
},
|
||||
};
|
||||
}
|
||||
function keyDown(event: KeyboardEvent) {
|
||||
if ((event.target as HTMLElement).closest('input:not([type=checkbox]),select,textarea,[role=menu],[role=dialog]')) return;
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') { event.preventDefault(); options.onSelection(new Set(options.ids)); }
|
||||
else if (event.key === 'Escape') { dismiss(); options.onSelection(new Set()); options.onSelect(undefined); }
|
||||
else if (event.key === 'Delete' && options.enabled && options.checked.size) { event.preventDefault(); run(options.onDelete([...options.checked])); }
|
||||
else if (event.key === 'Enter' && options.selectedId && !(event.target as HTMLElement).closest('button,input')) { event.preventDefault(); options.onOpen(options.selectedId); }
|
||||
}
|
||||
const actions: MenuAction[] = [];
|
||||
if (menu?.kind === 'file' && menu.id) {
|
||||
const item = useWorkspace.getState().byId[menu.id];
|
||||
const unavailable = menu.targets.some((id) => { const item = useWorkspace.getState().byId[id]; return !item || item.state.kind === 'receiving' || item.state.kind === 'tampered'; });
|
||||
actions.push({ label: '预览', icon: 'eye', disabled: unavailable || menu.targets.length !== 1, action: () => options.onOpen(menu.id!) },
|
||||
{ label: menu.targets.length > 1 ? '下载所选文件' : '下载', icon: 'download', disabled: unavailable, action: () => run(options.onDownload(menu.targets)) },
|
||||
{ label: '复制名称', icon: 'copy', disabled: !item || menu.targets.length !== 1, action: () => { if (item?.kind === 'file') run(Promise.resolve().then(() => navigator.clipboard.writeText(item.name))); } },
|
||||
{ label: '移动到文件夹…', icon: 'folder', disabled: !options.enabled || unavailable, action: () => setMoveIds(menu.targets) },
|
||||
{ label: '删除', icon: 'trash', disabled: !options.enabled || unavailable, action: () => run(options.onDelete(menu.targets)) });
|
||||
} else if (menu?.kind === 'folder' && menu.id) {
|
||||
actions.push({ label: '打开文件夹', icon: 'folder', action: () => { dismiss(); options.onFolder(menu.id); } },
|
||||
{ label: '重命名', icon: 'edit', disabled: !options.enabled, action: () => options.onRenameFolder(menu.id!) });
|
||||
} else if (menu) actions.push({ label: '上传文件', icon: 'share', disabled: !options.enabled, action: options.onUpload },
|
||||
{ label: '新建文件夹', icon: 'folder', disabled: !options.enabled, action: options.onCreateFolder },
|
||||
{ label: '全选文件', icon: 'check', disabled: !options.ids.length, action: () => options.onSelection(new Set(options.ids)) });
|
||||
return { menu, actions, hover, moveIds, setMoveIds, dropFolder, error, dismiss, leave, select, showFileMenu, showFolderMenu, blankMenu, hoverFile, drag, dropHandlers, keyDown };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { accountApi, type AccountDevice } from '@/core-bridge/account';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './AccountPanel.module.css';
|
||||
|
||||
export function AccountDevices() {
|
||||
const [devices, setDevices] = useState<AccountDevice[]>([]);
|
||||
const [names, setNames] = useState<Record<string, string>>({});
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const refreshAccount = useAccount((s) => s.refresh);
|
||||
async function refresh() { setDevices(await accountApi.devices()); }
|
||||
useEffect(() => { void refresh().catch(() => setError('设备清单读取失败')); }, []);
|
||||
async function change(device: AccountDevice, revoke: boolean) {
|
||||
setBusy(true); setError('');
|
||||
try {
|
||||
if (revoke) {
|
||||
await accountApi.revokeDevice(device.id); await refreshAccount();
|
||||
} else await accountApi.renameDevice(device.id, names[device.id] ?? device.name);
|
||||
await refresh();
|
||||
} catch (cause) { setError(cause instanceof Error ? cause.message : '设备更新失败'); }
|
||||
finally { setBusy(false); }
|
||||
}
|
||||
return <section aria-label="账号设备"><strong>我的设备</strong>
|
||||
<p className={styles.note}>同一账号可登录多台设备。移除设备会撤销它的登录与空间席位,保留账号的空间成员资格。</p>
|
||||
{devices.map((device) => <div className={styles.actions} key={device.id}>
|
||||
<input aria-label={`${device.name} 的设备名称`} maxLength={80} value={names[device.id] ?? device.name} onChange={(e) => setNames((values) => ({ ...values, [device.id]: e.target.value }))} />
|
||||
<span>{device.current ? '本机' : '其他设备'}</span>
|
||||
<Button disabled={busy} onClick={() => { void change(device, false); }}>重命名</Button>
|
||||
<Button variant="ghost" disabled={busy || device.current} onClick={() => {
|
||||
if (window.confirm(`移除设备「${device.name}」并撤销它的登录?`)) void change(device, true);
|
||||
}}>移除设备</Button>
|
||||
</div>)}
|
||||
<Button variant="ghost" disabled={busy} onClick={() => { void refresh().catch(() => setError('设备清单读取失败')); }}>刷新设备</Button>
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: var(--bar-h);
|
||||
height: var(--bar-h);
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.heading h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.heading p,
|
||||
.note,
|
||||
.message,
|
||||
.error {
|
||||
margin: var(--space-1) 0 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.user {
|
||||
color: var(--ok);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-dim);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form input {
|
||||
min-width: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.form .actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.message {
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
@media (width <= 560px) {
|
||||
.form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form .actions {
|
||||
grid-column: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useShares } from '@/store/shares';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './AccountPanel.module.css';
|
||||
import { navigate, channelPath } from '@/app/navigation';
|
||||
import { InvitationInbox } from './InvitationInbox';
|
||||
import { PersonalSettings } from './PersonalSettings';
|
||||
import { AccountDevices } from './AccountDevices';
|
||||
|
||||
export function AccountPanel() {
|
||||
const account = useAccount((s) => s.username);
|
||||
const busy = useAccount((s) => s.busy);
|
||||
const error = useAccount((s) => s.error);
|
||||
const login = useAccount((s) => s.login);
|
||||
const logout = useAccount((s) => s.logout);
|
||||
const sync = useAccount((s) => s.sync);
|
||||
const channelsReady = useChannels((s) => s.hydrated);
|
||||
const sharesReady = useShares((s) => s.hydrated);
|
||||
const refreshChannels = useChannels((s) => s.refresh);
|
||||
const refreshShares = useShares((s) => s.refresh);
|
||||
const [register, setRegister] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setMessage('');
|
||||
try {
|
||||
await login(username, password, register);
|
||||
setPassword('');
|
||||
await Promise.all([refreshChannels(), refreshShares()]);
|
||||
setMessage(register ? '账号已创建,资源保险库已同步。' : '已登录,资源保险库已同步。');
|
||||
const pending = useChannels.getState().pendingLink;
|
||||
if (pending) navigate(channelPath(pending.id));
|
||||
} catch { /* 错误由账号状态呈现 */ }
|
||||
finally { setPassword(''); }
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={styles.panel} aria-labelledby="account-title">
|
||||
<div className={styles.heading}>
|
||||
<div>
|
||||
<h2 id="account-title">账号与同步</h2>
|
||||
<p>登录后可在其他设备恢复本账号保存的空间和分享。</p>
|
||||
</div>
|
||||
{account ? <span className={styles.user}>{account}</span> : null}
|
||||
</div>
|
||||
{account ? (
|
||||
<div className={styles.actions}>
|
||||
<Button disabled={busy} onClick={() => { void sync().then(() => setMessage('资源保险库已同步。')).catch(() => {}); }}>{busy ? '同步中…' : '立即同步'}</Button>
|
||||
<Button variant="ghost" disabled={busy} onClick={() => { void logout().then(() => navigate('/')); setMessage(''); }}>退出登录</Button>
|
||||
</div>
|
||||
) : (
|
||||
<form className={styles.form} onSubmit={(event) => { void submit(event); }}>
|
||||
<label>用户名<input autoComplete="username" minLength={3} maxLength={32} value={username} onChange={(event) => setUsername(event.target.value)} required /></label>
|
||||
<label>密码<input autoComplete={register ? 'new-password' : 'current-password'} type="password" minLength={register ? 12 : undefined} value={password} onChange={(event) => setPassword(event.target.value)} required /></label>
|
||||
<div className={styles.actions}>
|
||||
<Button type="submit" variant="primary" disabled={busy || !channelsReady || !sharesReady}>{busy ? '处理中…' : !channelsReady || !sharesReady ? '正在读取本机资源…' : register ? '创建账号' : '登录并同步'}</Button>
|
||||
<Button variant="ghost" onClick={() => { setRegister((value) => !value); setMessage(''); }}>{register ? '已有账号' : '创建账号'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
{message ? <p className={styles.message} role="status">{message}</p> : null}
|
||||
{account ? <InvitationInbox /> : null}
|
||||
{account ? <PersonalSettings /> : null}
|
||||
{account ? <AccountDevices /> : null}
|
||||
<p className={styles.note}>资源清单在浏览器加密后再同步,服务端无法读取空间链接。密码没有找回功能;忘记密码将无法解密账号保险库。</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AccountPanel } from './AccountPanel';
|
||||
import { ThemeMenu } from '@/ui/ThemeMenu';
|
||||
import styles from './Home.module.css';
|
||||
export function AccountScreen() {
|
||||
return <div className={styles.page}><div className={styles.appearance}><ThemeMenu /></div><main className={styles.main}>
|
||||
<header className={styles.head}><h1 className={styles.logo}>WebDrop</h1><p className={styles.sub}>注册或登录后,管理你的空间和分享。</p></header>
|
||||
<AccountPanel />
|
||||
</main></div>;
|
||||
}
|
||||
@@ -36,8 +36,15 @@
|
||||
}
|
||||
|
||||
.id {
|
||||
overflow: hidden;
|
||||
font-family: var(--font-mono);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.channelId {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.meta {
|
||||
|
||||
@@ -29,7 +29,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
{list.map((c) => (
|
||||
<li key={c.id} className={styles.row}>
|
||||
<button type="button" className={styles.open} onClick={() => onOpen(c)}>
|
||||
<span className={styles.id}>{c.id.slice(0, 8)}…</span>
|
||||
<span className={styles.id}>{'state' in c ? c.id.slice(0, 8) : c.name || c.id.slice(0, 8)}…</span>
|
||||
<span className={styles.meta}>
|
||||
{'state' in c ? (
|
||||
<>
|
||||
@@ -40,6 +40,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{c.name ? <span className={styles.channelId}>{c.id.slice(0, 8)}…</span> : null}
|
||||
<span className={c.mode === 'enc' ? styles.enc : styles.raw}>
|
||||
{c.mode === 'enc' ? <><Icon name="lock" size={12} /> 加密</> : '明文'}
|
||||
</span>
|
||||
@@ -56,7 +57,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
|
||||
<Button variant="ghost" onClick={() => setConfirmId(undefined)}>取消</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="ghost" icon="trash" label={kind === 'share' ? '从本机移除' : '从本机移除(不会删除频道)'} onClick={() => setConfirmId(c.id)} />
|
||||
<Button variant="ghost" icon="trash" label="从当前资源清单移除(不会删除服务端内容)" onClick={() => setConfirmId(c.id)} />
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@@ -14,12 +14,8 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.field legend {
|
||||
padding: 0;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.field select {
|
||||
.field select,
|
||||
.field input[type="text"] {
|
||||
min-height: 40px;
|
||||
padding: 0 var(--space-3);
|
||||
border: 1px solid var(--line-strong);
|
||||
@@ -28,25 +24,10 @@
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.seg {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.seg button {
|
||||
flex: 1;
|
||||
min-height: 40px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seg button[aria-checked="true"] {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
font-weight: 600;
|
||||
.secure {
|
||||
margin: 0;
|
||||
color: var(--ok);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.check {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { solveCreationCaptcha } from '@/core-bridge/captcha';
|
||||
import styles from './CreateSheet.module.css';
|
||||
|
||||
const SEATS = [2, 3, 5, 10];
|
||||
const DOWNLOAD_LIMITS = [0, 1, 3, 5, 10, 20];
|
||||
const TTLS = [
|
||||
{ label: '1 小时', value: 3600 },
|
||||
{ label: '24 小时', value: 86400 },
|
||||
@@ -18,10 +19,10 @@ interface Props {
|
||||
onCreated: (c: ChannelInfo) => void;
|
||||
}
|
||||
|
||||
/** 新建频道:协议 §12 里与创建相关的设置子集 */
|
||||
/** 新建空间:协议 §12 里与创建相关的设置子集 */
|
||||
export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
const create = useChannels((s) => s.create);
|
||||
const [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 3, ttlSeconds: 86400, membersCanUpload: true });
|
||||
const [opts, setOpts] = useState<CreateOptions>({ mode: 'enc', seats: 10, ttlSeconds: 86400, membersCanUpload: true, fileMaxDownloads: 10, joinPolicy: 'invite', allowGuests: false });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const set = <K extends keyof CreateOptions>(k: K, v: CreateOptions[K]) => setOpts((o) => ({ ...o, [k]: v }));
|
||||
@@ -39,16 +40,16 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet title="新建频道" onClose={onClose}>
|
||||
<Sheet title="新建空间" onClose={onClose}>
|
||||
<div className={styles.form}>
|
||||
<fieldset className={styles.field}>
|
||||
<legend>模式</legend>
|
||||
<div className={styles.seg} role="radiogroup">
|
||||
<button type="button" role="radio" aria-checked={opts.mode === 'enc'} onClick={() => set('mode', 'enc')}>加密(推荐)</button>
|
||||
<button type="button" role="radio" aria-checked={opts.mode === 'raw'} onClick={() => set('mode', 'raw')}>明文</button>
|
||||
</div>
|
||||
{opts.mode === 'raw' ? <p className={styles.warn}>明文频道里的内容服务端可见,创建后不能改成加密。</p> : null}
|
||||
</fieldset>
|
||||
<label className={styles.field}>
|
||||
<span>空间名称(加入者和服务端可见)</span>
|
||||
<input type="text" maxLength={48} value={opts.name ?? ''} placeholder="例如:工作设备" onChange={(e) => set('name', e.target.value)} />
|
||||
</label>
|
||||
|
||||
<label className={styles.check}><input type="checkbox" checked={opts.mode === 'enc'} onChange={(event) => set('mode', event.target.checked ? 'enc' : 'raw')} /><span>启用端到端加密</span></label>
|
||||
<p className={opts.mode === 'enc' ? styles.secure : styles.warn}>{opts.mode === 'enc' ? '内容和文件名在设备端加密。' : '未加密的内容、文件名和频道信息对服务端可见。'}创建后不能改变加密方式。</p>
|
||||
<p className={styles.secure}>空间属于当前账号,并自动创建“常规”频道。默认通过账号邀请加入,成员在自己的设备上自动领取密钥包。</p>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>设备数(席位)</span>
|
||||
@@ -64,6 +65,13 @@ export function CreateSheet({ onClose, onCreated }: Props) {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>每个文件的受限下载次数</span>
|
||||
<select value={opts.fileMaxDownloads ?? 10} onChange={(e) => set('fileMaxDownloads', Number(e.target.value))}>
|
||||
{DOWNLOAD_LIMITS.map((n) => <option key={n} value={n}>{n === 0 ? '不限' : `${n} 次`}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className={styles.check}>
|
||||
<input type="checkbox" checked={opts.membersCanUpload} onChange={(e) => set('membersCanUpload', e.target.checked)} />
|
||||
<span>允许成员上传</span>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { errorMessage } from '@/core-bridge/errors';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { ThemeMenu } from '@/ui/ThemeMenu';
|
||||
import { ChannelList } from './ChannelList';
|
||||
import { AccountPanel } from './AccountPanel';
|
||||
import { CreateSheet } from './CreateSheet';
|
||||
import { QuickShare } from './QuickShare';
|
||||
import styles from './Home.module.css';
|
||||
@@ -77,20 +78,22 @@ export function Home() {
|
||||
/>
|
||||
{failedMessage ? <p role="alert" className={styles.error}>创建分享失败:{failedMessage}</p> : null}
|
||||
|
||||
<AccountPanel />
|
||||
|
||||
<ChannelList kind="share" title="我的分享" onOpen={open} />
|
||||
|
||||
<section className={styles.channels} aria-labelledby="channels-title">
|
||||
<div className={styles.sectionHead}>
|
||||
<h2 id="channels-title">设备频道</h2>
|
||||
<p>多台自己的设备之间长期同步</p>
|
||||
<h2 id="channels-title">空间工作区</h2>
|
||||
<p>同一账号跨设备使用,也可邀请其他账号共同使用频道与文件。</p>
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建频道</Button>
|
||||
<Button icon="scan" onClick={() => setScanOpen(true)}>加入频道</Button>
|
||||
<Button icon="edit" onClick={() => setCreateOpen(true)}>新建空间</Button>
|
||||
<Button icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button>
|
||||
</div>
|
||||
|
||||
<ChannelList kind="channel" title="我的频道" onOpen={open} />
|
||||
<p className={styles.note}>加密频道的密钥只在链接里,服务器看不到内容。持有链接的人可以读取该频道的全部历史。</p>
|
||||
<ChannelList kind="channel" title="我的空间" onOpen={open} />
|
||||
<p className={styles.note}>空间密钥按账号加密分发,登录其他设备后可自动恢复。已授权成员可读取空间历史。</p>
|
||||
</section>
|
||||
|
||||
{import.meta.env.DEV ? (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { accountApi, type AccountInvitation } from '@/core-bridge/account';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { channelPath, navigate } from '@/app/navigation';
|
||||
import styles from './AccountPanel.module.css';
|
||||
export function InvitationInbox() {
|
||||
const [invitations, setInvitations] = useState<AccountInvitation[]>([]);
|
||||
const [error, setError] = useState('');
|
||||
const [busy, setBusy] = useState<string>();
|
||||
useEffect(() => { void accountApi.invitations().then(setInvitations).catch(() => setError('读取邀请失败')); }, []);
|
||||
async function accept(id: string) {
|
||||
setBusy(id); setError('');
|
||||
try { await accountApi.acceptInvitation(id); await useChannels.getState().refresh(); navigate(channelPath(id)); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : '加入失败'); }
|
||||
finally { setBusy(undefined); }
|
||||
}
|
||||
return <div><strong>空间邀请</strong>
|
||||
<Button variant="ghost" disabled={Boolean(busy)} onClick={() => { void accountApi.invitations().then(setInvitations).catch(() => setError('读取邀请失败')); }}>刷新邀请</Button>
|
||||
{invitations.length === 0 ? <p className={styles.note}>暂无邀请。</p> : invitations.map((row) => <div className={styles.actions} key={row.channel_id}>
|
||||
<span>空间 {row.channel_id.slice(0, 8)}…</span><Button disabled={Boolean(busy)} onClick={() => { void accept(row.channel_id); }}>{busy === row.channel_id ? '加入中…' : '接受邀请'}</Button>
|
||||
</div>)}
|
||||
{error ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useAccount } from '@/store/account';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './AccountPanel.module.css';
|
||||
|
||||
export function PersonalSettings() {
|
||||
const personal = useAccount((s) => s.personal);
|
||||
const username = useAccount((s) => s.username);
|
||||
const busy = useAccount((s) => s.busy);
|
||||
const update = useAccount((s) => s.updatePersonal);
|
||||
const [name, setName] = useState(personal?.displayName ?? '');
|
||||
const [avatar, setAvatar] = useState(personal?.avatar);
|
||||
const [message, setMessage] = useState('');
|
||||
useEffect(() => { setName(personal?.displayName ?? ''); setAvatar(personal?.avatar); }, [personal?.displayName, personal?.avatar]);
|
||||
async function choose(file?: File) {
|
||||
if (!file) return;
|
||||
setMessage('');
|
||||
try {
|
||||
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type) || file.size > 250_000) throw new Error('请选择 250 KB 以内的 PNG、JPEG 或 WebP 图片');
|
||||
const bitmap = await createImageBitmap(file);
|
||||
const valid = bitmap.width > 0 && bitmap.height > 0 && bitmap.width <= 4096 && bitmap.height <= 4096;
|
||||
bitmap.close(); if (!valid) throw new Error('头像宽高不能超过 4096 像素');
|
||||
const value = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader(); reader.onload = () => resolve(String(reader.result)); reader.onerror = () => reject(new Error('头像读取失败')); reader.readAsDataURL(file);
|
||||
});
|
||||
setAvatar(value);
|
||||
} catch (cause) { setMessage(cause instanceof Error ? cause.message : '头像读取失败'); }
|
||||
}
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault(); setMessage('');
|
||||
try { await update(name, avatar); setMessage('个人资料已保存并同步。'); }
|
||||
catch (cause) { setMessage(cause instanceof Error ? cause.message : '保存失败'); }
|
||||
}
|
||||
return <form className={styles.form} onSubmit={(event) => { void submit(event); }} aria-label="个人资料">
|
||||
<label>个人名称<input maxLength={48} value={name} placeholder={username ?? ''} onChange={(e) => setName(e.target.value)} /></label>
|
||||
<label>头像<input type="file" accept="image/png,image/jpeg,image/webp" onChange={(e) => { void choose(e.target.files?.[0]); e.target.value = ''; }} /></label>
|
||||
<div className={styles.actions}>{avatar ? <img className={styles.avatar} src={avatar} alt="个人头像" /> : null}
|
||||
<Button type="submit" disabled={busy}>保存个人资料</Button>
|
||||
{avatar ? <Button variant="ghost" onClick={() => setAvatar(undefined)}>移除头像</Button> : null}
|
||||
</div>
|
||||
<p className={styles.note}>用户名是固定的账号身份。个人名称与头像会加密同步,设备名称单独管理。</p>
|
||||
{message ? <p className={styles.note} role="status">{message}</p> : null}
|
||||
</form>;
|
||||
}
|
||||
@@ -31,17 +31,32 @@ export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props)
|
||||
const download = useDownload();
|
||||
const actions = source.actions;
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [revealing, setRevealing] = useState(false);
|
||||
const [revealFailed, setRevealFailed] = useState(false);
|
||||
const [gatedAccess, setGatedAccess] = useState(false);
|
||||
if (!item) return null;
|
||||
|
||||
const size = item.kind === 'file' ? item.size : new Blob([item.text]).size;
|
||||
const long = item.kind === 'text' && isLongText(item.text);
|
||||
const receiving = item.state.kind === 'receiving' ? item.state.progress : null;
|
||||
const hiddenGatedMetadata = item.kind === 'file' && item.gated && item.name === '受限下载内容';
|
||||
const gatedPreviewBlocked = item.kind === 'file' && item.gated && !gatedAccess;
|
||||
|
||||
async function revealGatedFile() {
|
||||
if (revealing) return;
|
||||
setRevealing(true);
|
||||
setRevealFailed(false);
|
||||
try { await source.getBlob(id); setGatedAccess(true); }
|
||||
catch { setRevealFailed(true); }
|
||||
finally { setRevealing(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.detail}>
|
||||
{showTitle ? <h2 className={styles.title}>{titleOf(item)}</h2> : null}
|
||||
<p className={styles.meta}>
|
||||
{shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
|
||||
{item.kind === 'file' && !hiddenGatedMetadata ? ` · ${item.mime}` : ''}
|
||||
{item.kind === 'file' && item.remaining !== null ? ` · 剩余 ${item.remaining} 次下载` : ''}
|
||||
</p>
|
||||
|
||||
@@ -75,7 +90,9 @@ export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props)
|
||||
{receiving === null && editing && item.kind === 'text' ? (
|
||||
<TextEditor item={item} onDone={() => setEditing(false)} />
|
||||
) : null}
|
||||
{receiving === null && !editing ? <Body item={item} /> : null}
|
||||
{receiving === null && !editing ? (
|
||||
<Body item={item} hiddenGatedMetadata={gatedPreviewBlocked ?? false} revealing={revealing} revealFailed={revealFailed} onReveal={revealGatedFile} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{item.kind === 'file' && item.versions?.length ? <HistoryList item={item} /> : null}
|
||||
@@ -83,8 +100,31 @@ export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props)
|
||||
);
|
||||
}
|
||||
|
||||
function Body({ item }: { item: Item }) {
|
||||
function Body({
|
||||
item,
|
||||
hiddenGatedMetadata,
|
||||
revealing,
|
||||
revealFailed,
|
||||
onReveal,
|
||||
}: {
|
||||
item: Item;
|
||||
hiddenGatedMetadata: boolean;
|
||||
revealing: boolean;
|
||||
revealFailed: boolean;
|
||||
onReveal: () => void;
|
||||
}) {
|
||||
if (item.kind === 'text') return <TextPreview item={item} />;
|
||||
if (hiddenGatedMetadata) {
|
||||
return (
|
||||
<div className={styles.dim}>
|
||||
<p>打开预览会读取并解密文件内容,消耗 1 次下载。文件目录已通过空间加密同步。</p>
|
||||
<Button variant="quiet" icon="eye" disabled={revealing} onClick={() => void onReveal()}>
|
||||
{revealing ? '读取中…' : '解密并预览文件'}
|
||||
</Button>
|
||||
{revealFailed ? <p role="alert">无法读取文件。下载次数可能已用完,或文件已过期。</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
switch (item.preview) {
|
||||
case 'text':
|
||||
return <TextPreview item={item} />;
|
||||
|
||||
@@ -6,12 +6,13 @@ export function itemFileName(item: Item): string {
|
||||
return item.kind === 'file' ? item.name : `文本-${item.id}.txt`;
|
||||
}
|
||||
|
||||
/** 返回一个下载函数,取内容的来源由当前上下文(频道或分享)决定 */
|
||||
/** 返回一个下载函数,取内容的来源由当前上下文(空间或分享)决定 */
|
||||
export function useDownload(): (item: Item) => Promise<void> {
|
||||
const { getBlob } = useItemSource();
|
||||
return async (item) => {
|
||||
try {
|
||||
saveBlob(await getBlob(item.id), itemFileName(item));
|
||||
const blob = await getBlob(item.id);
|
||||
saveBlob(blob, blob instanceof File ? blob.name : itemFileName(item));
|
||||
} catch {
|
||||
// 原型用 alert 兜底;下载次数用完是 410 gone(api.md)
|
||||
alert('无法下载:下载次数已用完,或内容已过期。');
|
||||
|
||||
@@ -10,7 +10,7 @@ export interface ItemActions {
|
||||
}
|
||||
|
||||
/**
|
||||
* 条目组件(行、详情、预览)的数据来源。频道和便捷分享各自提供自己的,
|
||||
* 条目组件(行、详情、预览)的数据来源。空间和便捷分享各自提供自己的,
|
||||
* 这样条目组件不依赖任何一个 store,两边也不会互相牵连。
|
||||
*/
|
||||
export interface ItemSource {
|
||||
@@ -19,7 +19,7 @@ export interface ItemSource {
|
||||
useDeviceNamer: () => (deviceId: string | undefined) => string | undefined;
|
||||
getBlob: (id: string, versionId?: string) => Promise<Blob>;
|
||||
listArchive: (id: string) => Promise<ArchiveEntry[]>;
|
||||
/** 只有频道有:分享是只读的 */
|
||||
/** 只有空间有:分享是只读的 */
|
||||
actions?: ItemActions;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ export function titleOf(item: Item): string {
|
||||
}
|
||||
|
||||
export function shortDevice(name: string | undefined): string {
|
||||
return (name ?? '未知设备').split(' · ')[0] ?? '未知设备';
|
||||
return name ?? '未知设备';
|
||||
}
|
||||
|
||||
/** 当前版本号:旧版本数 + 1 */
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ChannelInfo } from '@/core-bridge/types';
|
||||
import type { ParsedChannelLink } from '@/lib/channelLink';
|
||||
import { Button } from '@/ui/Button';
|
||||
import styles from './ScanSheet.module.css';
|
||||
import { channelAccessApi } from '@/core-bridge/channelAccess';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { navigate } from '@/app/navigation';
|
||||
|
||||
interface Props {
|
||||
link: ParsedChannelLink;
|
||||
onJoin: (link: ParsedChannelLink) => Promise<ChannelInfo>;
|
||||
onJoin: (link: ParsedChannelLink, password?: string) => Promise<ChannelInfo>;
|
||||
onJoined: (c: ChannelInfo) => void;
|
||||
/** 返回上一步(重新扫描 / 取消) */
|
||||
onBack: () => void;
|
||||
@@ -16,21 +19,26 @@ interface Props {
|
||||
/** 加入前先预览:模式与权限看清楚,确认后才占席位(api.md:GET 预览、POST seats) */
|
||||
export function JoinPreview({ link, onJoin, onJoined, onBack, backLabel = '重新扫描' }: Props) {
|
||||
const [phase, setPhase] = useState<'ask' | 'joining' | 'failed'>('ask');
|
||||
const [preview, setPreview] = useState<ChannelInfo>();
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
useEffect(() => { let cancelled = false; void channelAccessApi.preview(link).then((info) => { if (!cancelled) setPreview(info); }).catch(() => {}); return () => { cancelled = true; }; }, [link]);
|
||||
|
||||
async function join() {
|
||||
setPhase('joining');
|
||||
try {
|
||||
onJoined(await onJoin(link));
|
||||
} catch {
|
||||
onJoined(await onJoin(link, password));
|
||||
} catch (cause) {
|
||||
setPhase('failed');
|
||||
setError(cause instanceof Error ? cause.message : '加入失败');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.body}>
|
||||
<dl className={styles.facts}>
|
||||
<dt>频道</dt>
|
||||
<dd><code>{link.id.slice(0, 8)}…</code></dd>
|
||||
<dt>空间</dt>
|
||||
<dd>{preview?.name ?? link.id.slice(0, 8)}</dd>
|
||||
<dt>模式</dt>
|
||||
<dd className={link.mode === 'raw' ? styles.warn : styles.ok}>
|
||||
{link.mode === 'enc' ? '加密 · 内容在浏览器内加密' : '明文 · 服务端可见全部内容'}
|
||||
@@ -40,13 +48,15 @@ export function JoinPreview({ link, onJoin, onJoined, onBack, backLabel = '重
|
||||
{link.isOwner ? '所有者 · 含管理权限' : link.canWrite ? '成员 · 可上传' : '成员'}
|
||||
</dd>
|
||||
</dl>
|
||||
<p className={styles.dim}>持有此链接的人可以读取该频道的全部历史内容。</p>
|
||||
{phase === 'failed' ? <p className={styles.error} role="alert">加入失败,请重试。</p> : null}
|
||||
<p className={styles.dim}>{preview?.joinPolicy === 'invite' ? '这个空间仅允许受邀账号加入。' : preview?.allowGuests === false ? '这个空间需要登录账号后加入。' : '未登录用户将以陌生人设备身份加入。'}符合加入规则后可读取空间历史。</p>
|
||||
{preview?.passwordRequired ? <label>空间加入密码<input type="password" autoComplete="off" maxLength={256} value={password} onChange={(event) => setPassword(event.target.value)} /></label> : null}
|
||||
{phase === 'failed' ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||
<div className={styles.row}>
|
||||
<Button variant="primary" disabled={phase === 'joining'} onClick={() => void join()}>
|
||||
{phase === 'joining' ? '加入中…' : '加入'}
|
||||
</Button>
|
||||
<Button onClick={onBack}>{backLabel}</Button>
|
||||
<Button variant="ghost" onClick={() => { useChannels.getState().setPendingLink(link); navigate('/'); }}>登录账号</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -11,26 +11,26 @@ interface Props {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/** 别人发来的频道链接:整页预览,确认后才加入 */
|
||||
/** 别人发来的空间链接:整页预览,确认后才加入 */
|
||||
export function JoinScreen({ link, onJoined, onCancel }: Props) {
|
||||
const join = useChannels((s) => s.join);
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<main className={styles.card}>
|
||||
<h1 className={styles.title}>加入频道</h1>
|
||||
<h1 className={styles.title}>加入空间</h1>
|
||||
<JoinPreview link={link} onJoin={join} onJoined={onJoined} onBack={onCancel} backLabel="取消" />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 路由里有频道 id,但本机没有这个频道,链接里也没有密钥(例如刷新后 fragment 已被清除) */
|
||||
/** 路由里有空间 id,但本机没有这个空间,链接里也没有密钥(例如刷新后 fragment 已被清除) */
|
||||
export function MissingAccess({ onHome }: { onHome: () => void }) {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<main className={styles.card}>
|
||||
<h1 className={styles.title}>无法打开此频道</h1>
|
||||
<p className={styles.dim}>本机没有这个频道的链接。请使用完整的分享链接(含 # 后面的密钥)重新打开,或扫描对方的二维码。</p>
|
||||
<h1 className={styles.title}>无法打开此空间</h1>
|
||||
<p className={styles.dim}>当前账号没有此空间的授权。请让空间主邀请你的账号;接受邀请后会自动取得加密密钥包。</p>
|
||||
<Button variant="primary" onClick={onHome}>回到首页</Button>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useQrScanner } from './useQrScanner';
|
||||
import styles from './ScanSheet.module.css';
|
||||
|
||||
const ERROR_TEXT: Record<LinkError, string> = {
|
||||
bad_url: '这不是 WebDrop 的频道或分享链接。',
|
||||
bad_url: '这不是 WebDrop 的空间或分享链接。',
|
||||
bad_id: '链接里的 id 不对。',
|
||||
bad_key: '链接里的密钥格式不对,可能被截断了。',
|
||||
};
|
||||
@@ -25,13 +25,13 @@ const SCANNER_HINT = {
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
/** 扫到频道链接:预览后加入 */
|
||||
/** 扫到空间链接:预览后加入 */
|
||||
onChannel: (c: ChannelInfo) => void;
|
||||
/** 扫到便捷分享链接:直接进入,不预览 */
|
||||
onShare: (s: Share) => void;
|
||||
}
|
||||
|
||||
/** 扫码或粘贴链接。频道链接先预览再加入;便捷分享链接直接进入清单 */
|
||||
/** 扫码或粘贴链接。空间链接先预览再加入;便捷分享链接直接进入清单 */
|
||||
export function ScanSheet({ onClose, onChannel, onShare }: Props) {
|
||||
const joinChannel = useChannels((s) => s.join);
|
||||
const joinShare = useShares((s) => s.join);
|
||||
@@ -63,7 +63,7 @@ export function ScanSheet({ onClose, onChannel, onShare }: Props) {
|
||||
const scanner = useQrScanner(videoRef, link === null && !opening, accept);
|
||||
|
||||
return (
|
||||
<Sheet title={link ? '加入频道' : '扫码或粘贴链接'} onClose={onClose}>
|
||||
<Sheet title={link ? '加入空间' : '扫码或粘贴链接'} onClose={onClose}>
|
||||
{link ? (
|
||||
<JoinPreview link={link} onJoin={joinChannel} onJoined={onChannel} onBack={() => setLink(null)} />
|
||||
) : (
|
||||
@@ -81,7 +81,7 @@ export function ScanSheet({ onClose, onChannel, onShare }: Props) {
|
||||
>
|
||||
<input
|
||||
className={styles.input}
|
||||
placeholder="或粘贴频道、分享链接"
|
||||
placeholder="或粘贴空间、分享链接"
|
||||
value={paste}
|
||||
onChange={(e) => setPaste(e.target.value)}
|
||||
autoComplete="off"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/** protocol §2:频道链接。密钥只在 fragment 里,这里的结果只放内存,不写日志、不持久化 */
|
||||
/** protocol §2:空间链接。密钥只在 fragment 里,这里的结果只放内存,不写日志、不持久化 */
|
||||
|
||||
export interface ParsedChannelLink {
|
||||
id: string;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fitFloatingPoint, readFileDrag, selectFiles } from './explorer';
|
||||
|
||||
describe('explorer selection', () => {
|
||||
const rows = ['a', 'b', 'c', 'd'];
|
||||
it('single clicks replace selection; modifier clicks toggle individual files', () => {
|
||||
expect([...selectFiles(rows, new Set(['a', 'b']), 'c', 'a', { additive: false, range: false })]).toEqual(['c']);
|
||||
expect([...selectFiles(rows, new Set(['a']), 'c', 'a', { additive: true, range: false })]).toEqual(['a', 'c']);
|
||||
expect([...selectFiles(rows, new Set(['a', 'c']), 'c', 'a', { additive: true, range: false })]).toEqual(['a']);
|
||||
});
|
||||
it('shift selection uses visible order in either direction and can add a range', () => {
|
||||
expect([...selectFiles(rows, new Set(['a']), 'b', 'd', { additive: false, range: true })]).toEqual(['b', 'c', 'd']);
|
||||
expect([...selectFiles(rows, new Set(['a']), 'd', 'c', { additive: true, range: true })]).toEqual(['a', 'c', 'd']);
|
||||
});
|
||||
it('unknown rows cannot be injected into selection', () => {
|
||||
expect([...selectFiles(rows, new Set(['a']), 'unknown', 'a', { additive: true, range: false })]).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
describe('internal file drag validation', () => {
|
||||
const available = new Set(['a', 'b']);
|
||||
it('deduplicates file ids from the same space and channel', () => {
|
||||
expect(readFileDrag(JSON.stringify({ scope: 'space:channel', ids: ['a', 'a', 'b'] }), 'space:channel', available)).toEqual(['a', 'b']);
|
||||
});
|
||||
it.each([
|
||||
{ scope: 'other:channel', ids: ['a'] }, { scope: 'space:channel', ids: ['unknown'] },
|
||||
{ scope: 'space:channel', ids: [] }, { scope: 'space:channel', ids: Array(101).fill('a') },
|
||||
])('rejects foreign or invalid payload %#', (payload) => {
|
||||
expect(readFileDrag(JSON.stringify(payload), 'space:channel', available)).toBeNull();
|
||||
});
|
||||
it('rejects malformed JSON', () => { expect(readFileDrag('not json', 'space:channel', available)).toBeNull(); });
|
||||
});
|
||||
describe('floating menu position', () => {
|
||||
it('keeps menus inside the viewport near the bottom-right edge', () => {
|
||||
expect(fitFloatingPoint(390, 890, 210, 250, 400, 900)).toEqual({ x: 182, y: 642 });
|
||||
expect(fitFloatingPoint(-100, -100, 210, 250, 400, 900)).toEqual({ x: 8, y: 8 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export const FILE_DRAG_TYPE = 'application/x-webdrop-files';
|
||||
|
||||
export function selectFiles(ordered: string[], selected: ReadonlySet<string>, id: string, anchor: string | undefined,
|
||||
modifiers: { additive: boolean; range: boolean }): Set<string> {
|
||||
if (!ordered.includes(id)) return new Set(selected);
|
||||
if (modifiers.range && anchor && ordered.includes(anchor)) {
|
||||
const first = ordered.indexOf(anchor); const last = ordered.indexOf(id);
|
||||
const range = ordered.slice(Math.min(first, last), Math.max(first, last) + 1);
|
||||
return new Set(modifiers.additive ? [...selected, ...range] : range);
|
||||
}
|
||||
if (!modifiers.additive) return new Set([id]);
|
||||
const next = new Set(selected); if (next.has(id)) next.delete(id); else next.add(id); return next;
|
||||
}
|
||||
|
||||
export function readFileDrag(text: string, scope: string, available: ReadonlySet<string>): string[] | null {
|
||||
try {
|
||||
const value = JSON.parse(text) as { scope?: unknown; ids?: unknown };
|
||||
if (value.scope !== scope || !Array.isArray(value.ids) || value.ids.length < 1 || value.ids.length > 100
|
||||
|| !value.ids.every((id: unknown) => typeof id === 'string' && available.has(id))) return null;
|
||||
return [...new Set(value.ids)] as string[];
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function fitFloatingPoint(x: number, y: number, width: number, height: number, viewportWidth: number, viewportHeight: number) {
|
||||
return { x: Math.max(8, Math.min(x, viewportWidth - width - 8)), y: Math.max(8, Math.min(y, viewportHeight - height - 8)) };
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 极简二维码编码器:字节模式、纠错等级 M、版本 1–10(最多 213 字节)。
|
||||
* 足够容纳频道链接。结构参照 ISO/IEC 18004 与 Nayuki 的公开实现思路。
|
||||
* 足够容纳空间链接。结构参照 ISO/IEC 18004 与 Nayuki 的公开实现思路。
|
||||
* 为什么自写:避免为一个 100 行量级的功能引入依赖(frontend.md 依赖策略)。
|
||||
*/
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { readSender } from './sender';
|
||||
|
||||
describe('sender display snapshots', () => {
|
||||
it('accepts account identity and safe local avatar data', () => {
|
||||
expect(readSender({ username: 'alice', displayName: '小白', avatar: 'data:image/webp;base64,AA==' })).toEqual({ username: 'alice', displayName: '小白', avatar: 'data:image/webp;base64,AA==' });
|
||||
expect(readSender({ username: 'alice', displayName: ' Alice ' })).toEqual({ username: 'alice', displayName: 'Alice' });
|
||||
});
|
||||
it.each([
|
||||
{ username: 'alice', displayName: 'Alice', avatar: 'https://tracker.test/avatar.png' },
|
||||
{ username: 'alice', displayName: 'Alice', avatar: 'data:image/svg+xml;base64,AA==' },
|
||||
{ username: 'alice', displayName: 'Alice', avatar: `data:image/png;base64,${'A'.repeat(8000)}` },
|
||||
{ username: 'alice', displayName: '\u0000Alice' },
|
||||
{ username: 'alice', displayName: '' }, { username: 'invalid user', displayName: 'Alice' }, null,
|
||||
])('rejects invalid or executable identity data %#', (value) => { expect(readSender(value)).toBeUndefined(); });
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { SenderInfo } from '@/core-bridge/types';
|
||||
|
||||
export function readSender(value: unknown): SenderInfo | undefined {
|
||||
if (!value || typeof value !== 'object') return undefined;
|
||||
const row = value as Record<string, unknown>;
|
||||
if (typeof row.username !== 'string' || !/^[a-z0-9_-]{3,32}$/.test(row.username)
|
||||
|| typeof row.displayName !== 'string' || !row.displayName.trim() || row.displayName.length > 48 || /[\u0000-\u001f]/.test(row.displayName)
|
||||
|| row.avatar !== undefined && (typeof row.avatar !== 'string' || row.avatar.length > 8000 || !/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/]+=*$/.test(row.avatar))) return undefined;
|
||||
return { username: row.username, displayName: row.displayName.trim(), ...(row.avatar ? { avatar: row.avatar as string } : {}) };
|
||||
}
|
||||
+5
-2
@@ -5,6 +5,7 @@ import { takeLinkFromLocation } from '@/app/entry';
|
||||
import { useChannels } from '@/store/channels';
|
||||
import { useShares } from '@/store/shares';
|
||||
import { initTheme } from '@/lib/theme';
|
||||
import { useAccount } from '@/store/account';
|
||||
import '@/styles/tokens.css';
|
||||
import '@/styles/base.css';
|
||||
import '@/styles/transitions.css';
|
||||
@@ -22,5 +23,7 @@ createRoot(document.getElementById('root')!).render(
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
void useChannels.getState().hydrate();
|
||||
void useShares.getState().hydrate();
|
||||
// Restore the account scope before loading any account/guest resources.
|
||||
void useAccount.getState().refresh().then(() => Promise.all([
|
||||
useChannels.getState().hydrate(), useShares.getState().hydrate(),
|
||||
]));
|
||||
@@ -0,0 +1,63 @@
|
||||
import { create } from 'zustand';
|
||||
import { accountApi } from '@/core-bridge/account';
|
||||
import type { AccountStatus } from '@/core-bridge/account';
|
||||
import { useChannels } from './channels';
|
||||
import { useShares } from './shares';
|
||||
import { useWorkspace } from './workspace';
|
||||
import { initialData } from './reduce';
|
||||
import { useShareSession } from './shareSession';
|
||||
import { initialShareData } from './shareReduce';
|
||||
|
||||
interface AccountState extends AccountStatus {
|
||||
busy: boolean;
|
||||
hydrated: boolean;
|
||||
login: (username: string, password: string, create: boolean) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
sync: () => Promise<void>;
|
||||
updatePersonal: (displayName: string, avatar?: string) => Promise<void>;
|
||||
}
|
||||
|
||||
async function refreshResources(): Promise<void> { await Promise.all([useChannels.getState().refresh(), useShares.getState().refresh()]); }
|
||||
function clearViews(): void { useWorkspace.setState(initialData); useShareSession.setState(initialShareData); useShares.setState({ pendingPayload: null }); }
|
||||
export const useAccount = create<AccountState>()((set, get) => ({
|
||||
username: null,
|
||||
revision: 0,
|
||||
error: undefined,
|
||||
busy: false,
|
||||
hydrated: false,
|
||||
login: async (username, password, create) => {
|
||||
set({ busy: true, error: undefined });
|
||||
try {
|
||||
const status = create ? await accountApi.register(username, password) : await accountApi.login(username, password);
|
||||
clearViews(); await refreshResources();
|
||||
set({ ...status, busy: false, hydrated: true });
|
||||
} catch (error) {
|
||||
set({ error: error instanceof Error ? error.message : '登录失败', busy: false, hydrated: true });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
refresh: async () => {
|
||||
try {
|
||||
const status = await accountApi.status();
|
||||
if (status.username !== get().username) clearViews();
|
||||
if (!get().hydrated || status.username !== get().username || status.revision !== get().revision) await refreshResources();
|
||||
set({ ...status, personal: status.personal, error: status.error, hydrated: true });
|
||||
} catch { set({ hydrated: true, error: '账号状态读取失败' }); }
|
||||
},
|
||||
logout: async () => {
|
||||
set({ busy: true });
|
||||
try { await accountApi.logout(); clearViews(); await refreshResources(); }
|
||||
finally { set({ username: null, personal: undefined, revision: 0, busy: false, error: undefined }); }
|
||||
},
|
||||
sync: async () => {
|
||||
set({ busy: true, error: undefined });
|
||||
try { const status = await accountApi.sync(); await refreshResources(); set({ ...status, busy: false }); }
|
||||
catch (error) { set({ error: error instanceof Error ? error.message : '同步失败', busy: false }); throw error; }
|
||||
},
|
||||
updatePersonal: async (displayName, avatar) => {
|
||||
set({ busy: true, error: undefined });
|
||||
try { const status = await accountApi.updatePersonal(displayName, avatar); set({ ...status, busy: false }); }
|
||||
catch (error) { set({ error: error instanceof Error ? error.message : '资料保存失败', busy: false }); throw error; }
|
||||
},
|
||||
}));
|
||||
Loaded 100 of 111 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user