feat(web): discord-style chat, members pane, invitations and optimistic send [CI]
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful

- chat: left-aligned discord-style messages with grouped senders, hover
  actions, an auto-growing composer and file cards
- send: render messages and files locally first and replace the
  placeholder when the worker publishes the real item; failures keep the
  text with a retry action
- navigation: space rail plus a flatter channel list with the space name
  header and collapsible groups
- members: right pane tabs (files / members) grouped by online, offline
  and pending users, with a prominent invite action and a top bar shortcut
- notifications: toast host for invite results; invitees get a toast with
  an accept action, and the invitation payload now carries the space name
  and inviter inside the encrypted grant
- users over devices: drop device names, the device list and device
  rename/release; senders show the account (guests show as guest) and the
  share panel only keeps links
- follow contract 7.0.0 (max_members, members_used, members_full, /join)
- docs: ui-design and frontend updated for the new layout and concepts
This commit is contained in:
justme committed 2026-10-14 22:19:25 +08:00
1 parent 5fd4ddd411
commit 4b8d0b5a5f
83 files changed
+1291 -661

No files matched your search

+4
View File
@@ -12,6 +12,8 @@ import { Splash } from './Splash';
import { AccountScreen } from '@/features/home/AccountScreen';
import { AccountDock } from '@/features/home/AccountDock';
import { useAccount } from '@/store/account';
import { ToastHost } from './ToastHost';
import { useInvitationNotices } from './useInvitationNotices';
const SEEN_KEY = 'wd.splash';
@@ -27,6 +29,7 @@ export function App() {
const username = useAccount((s) => s.username);
const accountHydrated = useAccount((s) => s.hydrated);
const refreshAccount = useAccount((s) => s.refresh);
useInvitationNotices(username);
useEffect(() => {
const timer = window.setInterval(() => { void refreshAccount(); }, 5000);
return () => window.clearInterval(timer);
@@ -82,6 +85,7 @@ export function App() {
<>
{screen}
{username ? <AccountDock /> : null}
<ToastHost />
{splash ? <Splash ready={ready} onDone={onSplashDone} /> : null}
</>
);
+76
View File
@@ -0,0 +1,76 @@
.host {
position: fixed;
right: max(var(--space-4), env(safe-area-inset-right));
bottom: max(var(--space-4), env(safe-area-inset-bottom));
z-index: 60;
display: flex;
flex-direction: column;
gap: var(--space-2);
width: min(360px, calc(100vw - var(--space-6)));
pointer-events: none;
}
.toast {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
border-inline-start: 3px solid var(--busy);
border-radius: var(--radius-md);
background: var(--surface);
box-shadow: var(--shadow-pop);
font-size: 13px;
pointer-events: auto;
animation: toast-in var(--duration-enter) ease-out;
}
.toast svg {
flex-shrink: 0;
}
.ok {
border-inline-start-color: var(--ok);
}
.ok svg {
color: var(--ok);
}
.error {
border-inline-start-color: var(--danger);
}
.error svg {
color: var(--danger);
}
.info svg {
color: var(--busy);
}
.text {
flex: 1;
min-width: 0;
overflow-wrap: anywhere;
}
.close {
width: 28px;
min-width: 28px;
height: 28px;
min-height: 28px;
padding: 0;
}
@keyframes toast-in {
from {
opacity: 0;
translate: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation: none;
}
}
+19
View File
@@ -0,0 +1,19 @@
import { useToast } from '@/store/toast';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import styles from './ToastHost.module.css';
const ICON = { ok: 'check', error: 'warn', info: 'bell' } as const;
export function ToastHost() {
const toasts = useToast((s) => s.toasts);
const dismiss = useToast((s) => s.dismiss);
return <div className={styles.host} aria-live="polite">
{toasts.map((toast) => <div key={toast.id} className={`${styles.toast} ${styles[toast.kind]}`} role={toast.kind === 'error' ? 'alert' : 'status'}>
<Icon name={ICON[toast.kind]} size={18} />
<span className={styles.text}>{toast.text}</span>
{toast.action ? <Button variant="ghost" onClick={() => { toast.action?.run(); dismiss(toast.id); }}>{toast.action.label}</Button> : null}
<Button className={styles.close} variant="ghost" icon="close" label="关闭通知" onClick={() => dismiss(toast.id)} />
</div>)}
</div>;
}
+48
View File
@@ -0,0 +1,48 @@
import { useEffect, useRef } from 'react';
import { accountApi, type AccountInvitation } from '@/core-bridge/account';
import { errorMessage } from '@/core-bridge/errors';
import { useChannels } from '@/store/channels';
import { useToast } from '@/store/toast';
import { channelPath, navigate } from './navigation';
const POLL_MS = 8000;
export function invitationLabel(row: AccountInvitation): string {
return row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`;
}
async function accept(row: AccountInvitation): Promise<void> {
try {
await accountApi.acceptInvitation(row.channel_id);
await useChannels.getState().refresh();
useToast.getState().push({ kind: 'ok', text: `已加入 ${invitationLabel(row)}` });
navigate(channelPath(row.channel_id));
} catch (cause) {
useToast.getState().push({ kind: 'error', text: `加入失败:${errorMessage(cause)}` });
}
}
/** 登录后轮询账号邀请,出现新邀请就弹通知,可直接接受 */
export function useInvitationNotices(username: string | null): void {
const seen = useRef<Set<string> | null>(null);
useEffect(() => {
if (!username) { seen.current = null; return; }
let stopped = false;
async function poll() {
try {
const rows = await accountApi.invitations();
if (stopped) return;
const known = seen.current;
seen.current = new Set(rows.map((row) => row.channel_id));
const fresh = known ? rows.filter((row) => !known.has(row.channel_id)) : rows;
for (const row of fresh) {
useToast.getState().push({ kind: 'info', text: row.invitedBy ? `${row.invitedBy} 邀请你加入 ${invitationLabel(row)}` : `你收到了加入 ${invitationLabel(row)} 的邀请`,
action: { label: '接受', run: () => { void accept(row); } } });
}
} catch { /* 下一轮再试,不打扰用户 */ }
}
void poll();
const timer = window.setInterval(() => { void poll(); }, POLL_MS);
return () => { stopped = true; window.clearInterval(timer); };
}, [username]);
}
+1 -1
View File
@@ -6,7 +6,7 @@ const API_BASE = import.meta.env.VITE_WEBDROP_API_URL ?? '/api/v1';
export interface AccountStatus { username: string | null; revision: number; remembered?: boolean; personal?: PersonalProfile; error?: string; unlockUsername?: string }
export type AccountDevice = AccountDeviceBody;
export type AccountInvitation = InvitationBody;
export type AccountInvitation = InvitationBody & { channelName?: string; invitedBy?: string };
export const accountApi = {
register: (username: string, password: string, remember = true) => callWorker<AccountStatus>('registerAccount', { username, password, remember, apiBase: API_BASE }),
+3 -5
View File
@@ -11,9 +11,7 @@ export interface WorkspaceApi {
createChannel(opts: CreateOptions, captchaToken?: string): Promise<ChannelInfo>;
/** 使用现有空间 settings 接口更新空间设置。 */
updateChannelSettings(patch: ChannelSettingsPatch): Promise<void>;
renameDevice(endpointHash: string, name: string): Promise<void>;
releaseDevice(endpointHash: string): Promise<void>;
/** 通过链接加入:先 GET 空间信息预览,再 POST seats */
/** 通过链接加入:先 GET 空间信息预览,再 POST join */
joinChannel(link: ParsedChannelLink, password?: string): Promise<ChannelInfo>;
inviteUser(username: string): Promise<void>;
listInvitations(): Promise<AccountInvitation[]>;
@@ -24,8 +22,8 @@ export interface WorkspaceApi {
/** 订阅某个空间的事件流(对应追加日志的推送),返回取消订阅函数 */
connect(channelId: string, onEvent: (e: WorkspaceEvent) => void): () => void;
sendText(text: string, threadId?: string): Promise<void>;
sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string): Promise<void>;
sendText(text: string, threadId?: string, localId?: string): Promise<void>;
sendFiles(files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise<void>;
createThread(name: string, groupId?: string): Promise<string>;
createGroup(name: string): Promise<string>;
renameGroup(id: string, name: string): Promise<void>;
+4 -3
View File
@@ -3,15 +3,16 @@ import { supportsApiContract } from './contract';
describe('API contract compatibility', () => {
it('accepts compatible SemVer releases in the supported major', () => {
expect(supportsApiContract('6.0.0')).toBe(true);
expect(supportsApiContract('6.8.2')).toBe(true);
expect(supportsApiContract('6.2.0-rc.1')).toBe(true);
expect(supportsApiContract('7.0.0')).toBe(true);
expect(supportsApiContract('7.8.2')).toBe(true);
expect(supportsApiContract('7.2.0-rc.1')).toBe(true);
});
it('rejects a breaking major and malformed versions', () => {
expect(supportsApiContract('3.0.0')).toBe(false);
expect(supportsApiContract('4.0.0')).toBe(false);
expect(supportsApiContract('5.0.0')).toBe(false);
expect(supportsApiContract('6.1.0')).toBe(false);
expect(supportsApiContract('01.0.0')).toBe(false);
expect(supportsApiContract(undefined)).toBe(false);
});
+1 -1
View File
@@ -1,4 +1,4 @@
export const SUPPORTED_API_CONTRACT_MAJOR = 6;
export const SUPPORTED_API_CONTRACT_MAJOR = 7;
const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
+2 -4
View File
@@ -19,8 +19,6 @@ export interface SavedResource {
kind: 'channel' | 'share';
/** 服务端空间设置名称的本地缓存,打开空间时会用最新设置刷新。 */
name?: string;
/** 本终端在席位列表中的自定义显示名称。 */
deviceLabel?: string;
mode: 'enc' | 'raw';
/** Keys stay in IndexedDB. The page URL and localStorage never retain them. */
link: string;
@@ -30,7 +28,7 @@ export interface SavedResource {
mine?: boolean;
createdAt: number;
expiresAt: number;
seats: number;
maxMembers: number;
state?: 'draft' | 'sealed';
maxVisitors?: number;
maxDownloads?: number | null;
@@ -137,7 +135,7 @@ export async function importResources(resources: SavedResource[]): Promise<void>
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,
localItems: existing?.localItems,
pendingManifestId: existing?.pendingManifestId,
needsJoin: existing?.needsJoin ?? (!existing && resource.kind === 'channel'),
});
+1 -1
View File
@@ -1,6 +1,6 @@
const messages: Record<string, string> = {
not_found: '内容不存在或已过期。', gone: '内容已删除、下载次数已用完或续传已失效。',
seats_full: '空间成员人数已满,请联系所有者。', not_joined: '你已被移出此空间。',
members_full: '空间成员人数已满,请联系所有者。', not_joined: '你已被移出此空间。',
visitors_full: '分享的访客名额已满。', unauthorized: '链接权限无效,请使用完整链接重新打开。',
forbidden: '此链接没有执行此操作的权限。', sealed: '分享已封存,无法再修改。',
too_large: '内容超过服务端容量或条目数量限制。', rate_limited: '请求过于频繁,请稍后重试。',
+1 -1
View File
@@ -17,7 +17,7 @@ export interface VaultResource {
expiresAt: number;
creatorAccount?: string;
name?: string;
seats: number;
maxMembers: number;
ttlSeconds?: number;
maxDownloads?: number | null;
maxVisitors?: number;
@@ -8,7 +8,6 @@ export interface PublishedFile {
mime: string;
size: number;
ts: number;
deviceId: string;
gated: boolean;
}
export type ResourceControl = { op: 'file.published'; item: PublishedFile };
+2 -4
View File
@@ -17,8 +17,6 @@ function reportChannelUnavailable(id: string, onEvent: (event: WorkspaceEvent) =
export const realApi: WorkspaceApi = {
createChannel: (options: CreateOptions, captchaToken?: string) => callWorker<ChannelInfo>('createChannel', { options, captchaToken, 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 }),
@@ -37,8 +35,8 @@ export const realApi: WorkspaceApi = {
};
},
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 }),
sendText: (text, threadId, localId) => callWorker<void>('sendText', { id: currentChannelId(), text, threadId, localId, apiBase: API_BASE }),
sendFiles: (files, options, threadId, folderId, localIds) => callWorker<void>('sendFiles', { id: currentChannelId(), files, options, threadId, folderId, localIds, 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 }),
+1 -1
View File
@@ -3,6 +3,6 @@ import type { CreateOptions } from './types';
export function creationOptions(defaults: DefaultsBody): CreateOptions {
const settings = defaults.channel;
return { mode: defaults.channel_mode, seats: settings.seats, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
return { mode: defaults.channel_mode, maxMembers: settings.max_members, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy, allowGuests: settings.allow_guests };
}
+9 -20
View File
@@ -1,19 +1,8 @@
import type { ChannelMode, WorkspaceChannel, WorkspaceGroup, WorkspaceFolder, WorkspaceState, AccountProfile } from './generated/api';
export type DeviceKind = 'desktop' | 'phone' | 'tablet';
export type ConnectionState = 'online' | 'reconnecting' | 'offline';
export type PreviewKind = 'image' | 'video' | 'audio' | 'text' | 'archive' | 'none';
export interface Device {
id: string;
name: string;
kind: DeviceKind;
self?: boolean;
/** 正在编辑便签 */
editing?: boolean;
joinedAt?: number;
}
/** 常驻空间:自己多台设备之间长期同步 */
export interface ChannelInfo {
dataSource?: 'server' | 'cache';
@@ -26,7 +15,7 @@ export interface ChannelInfo {
name?: string;
mode: ChannelMode;
role: 'owner' | 'member';
seats: number;
maxMembers: number;
membersUsed?: number;
membersCanUpload?: boolean;
ttlSeconds?: number;
@@ -48,7 +37,7 @@ export interface CreateOptions {
joinPassword?: string;
name?: string;
mode: ChannelMode;
seats: number;
maxMembers: number;
ttlSeconds: number;
membersCanUpload: boolean;
fileMaxDownloads?: number;
@@ -65,7 +54,7 @@ export interface ChannelSettingsPatch {
allowGuests?: boolean;
joinPassword?: string;
name?: string;
seats?: number;
maxMembers?: number;
membersCanUpload?: boolean;
ttlSeconds?: number;
fileMaxDownloads?: number;
@@ -111,10 +100,11 @@ interface ItemBase {
author?: SenderInfo;
id: string;
ts: number;
deviceId: string;
state: ItemState;
threadId?: string;
folderId?: string;
/** 乐观渲染:本地占位项的 id,worker 发布真实条目时带回,reduce 据此替换占位项 */
localId?: string;
}
export type SenderInfo = Pick<AccountProfile, 'username' | 'displayName'> & { avatar?: string };
@@ -123,8 +113,8 @@ export interface TextItem extends ItemBase {
kind: 'text';
text: string;
editedAt?: number;
/** 另一台设备同时改过(后写者胜,见 ui-design §8) */
conflict?: { deviceId: string; text: string };
/** 另一位用户同时改过(后写者胜,见 ui-design §8) */
conflict?: { author?: SenderInfo; text: string };
}
export interface FileItem extends ItemBase {
@@ -144,14 +134,14 @@ export interface FileItem extends ItemBase {
* 客户端据此把它们折叠成同一个文件的历史。后端不解析 meta。
*/
versions?: FileVersion[];
/** 当前版本是别的设备改的,而你还没看过 */
/** 当前版本是别人改的,而你还没看过 */
unseen?: boolean;
}
export interface FileVersion {
id: string;
ts: number;
deviceId: string;
author?: SenderInfo;
size: number;
}
@@ -181,7 +171,6 @@ export type WorkspaceEvent =
| { t: 'ready' }
| { t: 'connection'; state: ConnectionState }
| { t: 'channel'; info: ChannelInfo }
| { t: 'devices'; devices: Device[] }
| { t: 'item'; item: Item }
| { t: 'file-catalog'; item: FileItem }
| { t: 'removed'; id: string }
+3 -5
View File
@@ -1,7 +1,7 @@
import { setApiBase, getServerInfo } from './worker/http';
import { listPasskeys, removePasskey, beginPasskeyRegistration, finishPasskeyRegistration, beginPasskeyRecovery, finishPasskeyRecovery, beginPasskeyLogin, finishPasskeyLogin, unlockPasskeyLogin, clearPasskeyState } from './worker/passkeys';
import { connectChannelResource, getChannelBlob, listArchive, markChannelSeen, removeChannelItem, resolveTextConflict } from './worker/channelItems';
import { createChannel, forgetChannel, joinChannel, listChannels, releaseDevice, renameDevice, updateChannelSettings, previewChannel, channelMembers } from './worker/channelResources';
import { createChannel, forgetChannel, joinChannel, listChannels, 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';
@@ -68,8 +68,6 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
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);
@@ -77,8 +75,8 @@ async function dispatch(operation: string, input: unknown): Promise<unknown> {
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 ?? ''), 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 'sendText': return sendText(id, String(payload.text ?? ''), typeof payload.threadId === 'string' ? payload.threadId : undefined, undefined, typeof payload.localId === 'string' ? payload.localId : 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, Array.isArray(payload.localIds) ? payload.localIds as string[] : 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 ?? ''));
+10 -4
View File
@@ -5,7 +5,7 @@ import { setAccountIdentity, requireAccount } from './accountSession';
import { b64, unb64, deriveAccountKeys, sealJson, openJson, generateInvitationKeys, generateAccountSigningKeys } from './accountCrypto';
import { mergeVault, validateVault, type VaultProfile } from './accountVault';
import { joinChannel } from './channelResources';
import { openChannelGrant, sealChannelGrant } from './channelGrant';
import { openChannelGrant, peekChannelGrant, sealChannelGrant } from './channelGrant';
import { recoverAccountChannels } from './accountChannels';
import { forgetAccount, localAccountDevice, rememberedAccount, rememberAccount, saveDeviceName, saveLoginFields, forgetLoginFields, savedLoginFields } from './accountPersistence';
import type { AccountVaultBody, AccountSaltBody, AccountSessionBody, PublicKeyBody, InvitationBody, InvitationCryptoPlan, AccountDeviceBody, AccountRegisterRequest, AccountSessionRequest } from '../generated/api';
@@ -26,7 +26,7 @@ interface AccountSession {
controller: AbortController; dirty: boolean; syncing?: Promise<void>; error?: string;
timer?: ReturnType<typeof setTimeout>; polling?: ReturnType<typeof setInterval>;
}
export type Invitation = InvitationBody;
export type Invitation = InvitationBody & { channelName?: string; invitedBy?: string };
let account: AccountSession | undefined;
export function unlockedAccount() {
if (!account) throw new Error('请先登录账号');
@@ -246,7 +246,13 @@ export async function revokeAccountDevice(id: string): Promise<void> {
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[]>);
requireAccount();
const rows = await apiFetch('/accounts/invitations').then((r) => r.json() as Promise<InvitationBody[]>);
const session = account;
return Promise.all(rows.map(async (row): Promise<Invitation> => {
const meta = session ? await peekChannelGrant(row.payload, session.username, row.channel_id, session.profile.keys.privateKey) : {};
return { ...row, ...(meta.name ? { channelName: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) };
}));
}
export async function acceptInvitation(channelId: string): Promise<void> {
if (!account) throw new Error('请先登录账号');
@@ -262,7 +268,7 @@ export async function inviteChannelUser(channelId: string, input: string): Promi
const plan = await apiFetch(`/channels/${channelId}/invitations/${username}/crypto-plan`, { headers: session.authHeaders('owner') })
.then(r => r.json() as Promise<InvitationCryptoPlan>);
const body = await apiFetch(`/accounts/users/${username}/key`).then((r) => r.json() as Promise<PublicKeyBody>);
const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey);
const payload = await sealChannelGrant(resource.link, username, JSON.parse(body.public_key) as JsonWebKey, { name: resource.name, invitedBy: account?.username });
const domainEnvelopes = await packageInvitationDomains(resource, session, plan);
await apiFetch(`/channels/${channelId}/invitations`, { method: 'POST', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' }, body: JSON.stringify({ username, payload, domain_envelopes: domainEnvelopes }) });
}
@@ -25,7 +25,7 @@ export async function recoverAccountChannels(username: string, keys: InvitationK
creatorAccount: info.creator_account ?? undefined, name: info.settings.name ?? undefined,
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, maxDownloads: info.settings.file_max_downloads };
maxMembers: info.settings.max_members, maxDownloads: info.settings.file_max_downloads };
if (signal.aborted) throw new DOMException('账号已切换', 'AbortError');
await importResources([resource]);
} finally { session.dispose(); }
@@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest';
import { exportResource, mergeVault, 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, name: `version-${modifiedAt}` });
link: 'https://drop.test/c/a#k=secret', endpointSecret: new Uint8Array(32).fill(7), localScope: 'account:alice',
createdAt: 1, modifiedAt, expiresAt: 100, maxMembers: 3, name: `version-${modifiedAt}` });
const profile = (): VaultProfile => ({ v: 3, keys: { publicKey: {}, privateKey: {}, signing: { 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('endpointSecret');
expect(exported).not.toHaveProperty('localScope'); expect(exported.link).toContain('secret');
});
it('a stale device does not overwrite a newer remote resource', () => {
+1 -1
View File
@@ -7,7 +7,7 @@ export function exportResource(resource: SavedResource): VaultResource {
domainKeys: resource.domainKeys,
link: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
modifiedAt: resource.modifiedAt, creatorAccount: resource.creatorAccount, name: resource.name,
seats: resource.seats, ttlSeconds: resource.ttlSeconds,
maxMembers: resource.maxMembers, ttlSeconds: resource.ttlSeconds,
maxDownloads: resource.maxDownloads, maxVisitors: resource.maxVisitors, mine: resource.mine, state: resource.state };
}
export function validateVault(value: unknown): VaultProfile {
+6 -24
View File
@@ -3,11 +3,10 @@ import { canDecryptRecord, objectEventProjection, readDataKeys, verifyObjectDele
import { getResource, cacheResource, type SavedResource } from '../database';
import { channelCache } from './resourceMetadata';
import { loadWorkspace } from './workspaceStructure';
import type { SeatInfo } from '../generated/api';
import type { FileItem, Item, Snapshot } from '../types';
import { previewKindOf } from '@/lib/fileKind';
import { blobs, readers } from './sessions';
import { showRejected, deviceIdentity, deviceInfo, publishWorkspace } from './presentation';
import { showRejected, publishWorkspace } from './presentation';
import { type ChannelWire, toChannelInfo } from './wire';
import { apiFetch, readJson } from './http';
import { readResourceRecord } from './workspaceRecords';
@@ -15,12 +14,11 @@ 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') {
const name = item.meta?.name ?? '加密文件';
return {
id: item.itemId, kind: 'file', ts: timestamp, deviceId, state, name,
id: item.itemId, kind: 'file', ts: timestamp, 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,
@@ -28,7 +26,7 @@ export function mapReceived(item: ReceivedItem): Item {
gated: item.gated,
};
}
return { id: item.itemId, kind: 'text', ts: timestamp, deviceId, state,
return { id: item.itemId, kind: 'text', ts: timestamp, state,
...(item.meta?.threadId ? { threadId: item.meta.threadId } : {}),
text: new TextDecoder().decode(item.content) };
}
@@ -70,11 +68,11 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
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 };
let snapshot: Snapshot = { text: '', updatedBy: '', updatedAt: item.ts };
if (item.kind === 'text') {
try {
const value = JSON.parse(item.text) as Partial<Snapshot>;
if (typeof value.text === 'string') snapshot = { text: value.text, updatedBy: value.updatedBy ?? item.deviceId, updatedAt: value.updatedAt ?? item.ts };
if (typeof value.text === 'string') snapshot = { text: value.text, updatedBy: value.updatedBy ?? '', updatedAt: value.updatedAt ?? item.ts };
else snapshot.text = item.text;
} catch { snapshot.text = item.text; }
}
@@ -93,7 +91,7 @@ export async function applyChannelRecord(resource: SavedResource, session: Chann
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' },
id: event.itemId, kind: 'file', ts: event.createdAt, state: { kind: 'incomplete' },
name: '受限下载内容', mime: 'application/octet-stream', size: 0, remaining: null, preview: 'none',
gated: true, metadataHidden: true,
};
@@ -128,22 +126,6 @@ export async function channelMetadata(resource: SavedResource, session: ChannelS
Object.assign(resource, await cacheResource(resource, channelCache(info)));
publishWorkspace(resource.id, { t: 'channel', info: toChannelInfo(info, resource) });
await loadWorkspace(resource.id);
const ownId = toBase64Url(session.endpointHash);
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<SeatInfo[]>(
`/channels/${resource.id}/seats`, session.authHeaders('owner'));
devices = await Promise.all(seats.map(async (seat) => {
let name: string | undefined;
if (seat.label) {
try { name = await session.openLabel(seat.label, seat.endpoint_hash); }
catch { name = '设备名校验失败'; }
}
return deviceInfo(seat.endpoint_hash, seat.joined_at, name ?? (seat.endpoint_hash === ownId ? ownName : undefined), ownId);
}));
}
publishWorkspace(resource.id, { t: 'devices', devices });
}
export async function syncChannel(resource: SavedResource, session: ChannelSession, signal: AbortSignal): Promise<number> {
+16 -2
View File
@@ -8,16 +8,30 @@ interface ChannelGrant {
mode: 'enc' | 'raw';
role: 'owner' | 'member';
keys: ParsedChannelLink['keys'];
/** 仅供被邀请方在收件箱显示,在加密 payload 内,服务端不可见 */
name?: string;
invitedBy?: string;
}
export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey): Promise<string> {
export interface InvitationMeta { name?: string; invitedBy?: string }
export async function sealChannelGrant(link: string, recipient: string, publicKey: JsonWebKey, meta: InvitationMeta = {}): 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 };
role: parsed.link.isOwner ? 'owner' : 'member', keys: parsed.link.keys,
...(meta.name ? { name: meta.name } : {}), ...(meta.invitedBy ? { invitedBy: meta.invitedBy } : {}) };
return encryptInvitation(JSON.stringify(grant), recipient, grant.channelId, publicKey);
}
/** 只读取显示用的元信息;解不开或没有这些字段时返回空,调用方回退到空间 ID */
export async function peekChannelGrant(payload: string, recipient: string, channelId: string, privateKey: JsonWebKey): Promise<InvitationMeta> {
try {
const grant = JSON.parse(await decryptInvitation(payload, recipient, channelId, privateKey)) as Partial<ChannelGrant>;
return { ...(typeof grant.name === 'string' ? { name: grant.name } : {}), ...(typeof grant.invitedBy === 'string' ? { invitedBy: grant.invitedBy } : {}) };
} catch { return {}; }
}
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);
+2 -2
View File
@@ -46,7 +46,7 @@ export async function getChannelBlob(resourceId: string, itemId: string, version
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' } } });
if (error instanceof IntegrityError) publishWorkspace(resourceId, { t: 'item', item: { id: itemId, kind: 'text', ts: Date.now(), text: '', state: { kind: 'tampered' } } });
throw error;
}
const fileBlob = result.meta?.kind === 'file' && typeof File !== 'undefined'
@@ -58,7 +58,7 @@ export async function getChannelBlob(resourceId: string, itemId: string, version
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' },
id: itemId, kind: 'file', ts: result.meta.ts, state: { kind: 'complete' },
...(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,
+11 -51
View File
@@ -1,4 +1,4 @@
import { ChannelSession, toBase64Url } from '@webdrop/core';
import { ChannelSession } from '@webdrop/core';
import { cacheResource, getResource, listResources, removeResource, saveResource, type SavedResource } from '../database';
import type { ParsedChannelLink } from '@/lib/channelLink';
@@ -7,14 +7,12 @@ import { apiFetch, readJson, getServerInfo } from './http';
import { channelCache, isOfflineFailure } from './resourceMetadata';
import { accountToken } from './accountSession';
import { attachDataKeys, initializeChannelKeys } from './channelDataKeys';
import type { ChannelSettingsPatch as SettingsRequest, SeatJoined, MemberBody } from '../generated/api';
import type { ChannelSettingsPatch as SettingsRequest, JoinResult, MemberBody } from '../generated/api';
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';
@@ -32,7 +30,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
settings: {
...defaults,
name: options.name?.trim() || undefined,
seats: options.seats,
max_members: options.maxMembers,
ttl_seconds: options.ttlSeconds,
members_can_upload: options.membersCanUpload,
file_max_downloads: options.fileMaxDownloads,
@@ -53,13 +51,9 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
name: info.settings.name ?? undefined,
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,
maxMembers: info.settings.max_members,
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);
attachDataKeys(session, resource);
@@ -80,10 +74,10 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
try {
const info = await readJson<ChannelWire>(`/channels/${id}`, session.authHeaders('read'));
session.verifyChannelInfo(info);
const joined = await apiFetch(`/channels/${id}/seats`, {
const joined = await apiFetch(`/channels/${id}/join`, {
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<SeatJoined>);
info.seats_used = joined.seats_used;
}).then((response) => response.json() as Promise<JoinResult>);
info.members_used = joined.members_used;
const resource: SavedResource = {
...channelCache(info),
id, kind: 'channel', mode: session.mode, role: session.role,
@@ -91,13 +85,9 @@ export async function joinChannel(input: { link: ParsedChannelLink; password?: s
name: info.settings.name ?? undefined,
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,
maxMembers: info.settings.max_members,
maxDownloads: info.settings.file_max_downloads, localItems: existing?.localItems ?? [],
};
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,
});
await saveResource(resource);
sessions.set(id, session);
return toChannelInfo(info, resource);
@@ -114,7 +104,7 @@ export async function previewChannel(link: ParsedChannelLink): Promise<ChannelIn
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 });
expiresAt: info.expires_at, maxMembers: info.settings.max_members });
} finally { session.dispose(); }
}
@@ -146,7 +136,7 @@ export async function listChannels(): Promise<ChannelInfo[]> {
if (!isOfflineFailure(error)) throw error;
if (resource.channelMetadata) return { ...toChannelInfo(resource.channelMetadata, resource), dataSource: 'cache' };
return {
id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, seats: resource.seats, maxDownloads: resource.maxDownloads ?? undefined,
id: resource.id, name: resource.name, mode: resource.mode, role: resource.role, maxMembers: resource.maxMembers, maxDownloads: resource.maxDownloads ?? undefined,
creatorAccount: resource.creatorAccount,
memberLink: resource.link, ownerLink: resource.ownerLink, createdAt: resource.createdAt, expiresAt: resource.expiresAt,
dataSource: 'cache',
@@ -161,7 +151,7 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
if (!session.can('owner')) throw new Error('只有所有者可以修改空间设置');
const body: SettingsRequest = {};
if (patch.name !== undefined) body.name = patch.name.trim();
if (patch.seats !== undefined) body.seats = patch.seats;
if (patch.maxMembers !== undefined) body.max_members = patch.maxMembers;
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;
@@ -177,36 +167,6 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
publishWorkspace(resourceId, { t: 'channel', info: toChannelInfo(info, updated) });
}
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();
+13 -16
View File
@@ -11,6 +11,7 @@ import type { FileItem, FileUploadOptions, Item } from '../types';
import { blobs, getSession } from './sessions';
import { deviceIdentity } from './presentation';
import { accountDisplay } from './accountSession';
@@ -18,18 +19,15 @@ import { flushOutbox, MAX_FRAME_DATA, MOBILE_UPLOAD_FRAME_DATA_BYTES, persistPla
import { publishWorkspace } from './presentation';
export async function sendText(resourceId: string, text: string, threadId?: string,
event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }): Promise<{ itemId: string; uploaded: boolean }> {
event?: { kind: 'chat.create' | 'chat.edit'; targetItemId?: string }, localId?: 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'))) throw new Error('此链接没有上传权限');
const identity = await deviceIdentity(session.endpointSecret);
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
session.deviceName = device.name;
const ts = Date.now();
const author = await senderSnapshot();
const prepared = await prepareObject(resource, session, threadId, 'chat');
const plan = session.encryptText(text, { dev: device.name, ts, threadId, itemId: prepared.itemIdBytes });
const item: Item = { id: plan.itemId, kind: 'text', ts, deviceId: device.name, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}) };
const plan = session.encryptText(text, { ts, threadId, itemId: prepared.itemIdBytes });
const item: Item = { id: plan.itemId, kind: 'text', ts, ...(author ? { author } : {}), state: { kind: 'receiving', progress: 0 }, text, ...(threadId ? { threadId } : {}), ...(localId ? { localId } : {}) };
blobs.set(`${resourceId}:${item.id}`, new Blob([text], { type: 'text/plain' }));
publishWorkspace(resourceId, { t: 'item', item });
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch,
@@ -41,17 +39,16 @@ export async function sendText(resourceId: string, text: string, threadId?: stri
return { itemId: plan.itemId, uploaded };
}
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string): Promise<void> {
export async function sendFiles(resourceId: string, files: File[], suppliedOptions?: FileUploadOptions, threadId?: string, folderId?: string, localIds?: string[]): Promise<void> {
if (!threadId) throw new Error('请先选择频道');
const { session, resource } = await getSession(resourceId);
const options = suppliedOptions ?? { directPreview: true, maxDownloads: resource.maxDownloads ?? 0 };
if (resource.kind !== 'channel' || (!session.can('owner') && !session.can('write'))) throw new Error('此链接没有上传权限');
const identity = await deviceIdentity(session.endpointSecret);
const device = { ...identity, name: resource.deviceLabel ?? identity.name };
session.deviceName = device.name;
const device = await deviceIdentity(session.endpointSecret);
const maxFrameData = device.kind === 'phone' ? MOBILE_UPLOAD_FRAME_DATA_BYTES : MAX_FRAME_DATA;
const author = await senderSnapshot();
for (const file of files) {
for (const [index, file] of files.entries()) {
const localId = localIds?.[index];
const ts = Date.now();
let item: FileItem | undefined;
try {
@@ -59,12 +56,12 @@ export async function sendFiles(resourceId: string, files: File[], suppliedOptio
const plan = session.encryptFile(file, {}, {
itemId: prepared.itemIdBytes,
...(options.directPreview ? { gated: 'auto' as const } : { gated: true, maxDownloads: options.maxDownloads }),
dev: device.name, ts, maxFrameData, threadId, folderId,
ts, maxFrameData, threadId, folderId,
});
item = {
id: plan.itemId, kind: 'file', ts, deviceId: device.name, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
id: plan.itemId, kind: 'file', ts, state: { kind: 'receiving', progress: 0, phase: 'encrypting' },
...(author ? { author } : {}),
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}), ...(localId ? { localId } : {}),
name: file.name, mime: file.type || 'application/octet-stream', size: file.size,
remaining: plan.gated
? (options.directPreview ? resource.maxDownloads ?? 0 : options.maxDownloads) || null
@@ -127,11 +124,11 @@ export async function sendFiles(resourceId: string, files: File[], suppliedOptio
export async function updateSnapshot(resourceId: string, text: string): Promise<void> {
const { session, resource } = await getSession(resourceId);
const name = resource.deviceLabel ?? (await deviceIdentity(session.endpointSecret)).name;
const name = accountDisplay()?.username ?? '';
const { structure } = await import('./workspaceStructure').then(module => module.loadWorkspace(resourceId));
if (!structure.defaultChannelId) throw new Error('请先创建默认频道');
const prepared = await prepareObject(resource, session, structure.defaultChannelId, 'chat');
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, dev: name, itemId: prepared.itemIdBytes });
const plan = session.encryptJson({ text, updatedBy: name, updatedAt: Date.now() }, { snapshot: true, itemId: prepared.itemIdBytes });
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'chat.snapshot' });
await flushOutbox(resource, session);
}
+1 -1
View File
@@ -50,6 +50,6 @@ export async function queueFileCatalog(resource: SavedResource, session: Channel
const prepared = await prepareObject(resource, session, item.threadId, 'files');
const control: ResourceControl = { op: 'file.published', item: toPublishedFile(item) };
const plan = session.encryptBytes(encoder.encode(JSON.stringify({ app: WORKSPACE_APP, v: 1, ...control })),
{ kind: 'other', threadId: item.threadId, dev: item.deviceId }, { ts: item.ts, control: true, itemId: prepared.itemIdBytes });
{ kind: 'other', threadId: item.threadId }, { ts: item.ts, control: true, itemId: prepared.itemIdBytes });
await persistPlan(resource, plan, undefined, { channelId: prepared.channelId, domain: prepared.domain, epoch: prepared.epoch, eventKind: 'file.catalog' });
}
@@ -8,7 +8,6 @@ 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();
@@ -21,11 +20,9 @@ export async function connectChannel(id: string): Promise<void> {
if (signal.aborted) return;
if (resource.kind !== 'channel') throw new Error('资源类型不匹配');
// Device sessions can be revoked independently of account membership; rejoin idempotently.
await apiFetch(`/channels/${id}/seats`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
await apiFetch(`/channels/${id}/join`, { method: 'POST', headers: session.authHeaders(session.can('owner') ? 'owner' : 'read'), signal });
if (resource.needsJoin) {
if (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);
}
@@ -35,7 +32,7 @@ export async function connectChannel(id: string): Promise<void> {
async control(value) {
if (value.t === 'settings') {
await Promise.all([channelMetadata(resource, session), loadWorkspace(id)]);
} else if (value.t === 'seats') await channelMetadata(resource, session);
} else if (value.t === 'members') await channelMetadata(resource, session);
},
online() { if (!signal.aborted) publishWorkspace(id, { t: 'connection', state: 'online' }); },
};
+1 -1
View File
@@ -5,7 +5,7 @@ import type { PublishedFile } from '../protocol/workspace';
export function toPublishedFile(item: FileItem): PublishedFile {
if (!item.threadId) throw new Error('请先选择频道');
return { id: item.id, kind: 'file', threadId: item.threadId, folderId: item.folderId,
name: item.name, mime: item.mime, size: item.size, ts: item.ts, deviceId: item.deviceId,
name: item.name, mime: item.mime, size: item.size, ts: item.ts,
gated: item.gated ?? false };
}
+2 -2
View File
@@ -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: '7.0.0' }), { status: 200 }));
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '8.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: '6.0.0' }), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 }))
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
vi.stubGlobal('fetch', fetchMock);
+7 -13
View File
@@ -1,7 +1,9 @@
import type { ItemEvent } from '@webdrop/core';
import type { Device, FileItem, ShareEvent, WorkspaceEvent } from '../types';
import type { FileItem, ShareEvent, WorkspaceEvent } from '../types';
import { previewKindOf } from '@/lib/fileKind';
import { publish } from './port';
export type TerminalKind = 'desktop' | 'phone' | 'tablet';
interface ClientHints {
mobile?: boolean;
model?: string;
@@ -28,7 +30,7 @@ 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'] }> {
export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name: string; kind: TerminalKind }> {
const ua = navigator.userAgent;
const hints = await readClientHints();
const android = /Android/i.test(ua) || hints.platform === 'Android';
@@ -36,7 +38,7 @@ export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name
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 kind: TerminalKind = 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('_', '.');
@@ -66,24 +68,16 @@ export async function deviceIdentity(endpointSecret: Uint8Array): Promise<{ name
return { name: `${identity} · ${suffix}`, kind };
}
export function deviceInfo(id: string, joinedAt: number, label: string | undefined, selfId: string): Device {
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: '分享清单校验失败,内容已拒绝显示。' });
else if (event.itemId) publish(resourceId, {
t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), deviceId: '未知设备', state: { kind: 'tampered' }, text: '' },
t: 'item', item: { id: event.itemId, kind: 'text', ts: Date.now(), state: { kind: 'tampered' }, text: '' },
});
else publish(resourceId, { t: 'error', message: '内容校验失败,已拒绝该记录。' });
}
export function makeFileItem(id: string, name: string, mime: string, size: number, ts: number, state: FileItem['state'], remaining: number | null = null): FileItem {
return { id, kind: 'file', ts, deviceId: '创建者', state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
return { id, kind: 'file', ts, state, name, mime, size, remaining, preview: previewKindOf(mime, name) };
}
export function publishWorkspace(resourceId: string, event: WorkspaceEvent): void {
publish(resourceId, event);
@@ -13,7 +13,7 @@ describe('resource and rendering boundaries', () => {
expect(isOfflineFailure(new Error('bad_meta'))).toBe(false);
});
it('publishes only resource metadata, excluding UI state and download counters', () => {
const item: FileItem = { id: 'a', threadId: 'channel', kind: 'file', name: 'secret.txt', mime: 'text/plain', size: 10, ts: 1, deviceId: 'device',
const item: FileItem = { id: 'a', threadId: 'channel', kind: 'file', name: 'secret.txt', mime: 'text/plain', size: 10, ts: 1,
gated: true, remaining: 5, preview: 'text', state: { kind: 'receiving', progress: 0.2 }, unseen: true, versions: [] };
const wire = toPublishedFile(item);
expect(wire.name).toBe('secret.txt');
@@ -21,7 +21,7 @@ describe('resource and rendering boundaries', () => {
});
it('never synchronizes draft content or server structure snapshots inside the key vault', () => {
const resource: SavedResource = { id: 'a', kind: 'share', mode: 'enc', role: 'owner', link: 'key', createdAt: 1, expiresAt: 2,
endpointSecret: new Uint8Array(32), seats: 0, draftItems: [], manifestEntries: [],
endpointSecret: new Uint8Array(32), maxMembers: 0, draftItems: [], manifestEntries: [],
workspaceMetadata: { structure: { revision: 1, channels: [], groups: [], folders: [], locations: [] }, people: [] } };
const exported = exportResource(resource);
for (const field of ['draftItems', 'manifestEntries', 'workspaceMetadata', 'endpointSecret']) expect(exported).not.toHaveProperty(field);
@@ -4,7 +4,7 @@ import type { SavedResource } from '../database';
/** Server-owned fields only: preserve local credentials, drafts, and user edit timestamps. */
export function channelCache(raw: ChannelInfo): Partial<SavedResource> {
return { name: raw.settings.name ?? undefined, creatorAccount: raw.creator_account ?? undefined,
createdAt: raw.created_at, expiresAt: raw.expires_at, seats: raw.settings.seats,
createdAt: raw.created_at, expiresAt: raw.expires_at, maxMembers: raw.settings.max_members,
maxDownloads: raw.settings.file_max_downloads,
ttlSeconds: raw.settings.ttl_seconds, channelMetadata: raw };
}
+1 -1
View File
@@ -16,7 +16,7 @@ function newTextEntry(text: string): { item: TextItem; entry: Extract<ManifestEn
const ts = Date.now();
const localId = `draft-text-${crypto.randomUUID()}`;
return {
item: { id: localId, kind: 'text', ts, deviceId: '创建者', state: { kind: 'complete' }, text },
item: { id: localId, kind: 'text', ts, state: { kind: 'complete' }, text },
entry: { t: 'text', localId, text, ts },
};
}
+1 -1
View File
@@ -68,7 +68,7 @@ async function loadSealedManifest(resource: SavedResource, session: ChannelSessi
const entry = manifest.items[index];
if (!entry) throw new Error('invalid_manifest_item');
if (entry.t === 'text' && typeof entry.text === 'string' && Number.isSafeInteger(entry.ts)) {
const item: TextItem = { id: `manifest-text-${index}`, kind: 'text', ts: entry.ts, deviceId: '创建者', state: { kind: 'complete' }, text: entry.text };
const item: TextItem = { id: `manifest-text-${index}`, kind: 'text', ts: entry.ts, state: { kind: 'complete' }, text: entry.text };
publishShare(resource.id, { t: 'item', item });
} else if (entry.t === 'file' && typeof entry.id === 'string' && typeof entry.size === 'number' && Number.isSafeInteger(entry.size)) {
const detail = byId.get(entry.id);
+2 -2
View File
@@ -37,7 +37,7 @@ export async function createShare(settings: ShareSettings, captchaToken?: string
link: toShareLink(session.link(self.location.origin)),
ownerLink: toShareLink(session.link(self.location.origin, 'owner')),
endpointSecret: Uint8Array.from(session.endpointSecret),
createdAt: raw.created_at, expiresAt: raw.expires_at, seats: 0,
createdAt: raw.created_at, expiresAt: raw.expires_at, maxMembers: 0,
state: 'draft', maxVisitors: raw.settings.max_visitors,
ttlSeconds: raw.settings.ttl_seconds,
maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads,
@@ -64,7 +64,7 @@ export async function joinShare(input: { link: { id: string; key: string } }): P
id: input.link.id, kind: 'share', mode: 'enc', role: 'member', mine: false,
link: toShareLink(makeLink(self.location.origin, input.link.id, { k: input.link.key })),
endpointSecret: Uint8Array.from(session.endpointSecret), createdAt: raw.created_at, expiresAt: raw.expires_at,
seats: 0, state: raw.state,
maxMembers: 0, state: raw.state,
ttlSeconds: raw.settings.ttl_seconds,
maxVisitors: raw.settings.max_visitors, maxDownloads: raw.settings.file_max_downloads === 0 ? null : raw.settings.file_max_downloads,
};
+2 -2
View File
@@ -14,8 +14,8 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
role: raw.can_manage_structure ? 'owner' : 'member',
canUpload: raw.can_upload,
canManageStructure: raw.can_manage_structure,
seats: raw.settings.seats,
membersUsed: raw.seats_used,
maxMembers: raw.settings.max_members,
membersUsed: raw.members_used,
membersCanUpload: raw.settings.members_can_upload,
ttlSeconds: raw.settings.ttl_seconds,
maxDownloads: raw.settings.file_max_downloads,
@@ -21,6 +21,6 @@ export function readResourceRecord(item: ReceivedItem): WorkspaceEvent | false |
|| !Number.isSafeInteger(file.size) || file.size < 0 || typeof file.gated !== 'boolean')
return { t: 'error', message: '文件目录记录格式无效,已拒绝应用。' };
return { t: 'file-catalog', item: { id: file.id, kind: 'file', threadId: file.threadId, folderId: file.folderId,
name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts, deviceId: item.meta.dev ?? '未知设备',
name: file.name, mime: file.mime, size: file.size, ts: item.meta.ts,
gated: file.gated, remaining: null, preview: previewKindOf(file.mime, file.name), state: { kind: 'complete' } } };
}
@@ -1,49 +0,0 @@
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>;
}
@@ -3,7 +3,6 @@ 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';
import { useServerSettings } from '@/store/server';
import type { ServerInfoBody } from '@/core-bridge/generated/api';
@@ -25,7 +24,7 @@ export function ChannelSettingsSheet(props: Props) {
function SettingsForm({ channel, onClose, onSave, info }: Props & { info: ServerInfoBody }) {
const owner = channel.role === 'owner';
const [name, setName] = useState(channel.name ?? '');
const [seats, setSeats] = useState(channel.seats);
const [maxMembers, setMaxMembers] = useState(channel.maxMembers);
const [membersCanUpload, setMembersCanUpload] = useState(channel.membersCanUpload!);
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!);
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!);
@@ -43,7 +42,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
setSaving(true);
setFailed(false);
try {
await onSave({ name: name.trim(), seats, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
onClose();
} catch {
@@ -58,13 +57,13 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
<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)} />
<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={info.limits.max_seats} step={1} value={seats} disabled={!owner} onChange={(event) => setSeats(Number(event.target.value))} />
<input type="number" min={1} max={info.limits.max_members} step={1} value={maxMembers} disabled={!owner} onChange={(event) => setMaxMembers(Number(event.target.value))} />
</label>
<label className={styles.field}>
@@ -97,11 +96,11 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
<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} />
<p className={styles.note}>成员与邀请请在右侧栏的「成员」页管理。</p>
{owner ? (
<>
{failed ? <p className={styles.error} role="alert">保存失败,请检查名称和设置后重试。</p> : null}
<Button variant="primary" disabled={saving || seats < 1 || seats > info.limits.max_seats || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
<Button variant="primary" disabled={saving || maxMembers < 1 || maxMembers > info.limits.max_members || ttlSeconds < info.limits.min_ttl_seconds || ttlSeconds > info.limits.max_ttl_seconds || Boolean(joinPassword && joinPassword.length < 8)} onClick={() => void save()}>{saving ? '保存中…' : '保存设置'}</Button>
</>
) : <p className={styles.note}>只有所有者可以修改空间设置。</p>}
</div>
@@ -1,17 +0,0 @@
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);
return <section className={styles.panel} aria-label="我的空间导航">
<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>;
}
+27 -21
View File
@@ -1,50 +1,56 @@
import { useState } from 'react';
import { useWorkspace } from '@/store/workspace';
import { isLocalId, useWorkspace } from '@/store/workspace';
import { useAccount } from '@/store/account';
import { useDownload } from '@/features/item/download';
import { iconOf } from '@/features/item/summary';
import { senderLabel } from '@/lib/sender';
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, compact = false, onOpenFile, onDelete }: { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }) {
interface Props { id: string; compact?: boolean; onOpenFile: (id: string) => void; onDelete: (id: string) => void }
export function ChatMessage({ id, compact = false, onOpenFile, onDelete }: Props) {
const item = useWorkspace((s) => s.byId[id]);
const owner = useWorkspace((s) => s.channel?.role === 'owner');
const ownDevice = useWorkspace((s) => s.devices.find((device) => device.self)?.name);
const retry = useWorkspace((s) => s.retrySend);
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 : Boolean(ownDevice && item.deviceId === ownDevice);
const name = senderLabel(item);
const self = sender?.username !== undefined && sender.username === username;
const local = isLocalId(id);
const sending = local && item.state.kind === 'receiving';
const failed = local && item.state.kind === 'incomplete';
const ready = item.state.kind === 'complete';
const deleteLabel = item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息';
async function save() {
if (!item || downloading) return;
setDownloading(true); try { await download(item); } finally { setDownloading(false); }
}
return <article className={`${styles.message} ${self ? styles.mine : ''} ${compact ? styles.compact : ''}`} title={compact ? `${name} · ${formatTime(item.ts)}` : undefined}
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`}>
{compact ? <span className={styles.avatarSpacer} aria-hidden="true" /> : 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}>
{!compact ? <header><strong className={styles.senderName} title={sender ? `${sender.username} · ${item.deviceId}` : item.deviceId}>{name}</strong><time>{formatTime(item.ts)}</time>
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
</header> : null}
{compact && (owner || self) ? <Button className={`${styles.deleteAction} ${styles.compactDelete}`} variant="ghost" icon="trash" label={item.kind === 'file' ? `删除文件消息 ${item.name}` : '删除消息'} onClick={() => onDelete(id)} /> : null}
return <article className={`${styles.message} ${compact ? styles.compact : ''} ${sending ? styles.sending : ''}`}
aria-label={item.kind === 'file' ? `${name} 上传了 ${item.name}` : `${name} 的消息`} aria-busy={sending}>
{compact ? <time className={styles.gutterTime} dateTime={new Date(item.ts).toISOString()}>{formatTime(item.ts)}</time>
: sender?.avatar ? <img className={styles.avatar} src={sender.avatar} alt={`${name} 的头像`} />
: <span className={`${styles.avatar} ${styles.avatarFallback}`} role="img" aria-label={`${name} 的默认头像`}>{Array.from(name)[0] ?? '?'}</span>}
<div className={styles.body}>
{!compact ? <header><strong className={styles.senderName} title={sender?.username}>{name}</strong><time>{formatTime(item.ts)}</time></header> : null}
{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)}>
: <div className={styles.fileCard}>
<button type="button" className={styles.fileOpen} 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>
<Button variant="ghost" icon="eye" label="预览" disabled={!ready} onClick={() => onOpenFile(id)} />
<Button variant="ghost" icon="download" label={downloading ? '下载中…' : '下载'} disabled={!ready || downloading} onClick={() => { void save(); }} />
</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}
{item.state.kind === 'receiving' && item.kind === 'file' ? <div className={styles.delivery} role="status">{formatTransferProgress(item.state)}<progress max={1} value={item.state.progress} /></div>
: failed ? <span className={styles.failure} role="alert">发送失败。<button type="button" onClick={() => { void retry(id); }}>重试</button></span>
: item.state.kind === 'incomplete' ? <span className={styles.delivery}>发送未完成,等待续传。</span> : null}
</div>
{owner || self ? <Button className={styles.deleteAction} variant="ghost" icon="trash" label={deleteLabel} onClick={() => onDelete(id)} /> : null}
</article>;
}
+144 -88
View File
@@ -3,23 +3,28 @@
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;
gap: var(--space-2);
min-height: var(--bar-h);
padding: 0 var(--space-3);
padding: 0 var(--space-4);
border-bottom: 1px solid var(--line);
font-size: 12px;
font-size: 15px;
}
.hash {
color: var(--text-dim);
font-size: 20px;
font-weight: 400;
}
.head span,
.empty,
.message time {
.message time,
.delivery {
color: var(--text-dim);
font-size: 11px;
}
@@ -27,59 +32,72 @@
.messages {
flex: 1;
min-height: 0;
padding: var(--space-3) var(--space-4);
padding: var(--space-4) 0 var(--space-2);
overflow-y: auto;
overscroll-behavior: contain;
}
.empty {
margin: var(--space-2) 0;
margin: 0;
padding: 0 var(--space-4);
font-size: 13px;
}
/* Discord 式:所有消息同一条左边线,头像 + 名称/时间 + 正文占满宽度 */
.message {
display: flex;
align-items: flex-start;
gap: var(--space-3);
width: 100%;
margin-bottom: var(--space-3);
position: relative;
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
column-gap: var(--space-3);
padding: var(--space-1) var(--space-4);
margin-top: var(--space-3);
}
.mine {
flex-direction: row-reverse;
.message:hover,
.message:focus-within {
background: var(--accent-soft);
}
.compact {
margin-top: calc(var(--space-2) * -1);
margin-bottom: var(--space-1);
margin-top: 0;
}
.sending {
opacity: 0.6;
}
.avatar {
width: 40px;
height: 40px;
flex-shrink: 0;
margin-top: 2px;
border-radius: var(--radius-avatar);
object-fit: cover;
}
.avatarSpacer {
width: 40px;
height: 1px;
flex: 0 0 40px;
}
.avatarFallback {
display: grid;
place-items: center;
background: var(--surface-2);
color: var(--text-dim);
font-size: 14px;
font-size: 15px;
font-weight: 600;
}
.bubble {
position: relative;
/* 连发合并行:头像位置平时留空,悬停才显示时间 */
.gutterTime {
align-self: start;
padding-top: 3px;
text-align: center;
opacity: 0;
}
.message:hover .gutterTime,
.message:focus-within .gutterTime {
opacity: 1;
}
.body {
min-width: 0;
max-width: min(82%, 760px);
padding: 0 0 var(--space-1);
}
.message header {
@@ -93,22 +111,28 @@
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent);
font-size: 13px;
font-size: 14px;
}
.message header time {
margin-inline-end: auto;
.messageText {
margin: 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
font-size: 14px;
line-height: 1.5;
}
.deleteAction {
width: 26px;
min-width: 26px;
height: 26px;
min-height: 26px;
position: absolute;
inset-block-start: 0;
inset-inline-end: var(--space-4);
width: 28px;
min-width: 28px;
height: 28px;
min-height: 28px;
padding: 0;
background: var(--surface);
opacity: 0;
transition: opacity 0.12s ease-out;
}
.message:hover .deleteAction,
@@ -116,24 +140,11 @@
opacity: 1;
}
.compactDelete {
position: absolute;
inset-block-start: 0;
inset-inline-end: calc(var(--space-2) * -1);
}
.messageText {
margin: var(--space-1) 0 0;
overflow-wrap: anywhere;
white-space: pre-wrap;
font-size: 14px;
}
.dateDivider {
display: flex;
align-items: center;
gap: var(--space-3);
margin: var(--space-4) 0 var(--space-3);
margin: var(--space-4) var(--space-4) var(--space-2);
color: var(--text-dim);
font-size: 11px;
font-weight: 600;
@@ -147,17 +158,26 @@
content: '';
}
.fileMessage {
width: 280px;
max-width: 100%;
}
.fileCard {
display: flex;
align-items: center;
gap: var(--space-1);
width: 360px;
max-width: 100%;
margin-top: var(--space-1);
padding: var(--space-2) var(--space-2) var(--space-2) var(--space-3);
border: 1px solid var(--line-strong);
border-radius: var(--radius-md);
background: var(--surface-2);
}
.fileOpen {
display: flex;
flex: 1;
align-items: center;
gap: var(--space-3);
width: 100%;
padding: var(--space-3) 0;
min-width: 0;
padding: 0;
border: 0;
background: transparent;
color: var(--text);
@@ -165,6 +185,10 @@
cursor: pointer;
}
.fileOpen:disabled {
cursor: default;
}
.fileIcon {
flex-shrink: 0;
}
@@ -181,23 +205,19 @@
font-size: 13px;
}
.fileInfo > span,
.delivery {
.fileInfo > span {
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);
width: 360px;
max-width: 100%;
margin-top: var(--space-1);
}
.delivery progress {
@@ -206,7 +226,23 @@
accent-color: var(--busy);
}
.failure {
color: var(--danger);
font-size: 12px;
}
.failure button {
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-decoration: underline;
cursor: pointer;
}
.integrity {
margin: 0;
color: var(--danger);
font-size: 12px;
}
@@ -214,36 +250,56 @@
.composer {
display: flex;
align-items: flex-end;
gap: var(--space-2);
padding: var(--space-2);
border-top: 1px solid var(--line);
gap: var(--space-1);
margin: var(--space-2) var(--space-4) var(--space-4);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-lg);
background: var(--surface-2);
}
.composer:focus-within {
outline: 2px solid var(--line-strong);
}
.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);
min-height: 36px;
max-height: 160px;
padding: var(--space-2) var(--space-1);
resize: none;
border: 0;
outline: 0;
background: transparent;
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;
font-size: 14px;
line-height: 20px;
}
@media (width <= 899px) {
.pane {
flex: 1;
min-height: 0;
.message {
grid-template-columns: 32px minmax(0, 1fr);
column-gap: var(--space-2);
padding-inline: var(--space-3);
}
.avatar {
width: 32px;
height: 32px;
}
.deleteAction {
inset-inline-end: var(--space-2);
}
.composer {
margin: var(--space-2);
}
}
@media (hover: none) {
.deleteAction {
opacity: 1;
}
}
+25 -18
View File
@@ -8,36 +8,43 @@ 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 }
const MAX_INPUT_HEIGHT = 160;
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 input = useRef<HTMLTextAreaElement>(null);
const fileInput = useRef<HTMLInputElement>(null);
const byId = useWorkspace((s) => s.byId);
const chronological = ids.slice().reverse();
useEffect(() => { messages.current?.scrollTo({ top: messages.current.scrollHeight }); }, [chronological.length]);
async function submit() {
function fit() {
const el = input.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, MAX_INPUT_HEIGHT)}px`;
}
// 乐观发送:不等后端,先清空输入框,消息立刻画出来;失败由消息行自己显示重试
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); }
if (!value || !enabled) return;
setText('');
if (input.current) input.current.style.height = 'auto';
void onSend(value);
}
function keyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); void submit(); }
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault(); submit(); }
}
return (
<section className={styles.pane} aria-label={`${threadName} 聊天`}>
<div className={styles.head}><strong>{threadName}</strong><span>频道聊天</span></div>
<div className={styles.head}><span className={styles.hash} aria-hidden="true">#</span><strong>{threadName}</strong></div>
<div className={styles.messages} ref={messages} aria-live="polite">
{chronological.length === 0 ? <p className={styles.empty}>在这里发送消息或文件,成员可以看到你的名称和头像。</p> : null}
{chronological.length === 0 ? <p className={styles.empty}>这里是 #{threadName} 的开头。发送消息或文件,成员可以看到你的名称和头像。</p> : null}
{(() => {
let previous: Item | undefined;
return chronological.map((id) => {
@@ -46,7 +53,7 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
const dateKey = new Date(item.ts).toDateString();
const previousDate = previous ? new Date(previous.ts).toDateString() : undefined;
const showDate = dateKey !== previousDate;
const sameSender = previous && (previous.author?.username ?? previous.deviceId) === (item.author?.username ?? item.deviceId);
const sameSender = previous && previous.author?.username === item.author?.username;
const grouped = Boolean(sameSender && !showDate && item.ts - previous!.ts < 5 * 60_000);
previous = item;
return <Fragment key={id}>
@@ -56,13 +63,13 @@ export function ChatPane({ threadName, ids, enabled, onSend, onDelete, onOpenFil
});
})()}
</div>
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); void submit(); }}>
<form className={styles.composer} onSubmit={(event) => { event.preventDefault(); 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()} />
<Button variant="ghost" icon="plus" label="上传文件到当前频道" disabled={!enabled} onClick={() => fileInput.current?.click()} /></> : null}
<textarea ref={input} value={text} disabled={!enabled} rows={1} aria-label="消息内容" placeholder={enabled ? `给 #${threadName} 发消息` : '先创建或加入一个频道'}
onChange={(event) => { setText(event.target.value); fit(); }} onKeyDown={keyDown} />
<Button type="submit" variant="ghost" icon="send" label="发送消息" disabled={!enabled || !text.trim()} />
</form>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
</section>
);
}
-88
View File
@@ -1,88 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { formatAgo } from '@/lib/format';
import { useWorkspace } from '@/store/workspace';
import { useAccount } from '@/store/account';
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import { QrCode } from '@/ui/QrCode';
import styles from './DevicePanel.module.css';
export function DevicePanel({ qrOpen = false }: { qrOpen?: boolean }) {
const deviceCount = useWorkspace((s) => s.devices.length);
const channel = useWorkspace((s) => s.channel);
const username = useAccount((s) => s.username);
const [members, setMembers] = useState<ChannelMember[]>([]);
const [membersError, setMembersError] = useState('');
const [showOwner, setShowOwner] = useState(false);
const [showQr, setShowQr] = useState(qrOpen);
const membersUsed = channel?.membersUsed ?? '—';
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
const refreshMembers = useCallback(async () => {
if (!channel?.id) return;
setMembersError('');
try { setMembers(await channelAccessApi.members(channel.id)); }
catch { setMembersError('成员清单读取失败'); }
}, [channel?.id]);
useEffect(() => {
void refreshMembers();
}, [refreshMembers, deviceCount]);
return (
<aside className={styles.panel}>
<section>
<div className={styles.memberHeading}>
<h2 className={styles.h}>成员 <span className={styles.dim}>{membersUsed}/{channel?.seats} 人</span></h2>
<Button variant="ghost" disabled={!channel} onClick={() => { void refreshMembers(); }}>刷新</Button>
</div>
<ul className={styles.list}>
{members.map((member) => <li key={member.username} className={styles.member}>
<span className={styles.name}>{member.username}</span>
<span className={member.username === username ? styles.memberCurrent : styles.memberRole}>{member.username === username ? '你' : member.role === 'owner' ? '空间主' : '成员'}</span>
<time className={styles.memberTime}>{formatAgo(member.joined_at)}</time>
</li>)}
</ul>
{!members.length && !membersError ? <p className={styles.note}>暂无账号成员。</p> : null}
{membersError ? <p className={styles.note} role="alert">{membersError}</p> : null}
</section>
<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>
<div className={styles.row}>
<CopyButton text={channel?.memberLink ?? ''} />
<Button variant="ghost" icon="scan" aria-expanded={showQr} onClick={() => setShowQr((v) => !v)}>
{showQr ? '收起二维码' : '二维码'}
</Button>
</div>
{showQr && channel ? <QrCode text={channel.memberLink} label="成员链接二维码" /> : null}
</div>
{channel?.ownerLink ? (
<div className={styles.link}>
<div className={styles.label}>所有者链接 <span className={styles.warn}>含管理权限</span></div>
<code className={styles.code}>{showOwner ? channel.ownerLink : '••••••••••••••••••••'}</code>
<div className={styles.row}>
<Button variant="ghost" icon="eye" onClick={() => setShowOwner((v) => !v)}>{showOwner ? '隐藏' : '显示'}</Button>
<CopyButton text={channel.ownerLink} />
</div>
</div>
) : null}
<p className={styles.note}>
持有链接并符合空间加入规则后,可读取空间历史内容。不隐藏 IP 与访问时间。
</p>
</> : null}
</section>
</aside>
);
}
+2 -1
View File
@@ -1,3 +1,4 @@
import { senderLabel } from '@/lib/sender';
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useWorkspace } from '@/store/workspace';
@@ -16,7 +17,7 @@ export function ExplorerHover({ id, point }: { id: string; point: { x: number; y
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.author?.displayName ?? item.author?.username ?? item.deviceId} · {item.deviceId}</p>
<p>{formatSize(item.size)} · {item.mime}</p><p>{formatTime(item.ts)} · 上传者 {senderLabel(item)}</p>
{preview ? <HoverContent id={id} image={item.preview === 'image'} /> : <p>{item.gated ? '受限文件:悬停不读取内容,点击预览后再解密。' : item.state.kind === 'tampered' ? '内容校验失败,已拒绝预览。' : '双击文件打开完整预览。'}</p>}
</aside>, document.body);
}
+44
View File
@@ -0,0 +1,44 @@
import { useState, type FormEvent } from 'react';
import { errorMessage } from '@/core-bridge/errors';
import { useToast } from '@/store/toast';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { Sheet } from '@/ui/Sheet';
import styles from './MembersPane.module.css';
export function InviteSheet({ onClose, onInvited }: { onClose: () => void; onInvited: () => void }) {
const invite = useWorkspace((s) => s.inviteUser);
const [username, setUsername] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function submit(event: FormEvent) {
event.preventDefault();
const target = username.trim();
if (!target || busy) return;
setBusy(true); setError('');
try {
await invite(target);
useToast.getState().push({ kind: 'ok', text: `已向 ${target} 发送邀请` });
onInvited(); onClose();
} catch (cause) {
const message = errorMessage(cause);
setError(message);
useToast.getState().push({ kind: 'error', text: `邀请失败:${message}` });
} finally { setBusy(false); }
}
return <Sheet title="邀请成员" onClose={() => { if (!busy) onClose(); }} placement="top">
<form className={styles.invite} onSubmit={(event) => { void submit(event); }}>
<label>用户名
<input autoFocus maxLength={32} value={username} disabled={busy} placeholder="已注册用户的用户名" onChange={(event) => setUsername(event.target.value)} />
</label>
<p className={styles.note}>对方登录后会收到通知,接受邀请后加入空间。能否读取加入前的历史,取决于各频道的历史开关。</p>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
<div className={styles.inviteActions}>
<Button variant="ghost" disabled={busy} onClick={onClose}>取消</Button>
<Button type="submit" variant="primary" disabled={busy || !username.trim()}>{busy ? '发送中…' : '发送邀请'}</Button>
</div>
</form>
</Sheet>;
}
+3 -3
View File
@@ -1,7 +1,8 @@
import { formatSize, formatTime, formatTransferProgress } from '@/lib/format';
import { useDownload } from '@/features/item/download';
import { InlineContent, inlineKindOf } from '@/features/item/InlineContent';
import { shortDevice, titleOf } from '@/features/item/summary';
import { senderLabel } from '@/lib/sender';
import { titleOf } from '@/features/item/summary';
import { Tile } from '@/features/item/Tile';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
@@ -16,7 +17,6 @@ interface Props {
/** 手机首屏:最新一项放大,拇指区一键取走 */
export function LatestCard({ id, onOpen }: Props) {
const item = useWorkspace((s) => s.byId[id]);
const device = useWorkspace((s) => s.devices.find((d) => d.id === s.byId[id]?.deviceId)?.name);
const download = useDownload();
if (!item) return null;
@@ -32,7 +32,7 @@ export function LatestCard({ id, onOpen }: Props) {
<span className={styles.text}>
{inline === 'text' ? null : <span className={styles.title}>{titleOf(item)}</span>}
<span className={styles.sub}>
{shortDevice(device)} · {formatTime(item.ts)} · {formatSize(size)}
{senderLabel(item)} · {formatTime(item.ts)} · {formatSize(size)}
{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}
</span>
</span>
@@ -0,0 +1,177 @@
.pane {
display: flex;
flex: 1;
flex-direction: column;
min-height: 0;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
min-height: var(--bar-h);
padding: 0 var(--space-4);
border-bottom: 1px solid var(--line);
font-size: 14px;
font-weight: 600;
}
.scroll {
flex: 1;
min-height: 0;
padding: var(--space-2) var(--space-2) var(--space-4);
overflow-y: auto;
}
.section h3 {
margin: var(--space-4) var(--space-2) var(--space-1);
color: var(--text-dim);
font-size: 12px;
font-weight: 600;
}
.section ul {
margin: 0;
padding: 0;
list-style: none;
}
.row {
display: flex;
align-items: center;
gap: var(--space-3);
min-height: 44px;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
}
.row:hover,
.row:focus-within {
background: var(--accent-soft);
}
.avatar {
flex: 0 0 32px;
width: 32px;
height: 32px;
border-radius: var(--radius-avatar);
object-fit: cover;
}
.avatarFallback {
display: grid;
place-items: center;
background: var(--surface-2);
color: var(--text-dim);
font-size: 13px;
font-weight: 600;
}
.dim {
opacity: 0.6;
}
.who em {
margin-inline-start: var(--space-2);
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text-dim);
font-size: 11px;
font-style: normal;
font-weight: 400;
}
.who {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
line-height: 1.3;
}
.who strong,
.who small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.who strong {
font-size: 14px;
}
.who small {
color: var(--text-dim);
font-size: 11px;
}
.online {
flex: 0 0 8px;
width: 8px;
height: 8px;
border-radius: var(--radius-avatar);
background: var(--ok);
}
.remove {
opacity: 0;
}
.row:hover .remove,
.row:focus-within .remove {
opacity: 1;
}
@media (hover: none) {
.remove {
opacity: 1;
}
}
.note,
.error {
margin: var(--space-4) var(--space-2) 0;
color: var(--text-dim);
font-size: 12px;
line-height: 1.5;
}
.error {
color: var(--danger);
}
.invite {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.invite label {
display: flex;
flex-direction: column;
gap: var(--space-2);
color: var(--text-dim);
font-size: 13px;
}
.invite input {
min-height: 42px;
padding: 0 var(--space-3);
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-2);
color: var(--text);
font: inherit;
}
.invite .note {
margin: 0;
}
.inviteActions {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
}
+92
View File
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { channelAccessApi, type ChannelMember } from '@/core-bridge/channelAccess';
import type { AccountInvitation } from '@/core-bridge/account';
import { errorMessage } from '@/core-bridge/errors';
import type { SenderInfo } from '@/core-bridge/types';
import { useToast } from '@/store/toast';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import styles from './MembersPane.module.css';
interface Props { owner: boolean; refreshKey: number; onInvite: () => void }
function Avatar({ person, name }: { person?: SenderInfo; name: string }) {
return person?.avatar ? <img className={styles.avatar} src={person.avatar} alt="" />
: <span className={`${styles.avatar} ${styles.avatarFallback}`} aria-hidden="true">{Array.from(name)[0]?.toLocaleUpperCase() ?? '?'}</span>;
}
function Section({ title, count, children }: { title: string; count: number; children: ReactNode }) {
return count ? <section className={styles.section}><h3>{title} — {count}</h3><ul>{children}</ul></section> : null;
}
/** 右栏「成员」页:以用户为单位,按在线 / 离线 / 待接受分组。设备只是用户的属性(在线设备数由后端按账号统计) */
const PRESENCE_POLL_MS = 8000;
export function MembersPane({ owner, refreshKey, onInvite }: Props) {
const channelId = useWorkspace((s) => s.channel?.id);
const maxMembers = useWorkspace((s) => s.channel?.maxMembers);
const people = useWorkspace((s) => s.people);
const listInvitations = useWorkspace((s) => s.listInvitations);
const revoke = useWorkspace((s) => s.revokeInvitation);
const [members, setMembers] = useState<ChannelMember[]>([]);
const [pending, setPending] = useState<AccountInvitation[]>([]);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
if (!channelId) return;
try {
setMembers(await channelAccessApi.members(channelId));
setPending(owner ? await listInvitations() : []);
setError('');
} catch { setError('成员清单读取失败'); }
}, [channelId, owner, listInvitations]);
useEffect(() => {
void refresh();
// 在线状态不走订阅流,成员页可见期间定时读取
const timer = window.setInterval(() => { void refresh(); }, PRESENCE_POLL_MS);
return () => window.clearInterval(timer);
}, [refresh, refreshKey]);
async function remove(username: string, invited: boolean) {
if (!invited && !window.confirm(`移除成员 ${username}?对方的设备会被断开,已读取的内容无法收回。`)) return;
setBusy(true);
try {
await revoke(username);
useToast.getState().push({ kind: 'ok', text: invited ? `已撤销对 ${username} 的邀请` : `已移除 ${username}` });
await refresh();
} catch (cause) { useToast.getState().push({ kind: 'error', text: `操作失败:${errorMessage(cause)}` }); }
finally { setBusy(false); }
}
const online = members.filter((member) => member.online);
const offline = members.filter((member) => !member.online);
const row = (username: string, detail: string | undefined, invited: boolean, removable: boolean, isOwner = false, isOnline = false) => {
const person = people[username];
const name = person?.displayName || username;
return <li className={`${styles.row} ${invited || (!isOnline && !isOwner) ? styles.dim : ''}`} key={`${invited ? 'i' : 'm'}:${username}`}>
<Avatar person={person} name={name} />
<span className={styles.who}><strong>{name}{isOwner ? <em>空间主</em> : null}</strong>
{detail || name !== username ? <small>{[name !== username ? username : '', detail].filter(Boolean).join(' · ')}</small> : null}</span>
{isOnline ? <span className={styles.online} role="img" aria-label="在线" /> : null}
{removable ? <Button className={styles.remove} variant="ghost" disabled={busy} onClick={() => { void remove(username, invited); }}>{invited ? '撤销' : '移除'}</Button> : null}
</li>;
};
const member = (item: ChannelMember) => row(item.username,
item.devices > 1 || item.online_devices > 1 ? `${item.online_devices}/${item.devices} 台设备在线` : undefined,
false, owner && item.role !== 'owner', item.role === 'owner', item.online);
return <div className={styles.pane}>
<div className={styles.head}>
<span>成员 {members.length}{maxMembers ? `/${maxMembers}` : ''}</span>
{owner ? <Button variant="primary" icon="plus" onClick={onInvite}>邀请成员</Button> : null}
</div>
<div className={styles.scroll}>
<Section title="在线" count={online.length}>{online.map(member)}</Section>
<Section title="离线" count={offline.length}>{offline.map(member)}</Section>
<Section title="待接受" count={pending.length}>{pending.map((invitation) => row(invitation.username, undefined, true, owner))}</Section>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
{!owner ? <p className={styles.note}>只有空间主可以邀请或移除成员。</p> : null}
</div>
</div>;
}
+3 -2
View File
@@ -1,6 +1,7 @@
.pad {
padding: var(--space-2) var(--space-4);
border-bottom: 1px solid var(--line);
margin-top: var(--space-2);
padding: var(--space-1) var(--space-2);
border-top: 1px solid var(--line);
}
.head {
-2
View File
@@ -9,7 +9,6 @@ const SAVE_DELAY_MS = 400;
export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
const snapshot = useWorkspace((s) => s.snapshot);
const setSnapshot = useWorkspace((s) => s.setSnapshot);
const editor = useWorkspace((s) => s.devices.find((d) => d.editing && !d.self)?.name);
const [open, setOpen] = useState(defaultOpen);
const [draft, setDraft] = useState<string | null>(null);
const timer = useRef<number | undefined>(undefined);
@@ -31,7 +30,6 @@ export function Notepad({ defaultOpen = false }: { defaultOpen?: boolean }) {
<span className={`${styles.chev} ${open ? styles.open : ''}`}><Icon name="chevronRight" size={16} /></span>
<span className={styles.title}>便签</span>
{open ? null : <span className={styles.preview}>{firstLine}</span>}
{editor ? <span className={styles.editing}>{editor} 正在编辑</span> : null}
</button>
{open ? (
<textarea
@@ -0,0 +1,43 @@
.tabs {
display: flex;
flex-shrink: 0;
gap: var(--space-4);
min-height: var(--bar-h);
padding: 0 var(--space-4);
border-bottom: 1px solid var(--line);
}
.tabs button {
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: -1px;
padding: 0;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--text-dim);
font: inherit;
font-size: 14px;
cursor: pointer;
}
.tabs button:hover {
color: var(--text);
}
.tabs button[aria-selected="true"] {
border-bottom-color: var(--accent);
color: var(--text);
font-weight: 600;
}
.tabs span {
padding: 0 var(--space-2);
border-radius: var(--radius-sm);
background: var(--surface-2);
color: var(--text-dim);
font-size: 11px;
font-weight: 400;
line-height: 18px;
}
+11
View File
@@ -0,0 +1,11 @@
import styles from './PaneTabs.module.css';
export type SideTab = 'files' | 'members';
/** 桌面右栏的「文件 / 成员」切换 */
export function PaneTabs({ tab, memberCount, onTab }: { tab: SideTab; memberCount?: number; onTab: (tab: SideTab) => void }) {
return <div className={styles.tabs} role="tablist" aria-label="右侧栏">
<button type="button" role="tab" aria-selected={tab === 'files'} onClick={() => onTab('files')}>文件</button>
<button type="button" role="tab" aria-selected={tab === 'members'} onClick={() => onTab('members')}>成员{memberCount ? <span>{memberCount}</span> : null}</button>
</div>;
}
+54
View File
@@ -0,0 +1,54 @@
import { useState } from 'react';
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { CopyButton } from '@/ui/CopyButton';
import { QrCode } from '@/ui/QrCode';
import styles from './SharePanel.module.css';
/** 分享入口:空间地址与(持链接加入时的)成员链接。成员与邀请在右侧「成员」页管理,这里不列人 */
export function SharePanel({ qrOpen = false }: { qrOpen?: boolean }) {
const channel = useWorkspace((s) => s.channel);
const [showOwner, setShowOwner] = useState(false);
const [showQr, setShowQr] = useState(qrOpen);
const accountAddress = channel ? `${location.origin}/c/${channel.id}` : '';
return (
<aside className={styles.panel}>
<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>
<div className={styles.row}>
<CopyButton text={channel.memberLink} />
<Button variant="ghost" icon="scan" aria-expanded={showQr} onClick={() => setShowQr((v) => !v)}>
{showQr ? '收起二维码' : '二维码'}
</Button>
</div>
{showQr ? <QrCode text={channel.memberLink} label="成员链接二维码" /> : null}
</div>
{channel.ownerLink ? (
<div className={styles.link}>
<div className={styles.label}>所有者链接 <span className={styles.warn}>含管理权限</span></div>
<code className={styles.code}>{showOwner ? channel.ownerLink : '••••••••••••••••••••'}</code>
<div className={styles.row}>
<Button variant="ghost" icon="eye" onClick={() => setShowOwner((v) => !v)}>{showOwner ? '隐藏' : '显示'}</Button>
<CopyButton text={channel.ownerLink} />
</div>
</div>
) : null}
<p className={styles.note}>
持有链接并符合空间加入规则后,可读取空间历史内容。不隐藏 IP 与访问时间。
</p>
</> : null}
</section>
</aside>
);
}
@@ -1,48 +0,0 @@
.rail,
.open {
grid-row: 1 / -1;
grid-column: 3;
border-left: 1px solid var(--line);
background: var(--surface);
}
.rail {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-3);
width: 56px;
padding: var(--space-3) 0;
}
.count {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-1);
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
.countLabel {
font-family: var(--font-sans);
font-size: 10px;
}
.open {
width: 320px;
padding: var(--space-3) var(--space-4) var(--space-4);
overflow-y: auto;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-3);
}
.heading {
font-weight: 600;
}
-38
View File
@@ -1,38 +0,0 @@
import { useWorkspace } from '@/store/workspace';
import { Button } from '@/ui/Button';
import { DevicePanel } from './DevicePanel';
import styles from './SideRail.module.css';
interface Props {
open: boolean;
qrOpen: boolean;
onToggle: () => void;
}
/** 电脑右侧的设备与分享:低频,默认收成窄栏,需要时展开 */
export function SideRail({ open, qrOpen, onToggle }: Props) {
const channel = useWorkspace((s) => s.channel);
if (open) {
return (
<aside className={styles.open}>
<div className={styles.head}>
<span className={styles.heading}>成员与分享</span>
<Button variant="ghost" icon="chevronRight" label="收起" onClick={onToggle} />
</div>
<DevicePanel qrOpen={qrOpen} />
</aside>
);
}
return (
<aside className={styles.rail}>
<Button variant="ghost" icon="chevronLeft" label="展开成员与分享" onClick={onToggle} />
<span className={styles.count} title="已加入成员 / 最大成员人数">
{channel?.membersUsed ?? '—'}/{channel?.seats ?? '—'}
<span className={styles.countLabel}>人</span>
</span>
<Button variant="ghost" icon="share" label="分享" onClick={onToggle} />
</aside>
);
}
@@ -0,0 +1,108 @@
.rail {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-3) 0;
}
.space {
display: grid;
flex: 0 0 44px;
place-items: center;
width: 44px;
height: 44px;
border: 0;
border-radius: var(--radius-lg);
background: var(--surface-2);
color: var(--text);
font: inherit;
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.space:hover {
background: var(--line-strong);
}
.space:disabled {
opacity: 0.4;
cursor: default;
}
.current {
background: var(--accent);
color: var(--on-accent);
}
.current:hover {
background: var(--accent);
}
.action {
background: transparent;
color: var(--text-dim);
outline: 1px dashed var(--line-strong);
outline-offset: -1px;
}
.header {
display: flex;
align-items: center;
gap: var(--space-2);
width: 100%;
min-height: var(--bar-h);
padding: 0 var(--space-4);
border: 0;
border-bottom: 1px solid var(--line);
background: transparent;
color: var(--text);
font: inherit;
text-align: left;
cursor: pointer;
}
.header:hover {
background: var(--accent-soft);
}
.header strong {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 15px;
}
.header svg {
flex-shrink: 0;
color: var(--text-dim);
}
.raw {
color: var(--warn);
font-size: 12px;
font-weight: 600;
}
@media (width <= 899px) {
/* 手机顶栏已有空间名与设置入口 */
.header {
display: none;
}
.rail {
flex-direction: row;
justify-content: flex-start;
padding: var(--space-2) var(--space-3);
overflow-x: auto;
}
.space {
flex-basis: 40px;
width: 40px;
height: 40px;
}
}
+36
View File
@@ -0,0 +1,36 @@
import { useChannels } from '@/store/channels';
import { useAccount } from '@/store/account';
import { useWorkspace } from '@/store/workspace';
import { channelPath, navigate } from '@/app/navigation';
import { Icon } from '@/ui/Icon';
import styles from './SpaceRail.module.css';
interface RailProps { id?: string; onCreate: () => void; onJoin: () => void }
/** 空间切换:桌面是最左一列窄栏,手机是横向一排方块 */
export function SpaceRail({ id, onCreate, onJoin }: RailProps) {
const channels = useChannels((s) => s.list);
const username = useAccount((s) => s.username);
return <nav className={styles.rail} aria-label="空间列表">
{channels.map((channel) => {
const name = channel.name || channel.id.slice(0, 8);
return <button key={channel.id} type="button" className={`${styles.space} ${channel.id === id ? styles.current : ''}`}
aria-current={channel.id === id ? 'page' : undefined} title={name} aria-label={name} onClick={() => navigate(channelPath(channel.id))}>
{Array.from(name)[0]?.toLocaleUpperCase()}
</button>;
})}
<button type="button" className={`${styles.space} ${styles.action}`} title="新建空间" aria-label="新建空间" disabled={!username} onClick={onCreate}><Icon name="plus" size={20} /></button>
<button type="button" className={`${styles.space} ${styles.action}`} title="加入空间" aria-label="加入空间" onClick={onJoin}><Icon name="scan" size={20} /></button>
</nav>;
}
/** 频道列表顶部:空间名与加密状态,点击打开空间设置 */
export function SpaceHeader({ onSettings }: { onSettings: () => void }) {
const channel = useWorkspace((s) => s.channel);
const name = channel?.name || `${channel?.id.slice(0, 8) ?? ''}…`;
return <button type="button" className={styles.header} aria-label={`${name} 空间设置`} onClick={onSettings}>
{channel?.mode === 'raw' ? <span className={styles.raw}>明文</span> : <Icon name="lock" size={14} />}
<strong>{name}</strong>
<Icon name="settings" size={16} />
</button>;
}
@@ -1,9 +1,8 @@
.panel {
display: flex;
flex-direction: column;
gap: var(--space-3);
padding: var(--space-4) var(--space-3);
border-bottom: 1px solid var(--line);
gap: var(--space-1);
padding: var(--space-3) var(--space-2);
}
.channels {
@@ -17,7 +16,6 @@
.group {
min-width: 0;
margin-inline-start: var(--space-2);
padding-top: var(--space-2);
}
@@ -32,15 +30,15 @@
.groupToggle {
flex: 1;
min-width: 0;
min-height: 36px;
padding: 0 var(--space-2);
border-radius: var(--radius-md);
min-height: 28px;
padding: 0 var(--space-1);
border-radius: var(--radius-sm);
border: 0;
background: transparent;
color: var(--text-dim);
text-align: left;
font: inherit;
font-size: 13px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
@@ -67,6 +65,12 @@
flex-shrink: 0;
align-items: center;
gap: var(--space-1);
opacity: 0;
transition: opacity 0.12s ease-out;
}
.groupHeading:hover .categoryActions,
.groupHeading:focus-within .categoryActions {
opacity: 1;
}
@@ -142,8 +146,10 @@
}
.heading {
min-height: 36px;
padding-inline: var(--space-2);
min-height: 32px;
padding-inline: var(--space-1);
color: var(--text-dim);
font-size: 12px;
}
.folderHeading {
@@ -161,14 +167,13 @@
list-style: none;
}
/* 频道行缩进到分组标题文字之下 */
.threads {
padding-inline-start: var(--space-4);
}
.nestedThreads {
margin-inline-start: 0;
padding-inline-start: var(--space-4);
border-inline-start: 1px solid var(--line);
}
.threadRow,
@@ -213,12 +218,12 @@
align-items: center;
gap: var(--space-2);
min-width: 0;
min-height: 40px;
padding: 0 var(--space-3);
min-height: 32px;
padding: 0 var(--space-2);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text);
color: var(--text-dim);
text-align: left;
cursor: pointer;
}
@@ -247,12 +252,21 @@
background: var(--accent-soft);
}
.thread:hover {
color: var(--text);
}
.thread.selected {
background: var(--busy-soft);
color: var(--busy);
background: var(--accent-soft);
color: var(--text);
font-weight: 600;
}
.thread > span:first-child {
color: var(--text-dim);
font-size: 16px;
}
.empty,
.hint,
.error {
@@ -297,7 +311,11 @@
@media (width <= 899px) {
.panel {
padding: var(--space-2) var(--space-3);
padding: var(--space-1) var(--space-3);
}
.categoryActions {
opacity: 1;
}
.threads {
+10 -5
View File
@@ -11,16 +11,20 @@ interface Props {
/** 手机上点开成员与分享面板;桌面端面板在右侧栏,不传 */
onOpenPanel?: () => void;
onOpenSettings: () => void;
/** 打开右侧「成员」页(手机为成员页签) */
onOpenMembers: () => void;
/** 仅空间主:直接打开邀请弹窗 */
onInvite?: () => void;
searchOpen: boolean;
/** 有筛选条件在生效 */
filtering: boolean;
onToggleSearch: () => void;
}
export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filtering, onToggleSearch }: Props) {
export function TopBar({ onBack, onOpenPanel, onOpenSettings, onOpenMembers, onInvite, searchOpen, filtering, onToggleSearch }: Props) {
const channel = useWorkspace((s) => s.channel);
const connection = useWorkspace((s) => s.connection);
const memberCount = `${channel?.membersUsed ?? '—'}/${channel?.seats ?? '—'}`;
const memberCount = `${channel?.membersUsed ?? '—'}/${channel?.maxMembers ?? '—'}`;
const raw = channel?.mode === 'raw';
return (
@@ -38,11 +42,12 @@ export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filter
</span>
)}
</div>
<button type="button" className={styles.presence} onClick={onOpenPanel} disabled={!onOpenPanel}>
<span className={styles.count} title="已加入成员 / 最大成员人数" aria-label={`已加入 ${channel?.membersUsed ?? '未知'} 人,最多 ${channel?.seats ?? '未知'} 人`}>
<button type="button" className={styles.presence} onClick={onOpenMembers} title="查看成员">
<span className={styles.count} title="已加入成员 / 最大成员人数" aria-label={`已加入 ${channel?.membersUsed ?? '未知'} 人,最多 ${channel?.maxMembers ?? '未知'} 人`}>
{memberCount} <span className={styles.countLabel}>人</span>
</span>
</button>
{onInvite ? <Button variant="ghost" icon="plus" label="邀请成员" onClick={onInvite} /> : null}
<span className={`${styles.conn} ${styles[connection]}`} role="status">
{CONNECTION_TEXT[connection]}
</span>
@@ -56,7 +61,7 @@ export function TopBar({ onBack, onOpenPanel, onOpenSettings, searchOpen, filter
/>
<ThemeMenu />
<Button variant="ghost" icon="settings" label="空间设置" onClick={onOpenSettings} />
{onOpenPanel ? <Button variant="ghost" icon="share" label="成员与分享" onClick={onOpenPanel} /> : null}
{onOpenPanel ? <Button variant="ghost" icon="share" label="分享" onClick={onOpenPanel} /> : null}
</header>
);
}
+10 -3
View File
@@ -1,8 +1,9 @@
.desktop {
--nav-width: 320px;
--rail-width: 64px;
--nav-width: 240px;
display: grid;
grid-template-columns: var(--nav-width) minmax(260px, 1fr) 6px clamp(320px, var(--files-width, 36%), calc(100% - var(--nav-width) - 266px));
grid-template-columns: var(--rail-width) var(--nav-width) minmax(260px, 1fr) 6px clamp(320px, var(--files-width, 36%), calc(100% - var(--rail-width) - var(--nav-width) - 266px));
grid-template-rows: auto minmax(0, 1fr);
height: 100vh;
height: 100dvh;
@@ -13,6 +14,7 @@
border-bottom: 1px solid var(--line);
}
.rail,
.navigation,
.center,
.files,
@@ -23,6 +25,11 @@
min-height: 0;
}
.rail {
overflow-y: auto;
background: var(--bg);
}
.navigation {
overflow-y: auto;
border-right: 1px solid var(--line);
@@ -125,6 +132,6 @@
@media (width >= 900px) and (width <= 1100px) {
.desktop {
--nav-width: 272px;
--nav-width: 220px;
}
}
+20 -9
View File
@@ -10,15 +10,18 @@ import type { FileUploadOptions } from '@/core-bridge/types';
import type { ClipboardPayload } from '@/lib/clipboard';
import { Sheet } from '@/ui/Sheet';
import { Button } from '@/ui/Button';
import { ChannelSwitcher } from './ChannelSwitcher';
import { SpaceHeader, SpaceRail } from './SpaceRail';
import { ThreadSidebar } from './ThreadSidebar';
import { ChatPane } from './ChatPane';
import { FileExplorer } from './FileExplorer';
import { FilePaneResize } from './FilePaneResize';
import { InviteSheet } from './InviteSheet';
import { MembersPane } from './MembersPane';
import { PaneTabs, type SideTab } from './PaneTabs';
import { TopBar } from './TopBar';
import { FilterBar } from './FilterBar';
import { Notepad } from './Notepad';
import { DevicePanel } from './DevicePanel';
import { SharePanel } from './SharePanel';
import { ChannelSettingsSheet } from './ChannelSettingsSheet';
import { UploadOptionsSheet } from './UploadOptionsSheet';
import styles from './Workspace.module.css';
@@ -60,7 +63,10 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const [settingsOpen, setSettingsOpen] = useState(false);
const [scanOpen, setScanOpen] = useState(false);
const [dragging, setDragging] = useState(false);
const [mobileView, setMobileView] = useState<'chat' | 'files'>('chat');
const [mobileView, setMobileView] = useState<'chat' | 'files' | 'members'>('chat');
const [sideTab, setSideTab] = useState<SideTab>('files');
const [inviteOpen, setInviteOpen] = useState(false);
const [memberRevision, setMemberRevision] = useState(0);
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];
@@ -68,6 +74,7 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
const activeFolders = folders.filter((folder) => folder.threadId === activeThreadId);
const activeFolderId = activeFolders.some((folder) => folder.id === folderId) ? folderId : undefined;
const enabled = Boolean(activeThreadId && channel?.canUpload);
const owner = channel?.role === 'owner';
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);
@@ -103,10 +110,11 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
}
function drop(event: DragEvent) { event.preventDefault(); setDragging(false); requestFiles([...event.dataTransfer.files]); }
function deleteItem(id: string) { void deleteFiles([id]).catch((cause: unknown) => useWorkspace.setState({ error: cause instanceof Error ? cause.message : '删除失败' })); }
const navigation = <><ChannelSwitcher id={channel?.id} onCreate={onCreateChannel} />
const rail = <SpaceRail id={channel?.id} onCreate={onCreateChannel} onJoin={() => setScanOpen(true)} />;
const navigation = <><SpaceHeader onSettings={() => setSettingsOpen(true)} />
<ThreadSidebar threads={threads} groups={groups} selectedThreadId={activeThreadId} enabled={Boolean(channel?.canManageStructure)} owner={channel?.role === 'owner'} onHistory={setChannelHistory} onSelectThread={selectThread} onCreateThread={createThread} onRenameThread={renameThread}
onCreateGroup={createGroup} onRenameGroup={renameGroup} onGroupThread={groupThread} />
{desktop ? <Notepad /> : null}<Button variant="ghost" icon="scan" onClick={() => setScanOpen(true)}>加入空间</Button></>;
{desktop ? <Notepad /> : null}</>;
const chat = <ChatPane threadName={activeThread?.name ?? '选择频道'} ids={messages} enabled={enabled} onSend={(text) => sendText(text, activeThreadId)} onDelete={deleteItem} onOpenFile={setPreviewId} onFiles={requestFiles} />;
const explorer = <>
<FilterBar filter={filter} searchOpen={searchOpen} onChange={setFilter} />
@@ -120,14 +128,17 @@ export function Workspace({ onCreateChannel }: { onCreateChannel: () => void })
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.pendingCatalog}><span>等待同步文件目录</span>{restricted.map((id) => <Button variant="ghost" key={id} onClick={() => setPreviewId(id)}>查看受限文件</Button>)}</div> : null}
</>;
const members = <MembersPane owner={owner} refreshKey={memberRevision} onInvite={() => setInviteOpen(true)} />;
function openMembers() { if (desktop) setSideTab('members'); else setMobileView('members'); }
return <div className={desktop ? styles.desktop : styles.mobile} style={fileWidth ? { '--files-width': `${fileWidth}px` } as CSSProperties : undefined}
onDropCapture={() => setDragging(false)} onDragEndCapture={() => setDragging(false)}
onDragOver={(event) => { if (event.dataTransfer.types.includes('Files')) { event.preventDefault(); setDragging(true); } }} onDragLeave={() => setDragging(false)} onDrop={drop}>
<TopBar onBack={() => navigate('/')} 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></>}
<TopBar onBack={() => navigate('/')} onOpenMembers={openMembers} onInvite={owner ? () => { openMembers(); setInviteOpen(true); } : undefined} onOpenPanel={() => setPanelOpen(true)} onOpenSettings={() => setSettingsOpen(true)} searchOpen={searchOpen} filtering={isFiltering(filter)} onToggleSearch={() => { setSearchOpen((value) => !value); if (!desktop) setMobileView('files'); }} />
{desktop ? <><aside className={styles.rail}>{rail}</aside><aside className={styles.navigation} data-space-nav>{navigation}</aside><main className={styles.center}>{chat}</main><FilePaneResize width={fileWidth} onWidth={setFileWidth} /><aside className={styles.files} data-file-pane><PaneTabs tab={sideTab} memberCount={channel?.membersUsed} onTab={setSideTab} />{sideTab === 'files' ? explorer : members}</aside></>
: <>{rail}<aside className={styles.navigation}>{navigation}</aside><nav className={styles.tabs} aria-label="频道视图"><button type="button" aria-pressed={mobileView === 'chat'} onClick={() => setMobileView('chat')}>聊天</button><button type="button" aria-pressed={mobileView === 'files'} onClick={() => setMobileView('files')}>文件</button><button type="button" aria-pressed={mobileView === 'members'} onClick={() => setMobileView('members')}>成员</button></nav><main className={styles.mobileContent}>{mobileView === 'chat' ? chat : mobileView === 'files' ? explorer : members}</main></>}
{error ? <div className={styles.error} role="alert">{error}</div> : null}
{panelOpen ? <Sheet title="设备与账号邀请" onClose={() => setPanelOpen(false)}><DevicePanel /></Sheet> : null}
{inviteOpen ? <InviteSheet onClose={() => setInviteOpen(false)} onInvited={() => setMemberRevision((value) => value + 1)} /> : null}
{panelOpen ? <Sheet title="分享" onClose={() => setPanelOpen(false)}><SharePanel /></Sheet> : null}
{settingsOpen && channel ? <ChannelSettingsSheet channel={channel} onClose={() => setSettingsOpen(false)} onSave={updateSettings} /> : null}
{upload && channel?.maxDownloads !== undefined ? <UploadOptionsSheet files={upload.files} encrypted={channel?.mode === 'enc'} defaultMaxDownloads={channel.maxDownloads} onClose={() => setUpload(undefined)} onUpload={confirmUpload} /> : null}
<Suspense fallback={null}>{previewId ? <PreviewSheet id={previewId} onClose={() => setPreviewId(undefined)} onDelete={deleteItem} /> : null}
+1 -1
View File
@@ -26,7 +26,7 @@ export function AccountDevices() {
<div className={styles.sectionHeader}><strong>我的设备</strong>
<Button variant="ghost" disabled={busy} onClick={() => { void refresh().catch(() => setError('设备清单读取失败')); }}>刷新设备</Button>
</div>
<p className={styles.note}>同一账号可登录多台设备。移除设备会撤销它的登录与空间席位,保留账号的空间成员资格。</p>
<p className={styles.note}>同一账号可登录多台设备。移除设备只会撤销它的登录,不影响账号在各空间里的成员身份。</p>
{devices.map((device) => <div className={styles.deviceRow} key={device.id}>
<label className={styles.deviceName}>
<input aria-label={`${device.name} 的设备名称`} maxLength={80} value={names[device.id] ?? device.name} onChange={(e) => setNames((values) => ({ ...values, [device.id]: e.target.value }))} />
+2 -2
View File
@@ -1,8 +1,8 @@
.dock {
position: fixed;
z-index: 24;
left: max(var(--space-4), env(safe-area-inset-left));
bottom: max(var(--space-4), env(safe-area-inset-bottom));
left: max(var(--space-2), env(safe-area-inset-left));
bottom: max(var(--space-2), env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: var(--space-2);
+1 -1
View File
@@ -46,7 +46,7 @@ export function ChannelList({ kind, title, onOpen }: Props) {
{c.mode === 'enc' ? <><Icon name="lock" size={12} /> 加密</> : '明文'}
</span>
<span>{c.role === 'owner' ? '所有者' : '成员'}</span>
<span>最多 {c.seats} 人</span>
<span>最多 {c.maxMembers} 人</span>
<span>{formatAgo(c.createdAt)}创建</span>
</>
)}
+3 -3
View File
@@ -48,7 +48,7 @@ function CreateForm({ onClose, onCreated, info }: Props & { info: ServerInfoBody
<div className={styles.form}>
<label className={styles.field}>
<span>空间名称(加入者和服务端可见)</span>
<input type="text" value={opts.name ?? ''} placeholder="例如:工作设备" onChange={(e) => set('name', e.target.value)} />
<input type="text" 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>
@@ -57,8 +57,8 @@ function CreateForm({ onClose, onCreated, info }: Props & { info: ServerInfoBody
<label className={styles.field}>
<span>成员人数上限</span>
<select value={opts.seats} onChange={(e) => set('seats', Number(e.target.value))}>
{info.choices.seats.map((n) => <option key={n} value={n}>{n} 人</option>)}
<select value={opts.maxMembers} onChange={(e) => set('maxMembers', Number(e.target.value))}>
{info.choices.max_members.map((n) => <option key={n} value={n}>{n} 人</option>)}
</select>
</label>
+1 -1
View File
@@ -20,7 +20,7 @@ export function InvitationInbox() {
<Button variant="ghost" disabled={Boolean(busy)} onClick={() => { void accountApi.invitations().then(setInvitations).catch(() => setError('读取邀请失败')); }}>刷新</Button>
</div>
{invitations.length === 0 ? <p className={styles.note}>暂无邀请。</p> : invitations.map((row) => <div className={styles.invitationRow} 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>
<span>{row.channelName || `空间 ${row.channel_id.slice(0, 8)}…`}{row.invitedBy ? ` · 邀请人 ${row.invitedBy}` : ''}</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>;
+3 -3
View File
@@ -5,7 +5,8 @@ import { CopyButton } from '@/ui/CopyButton';
import { useDownload } from './download';
import { InlineContent, inlineKindOf } from './InlineContent';
import { useItemSource } from './source';
import { shortDevice, titleOf, versionOf } from './summary';
import { senderLabel } from '@/lib/sender';
import { titleOf, versionOf } from './summary';
import { Tile } from './Tile';
import styles from './CompactRow.module.css';
@@ -25,7 +26,6 @@ interface Props {
export const CompactRow = memo(function CompactRow({ id, selected, checked, onSelect, onToggle }: Props) {
const source = useItemSource();
const item = source.useItem(id);
const nameOf = source.useDeviceNamer();
const download = useDownload();
if (!item) return null;
@@ -40,7 +40,7 @@ export const CompactRow = memo(function CompactRow({ id, selected, checked, onSe
const meta = (
<>
{shortDevice(nameOf(item.deviceId))} · {formatTime(item.ts)} · {formatSize(size)}
{senderLabel(item, source.fallbackSender)} · {formatTime(item.ts)} · {formatSize(size)}
{item.kind === 'file' && item.remaining !== null ? ` · 剩 ${item.remaining} 次` : ''}
{receiving !== null && item.state.kind === 'receiving' ? ` · ${formatTransferProgress(item.state)}` : ''}
{version > 1 ? <span className={styles.tag}>v{version}</span> : null}
+4 -6
View File
@@ -3,15 +3,13 @@ import { formatSize, formatTime } from '@/lib/format';
import { saveBlob } from '@/lib/download';
import { Button } from '@/ui/Button';
import { useItemSource } from './source';
import { shortDevice } from './summary';
import { senderLabel } from '@/lib/sender';
import styles from './HistoryList.module.css';
/** 文件的修改历史:当前版本在最上,旧版本可单独下载 */
export function HistoryList({ item }: { item: FileItem }) {
const { getBlob, useDeviceNamer } = useItemSource();
const namer = useDeviceNamer();
const { getBlob, fallbackSender } = useItemSource();
const versions = item.versions ?? [];
const nameOf = (id: string) => shortDevice(namer(id));
const total = versions.length + 1;
return (
@@ -20,13 +18,13 @@ export function HistoryList({ item }: { item: FileItem }) {
<ul className={styles.list}>
<li className={styles.row}>
<span className={styles.v}>v{total}</span>
<span className={styles.info}>{nameOf(item.deviceId)} · {formatTime(item.ts)} · {formatSize(item.size)}</span>
<span className={styles.info}>{senderLabel(item, fallbackSender)} · {formatTime(item.ts)} · {formatSize(item.size)}</span>
<span className={styles.current}>当前</span>
</li>
{versions.map((v, i) => (
<li key={v.id} className={styles.row}>
<span className={styles.v}>v{total - 1 - i}</span>
<span className={styles.info}>{nameOf(v.deviceId)} · {formatTime(v.ts)} · {formatSize(v.size)}</span>
<span className={styles.info}>{senderLabel(v, fallbackSender)} · {formatTime(v.ts)} · {formatSize(v.size)}</span>
<Button
variant="ghost"
icon="download"
+5 -5
View File
@@ -10,7 +10,8 @@ import { ArchivePreview } from './previews/ArchivePreview';
import { MediaPreview } from './previews/MediaPreview';
import { TextPreview } from './previews/TextPreview';
import { useItemSource } from './source';
import { shortDevice, titleOf } from './summary';
import { senderLabel } from '@/lib/sender';
import { titleOf } from './summary';
import { TextEditor } from './TextEditor';
import styles from './ItemDetail.module.css';
@@ -27,7 +28,6 @@ interface Props {
export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props) {
const source = useItemSource();
const item = source.useItem(id);
const nameOf = source.useDeviceNamer();
const download = useDownload();
const actions = source.actions;
const [editing, setEditing] = useState(false);
@@ -55,7 +55,7 @@ export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props)
<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)}
{senderLabel(item, source.fallbackSender)} · {formatTime(item.ts)} · {formatSize(size)}
{item.kind === 'file' && !hiddenGatedMetadata ? ` · ${item.mime}` : ''}
{item.kind === 'file' && item.remaining !== null ? ` · 剩余 ${item.remaining} 次下载` : ''}
</p>
@@ -71,13 +71,13 @@ export default function ItemDetail({ id, showTitle, readOnly, onDelete }: Props)
{!readOnly && item.kind === 'file' && item.unseen ? (
<div className={`${styles.banner} ${styles.update}`} role="status">
<span>{shortDevice(nameOf(item.deviceId))} 更新了此文件 · {formatTime(item.ts)}</span>
<span>{senderLabel(item, source.fallbackSender)} 更新了此文件 · {formatTime(item.ts)}</span>
<Button variant="ghost" onClick={() => void actions?.markSeen(id)}>知道了</Button>
</div>
) : null}
{!readOnly && item.kind === 'text' && item.conflict ? (
<div className={`${styles.banner} ${styles.conflict}`} role="status">
<span>{shortDevice(item.kind === 'text' ? nameOf(item.conflict?.deviceId) : undefined)} 同时改过:「{item.conflict.text}」</span>
<span>{senderLabel(item.conflict, source.fallbackSender)} 同时改过:「{item.conflict.text}」</span>
<span>
<Button variant="ghost" onClick={() => void actions?.resolveConflict(id, 'theirs')}>用他的</Button>
<Button variant="ghost" onClick={() => void actions?.resolveConflict(id, 'mine')}>保留我的</Button>
+6 -8
View File
@@ -1,5 +1,6 @@
import { createContext, useContext } from 'react';
import type { ArchiveEntry, Item } from '@/core-bridge/types';
import { GUEST_LABEL } from '@/lib/sender';
import { useShareSession } from '@/store/shareSession';
import { useWorkspace } from '@/store/workspace';
@@ -15,8 +16,8 @@ export interface ItemActions {
*/
export interface ItemSource {
useItem: (id: string) => Item | undefined;
/** 返回「设备 id → 名字」的函数 */
useDeviceNamer: () => (deviceId: string | undefined) => string | undefined;
/** 内容没有账号归属时显示的发送者名称 */
fallbackSender: string;
getBlob: (id: string, versionId?: string) => Promise<Blob>;
listArchive: (id: string) => Promise<ArchiveEntry[]>;
/** 只有空间有:分享是只读的 */
@@ -25,10 +26,7 @@ export interface ItemSource {
export const channelSource: ItemSource = {
useItem: (id) => useWorkspace((s) => s.byId[id]),
useDeviceNamer: () => {
const devices = useWorkspace((s) => s.devices);
return (deviceId) => devices.find((d) => d.id === deviceId)?.name ?? deviceId;
},
fallbackSender: GUEST_LABEL,
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
listArchive: (id) => useWorkspace.getState().listArchive(id),
actions: {
@@ -40,8 +38,8 @@ export const channelSource: ItemSource = {
export const shareSource: ItemSource = {
useItem: (id) => useShareSession((s) => s.byId[id]),
// 分享里没有「设备」的概念,内容都来自创建者
useDeviceNamer: () => () => '创建者',
// 分享内容都来自创建者
fallbackSender: '创建者',
getBlob: (id) => useShareSession.getState().getBlob(id),
listArchive: (id) => useShareSession.getState().listArchive(id),
};
-4
View File
@@ -20,10 +20,6 @@ export function titleOf(item: Item): string {
return item.text.split('\n').find((l) => l.trim() !== '')?.trim() ?? '(空)';
}
export function shortDevice(name: string | undefined): string {
return name ?? '未知设备';
}
/** 当前版本号:旧版本数 + 1 */
export function versionOf(item: Item): number {
return item.kind === 'file' ? (item.versions?.length ?? 0) + 1 : 1;
+1 -1
View File
@@ -16,7 +16,7 @@ interface Props {
backLabel?: string;
}
/** 加入前先预览:模式与权限看清楚,确认后才占席位(api.md:GET 预览、POST seats) */
/** 加入前先预览:模式与权限看清楚,确认后才加入(api.md:GET 预览、POST join) */
export function JoinPreview({ link, onJoin, onJoined, onBack, backLabel = '重新扫描' }: Props) {
const [phase, setPhase] = useState<'ask' | 'joining' | 'failed'>('ask');
const [preview, setPreview] = useState<ChannelInfo>();
+8
View File
@@ -0,0 +1,8 @@
import type { SenderInfo } from '@/core-bridge/types';
/** 没有账号归属的内容(未登录访客)的显示名;设备只是账号的属性,不作为发送者展示 */
export const GUEST_LABEL = '访客';
export function senderLabel(source: { author?: SenderInfo } | undefined, fallback = GUEST_LABEL): string {
return source?.author?.displayName || source?.author?.username || fallback;
}
+8 -1
View File
@@ -3,7 +3,7 @@ import type { TextItem } from '@/core-bridge/types';
import { initialData, reduce } from './reduce';
const text = (id: string, ts: number, body = id): TextItem => ({
id, kind: 'text', ts, deviceId: 'self', state: { kind: 'complete' }, text: body,
id, kind: 'text', ts, state: { kind: 'complete' }, text: body,
});
describe('reduce', () => {
@@ -23,6 +23,13 @@ describe('reduce', () => {
state = reduce(state, ungrouped); expect(state.threads[0]?.groupId).toBeUndefined();
state = reduce(state, { t: 'reset' }); expect(state.groups).toEqual([]);
});
it('乐观发送:真实条目带 localId 到达时替换占位项,不重复', () => {
let s = reduce(initialData, { t: 'item', item: text('a', 1) });
s = { ...s, byId: { ...s.byId, 'local:1': { ...text('local:1', 2), localId: 'local:1', state: { kind: 'receiving', progress: 0 } } }, order: ['local:1', ...s.order] };
s = reduce(s, { t: 'item', item: { ...text('real', 2), localId: 'local:1' } });
expect(s.order).toEqual(['real', 'a']);
expect(s.byId['local:1']).toBeUndefined();
});
it('新条目按时间倒序,新的在前', () => {
let s = reduce(initialData, { t: 'item', item: text('a', 1) });
s = reduce(s, { t: 'item', item: text('c', 3) });
+13 -6
View File
@@ -2,7 +2,6 @@ import type {
ChannelInfo,
ChannelGroupInfo,
ConnectionState,
Device,
FolderInfo,
Item,
SenderInfo,
@@ -15,13 +14,14 @@ export interface WorkspaceData {
ready: boolean;
connection: ConnectionState;
channel: ChannelInfo | null;
devices: Device[];
threads: ThreadInfo[];
groups: ChannelGroupInfo[];
folders: FolderInfo[];
locations: Record<string, { threadId: string; folderId?: string; authorAccount?: string }>;
byId: Record<string, Item>;
authors: Record<string, SenderInfo>;
/** 空间成员的资料(昵称、头像),按用户名索引 */
people: Record<string, SenderInfo>;
/** 新的在前 */
order: string[];
snapshot: Snapshot;
@@ -32,13 +32,13 @@ export const initialData: WorkspaceData = {
ready: false,
connection: 'offline',
channel: null,
devices: [],
threads: [],
groups: [],
folders: [],
locations: {},
byId: {},
authors: {},
people: {},
order: [],
snapshot: { text: '', updatedBy: '', updatedAt: 0 },
error: null,
@@ -51,6 +51,12 @@ function insertByTime(order: string[], byId: Record<string, Item>, item: Item):
return next;
}
export function withoutItem(s: WorkspaceData, id: string): WorkspaceData {
if (!(id in s.byId)) return s;
const byId = { ...s.byId }; delete byId[id];
return { ...s, byId, order: s.order.filter((entry) => entry !== id) };
}
export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
switch (e.t) {
case 'structure': {
@@ -64,7 +70,8 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
const byId = Object.fromEntries(Object.entries(s.byId).map(([id, item]) => [id, {
...item, ...(locations[id] ?? {}), ...(authors[id] ? { author: authors[id] } : {}),
}]));
return { ...s, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups,
const profiles = Object.fromEntries(e.people.map(person => [person.username, { username: person.username, displayName: person.displayName || person.username, ...(person.avatar ? { avatar: person.avatar } : {}) }]));
return { ...s, people: profiles, threads: e.state.channels.map(c => ({ ...c, groupId: c.groupId ?? undefined })), groups: e.state.groups,
folders: e.state.folders.map(f => ({ ...f, parentId: f.parentId ?? undefined })), locations, authors, byId };
}
case 'reset':
@@ -77,12 +84,12 @@ export function reduce(s: WorkspaceData, e: WorkspaceEvent): WorkspaceData {
return { ...s, connection: e.state };
case 'channel':
return { ...s, channel: e.info };
case 'devices':
return { ...s, devices: e.devices };
case 'snapshot':
return { ...s, snapshot: e.snapshot };
case 'file-catalog':
case 'item': {
// 乐观渲染:真实条目到达时,同一次更新里替换本地占位项,列表不闪、不重复
if (e.item.localId && e.item.localId !== e.item.id) s = withoutItem(s, e.item.localId);
const known = e.item.id in s.byId;
let item = e.item;
const current = s.byId[item.id];
+1 -1
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import type { Share, TextItem } from '@/core-bridge/types';
import { initialShareData, reduceShare } from './shareReduce';
const text = (id: string, ts: number): TextItem => ({ id, kind: 'text', ts, deviceId: 'x', state: { kind: 'complete' }, text: id });
const text = (id: string, ts: number): TextItem => ({ id, kind: 'text', ts, state: { kind: 'complete' }, text: id });
const share: Share = { id: 's', state: 'draft', mine: true, link: 'l', createdAt: 0, expiresAt: 86400000, ttlSeconds: 1, maxVisitors: 2, maxDownloads: null };
describe('reduceShare', () => {
+29
View File
@@ -0,0 +1,29 @@
import { create } from 'zustand';
export interface Toast {
id: number;
kind: 'ok' | 'error' | 'info';
text: string;
action?: { label: string; run: () => void };
}
interface ToastState {
toasts: Toast[];
push: (toast: Omit<Toast, 'id'>) => void;
dismiss: (id: number) => void;
}
let next = 1;
const SHOW_MS = 4000;
/** 带操作按钮的通知停留更久,给用户时间点 */
const SHOW_WITH_ACTION_MS = 10000;
export const useToast = create<ToastState>()((set, get) => ({
toasts: [],
push: (toast) => {
const id = next++;
set((s) => ({ toasts: [...s.toasts.slice(-3), { ...toast, id }] }));
window.setTimeout(() => get().dismiss(id), toast.action ? SHOW_WITH_ACTION_MS : SHOW_MS);
},
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((toast) => toast.id !== id) })),
}));
+71 -8
View File
@@ -1,7 +1,56 @@
import { create } from 'zustand';
import { api } from '@/core-bridge/instance';
import type { ChannelSettingsPatch, FileUploadOptions } from '@/core-bridge/types';
import { initialData, reduce, type WorkspaceData } from './reduce';
import type { FileItem, Item } from '@/core-bridge/types';
import { previewKindOf } from '@/lib/fileKind';
import { errorMessage } from '@/core-bridge/errors';
import { useAccount } from './account';
import { initialData, reduce, withoutItem, type WorkspaceData } from './reduce';
/** 乐观发送:点发送即在本地画出占位项,后台再加密上传;worker 发布真实条目时带回 localId 替换占位项 */
type PendingJob =
| { kind: 'text'; text: string; threadId?: string }
| { kind: 'file'; file: File; options?: FileUploadOptions; threadId?: string; folderId?: string };
const pendingJobs = new Map<string, PendingJob>();
export const isLocalId = (id: string): boolean => pendingJobs.has(id);
const newLocalId = (): string => `local:${crypto.randomUUID()}`;
function localBase(threadId?: string, folderId?: string) {
const { username, personal } = useAccount.getState();
return {
ts: Date.now(),
state: { kind: 'receiving' as const, progress: 0 },
...(username ? { author: { username, displayName: personal?.displayName || username, ...(personal?.avatar ? { avatar: personal.avatar } : {}) } } : {}),
...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}),
};
}
function putLocal(item: Item): void {
useWorkspace.setState((s) => ({ byId: { ...s.byId, [item.id]: item }, order: [item.id, ...s.order] }));
}
function failLocal(id: string, cause: unknown): void {
useWorkspace.setState((s) => {
const item = s.byId[id];
return item ? { byId: { ...s.byId, [id]: { ...item, state: { kind: 'incomplete' } } }, error: errorMessage(cause) } : { error: errorMessage(cause) };
});
}
function settleLocal(id: string): void {
pendingJobs.delete(id);
useWorkspace.setState((s) => withoutItem(s, id));
}
async function deliver(id: string): Promise<void> {
const job = pendingJobs.get(id);
if (!job) return;
useWorkspace.setState((s) => s.byId[id] ? { byId: { ...s.byId, [id]: { ...s.byId[id], state: { kind: 'receiving', progress: 0 } } } } : s);
try {
if (job.kind === 'text') await api.sendText(job.text, job.threadId, id);
else await api.sendFiles([job.file], job.options, job.threadId, job.folderId, [id]);
settleLocal(id);
} catch (cause) { failLocal(id, cause); }
}
interface Actions {
inviteUser: typeof api.inviteUser;
@@ -10,9 +59,9 @@ interface Actions {
/** 打开(订阅)一个空间:先清空上一个空间的数据,返回取消订阅函数 */
open: (channelId: string) => () => void;
sendText: (text: string, threadId?: string) => Promise<void>;
/** 重试发送失败的本地占位项 */
retrySend: (localId: string) => Promise<void>;
updateChannelSettings: (patch: ChannelSettingsPatch) => Promise<void>;
renameDevice: (endpointHash: string, name: string) => Promise<void>;
releaseDevice: (endpointHash: string) => Promise<void>;
sendFiles: (files: File[], options?: FileUploadOptions, threadId?: string, folderId?: string) => Promise<void>;
createThread: (name: string, groupId?: string) => Promise<string>;
createGroup: typeof api.createGroup;
@@ -41,11 +90,25 @@ export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
useWorkspace.setState(initialData);
return api.connect(channelId, (e) => useWorkspace.setState((s) => reduce(s, e)));
},
sendText: (t, threadId) => api.sendText(t, threadId),
sendText: (text, threadId) => {
const id = newLocalId();
pendingJobs.set(id, { kind: 'text', text, ...(threadId ? { threadId } : {}) });
putLocal({ id, kind: 'text', text, localId: id, ...localBase(threadId) });
return deliver(id);
},
retrySend: (id) => deliver(id),
updateChannelSettings: (patch) => api.updateChannelSettings(patch),
renameDevice: (endpointHash, name) => api.renameDevice(endpointHash, name),
releaseDevice: (endpointHash) => api.releaseDevice(endpointHash),
sendFiles: (f, options, threadId, folderId) => api.sendFiles(f, options, threadId, folderId),
sendFiles: async (files, options, threadId, folderId) => {
const ids = files.map((file) => {
const id = newLocalId();
pendingJobs.set(id, { kind: 'file', file, ...(options ? { options } : {}), ...(threadId ? { threadId } : {}), ...(folderId ? { folderId } : {}) });
const item: FileItem = { id, kind: 'file', localId: id, name: file.name, mime: file.type || 'application/octet-stream', size: file.size, remaining: null,
preview: previewKindOf(file.type, file.name), ...localBase(threadId, folderId), state: { kind: 'receiving', progress: 0, phase: 'encrypting' } };
putLocal(item);
return id;
});
for (const id of ids) await deliver(id);
},
createThread: (name, groupId) => api.createThread(name, groupId),
createGroup: (name) => api.createGroup(name),
renameGroup: (id, name) => api.renameGroup(id, name),
@@ -57,7 +120,7 @@ export const useWorkspace = create<WorkspaceData & Actions>()(() => ({
moveItems: (itemIds, threadId, folderId) => api.moveItems(itemIds, threadId, folderId),
editText: (id, t): Promise<void> => api.editText(id, t, useWorkspace.getState().byId[id]?.threadId),
resolveConflict: (id, c) => api.resolveConflict(id, c),
remove: (id) => api.remove(id),
remove: (id) => { if (!pendingJobs.has(id)) return api.remove(id); settleLocal(id); return Promise.resolve(); },
setSnapshot: (t) => api.setSnapshot(t),
getBlob: (id, v) => api.getBlob(id, v),
markSeen: (id) => api.markSeen(id),